Skip to content

Commit 41b9c5e

Browse files
alexkromanclaude
andauthored
Map Ctrl-C to exit code 130 (cancel) instead of 0 (#202)
## Summary This change establishes a consistent exit code convention for user interrupts (Ctrl-C / SIGTERM) across all interactive and streaming commands. Previously, Ctrl-C was treated as a clean success (exit 0), which made it impossible for callers to distinguish between a normal completion and an interrupt. Now all Ctrl-C paths exit with code 130 (the conventional Unix "cancelled by SIGINT" code), while maintaining clean shutdown behavior. ## Key Changes - **Core interrupt handling**: Added `CANCELLED_EXIT_CODE = 130` constant in `aai_cli/core/errors.py` as the single source of truth for cancel exit codes. - **Telemetry tracking**: Updated `aai_cli/core/telemetry.py` to: - Map exit code 130 to "cancelled" outcome (distinct from generic "error") - Treat raw `KeyboardInterrupt` exceptions as "cancelled" rather than "internal_error" - Preserve the distinction so interrupts don't inflate crash metrics - **Command execution**: Refactored `aai_cli/app/context.py` to: - Extract telemetry/update-check wrapping into `_run_body()` helper - Catch `KeyboardInterrupt` in `run_command()` and map it to `typer.Exit(code=130)` - Preserve the existing deferred-config path that skips telemetry - **Interactive commands**: Updated all interactive/streaming commands to raise `typer.Exit(code=130)` on Ctrl-C instead of returning cleanly: - `aai_cli/commands/llm/_exec.py` (follow mode) - `aai_cli/commands/dictate/_exec.py` - `aai_cli/commands/agent/_exec.py` - `aai_cli/commands/agent_cascade/_exec.py` - `aai_cli/commands/share/_exec.py` - `aai_cli/commands/webhooks/_listen.py` - `aai_cli/streaming/session.py` (batch streaming) - `aai_cli/init/runner.py` (dev server) - **Tests**: Updated all test expectations to verify exit code 130 on Ctrl-C, with clarified comments explaining the cancel semantics. ## Implementation Details - The change preserves all existing cleanup behavior (renderer closes, tunnels tear down, etc.) — only the exit code changes. - Commands with no interactive handler (raw `KeyboardInterrupt` reaching `run_command`) now exit 130 instead of Click's default exit 1. - Telemetry correctly identifies 130 as "cancelled" so it doesn't count toward crash rates. - The `config path` command (which tolerates unreadable config) continues to skip telemetry/update-check wrappers as before. - Callers can now reliably use exit code 130 to detect interrupts in shell scripts: `assembly stream && next_command` will not run `next_command` if the user pressed Ctrl-C. https://claude.ai/code/session_01BRiB7KpAgb833DkxHh5ntc Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2de6b3d commit 41b9c5e

25 files changed

Lines changed: 176 additions & 75 deletions

aai_cli/app/context.py

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import keyring.errors
88
import typer
99

10-
from aai_cli.core import config, debuglog, env, environments, stdio, telemetry
10+
from aai_cli.core import config, debuglog, env, environments, errors, stdio, telemetry
1111
from aai_cli.core.environments import Environment
1212
from aai_cli.core.errors import APIError, CLIError, NotAuthenticated
1313
from aai_cli.ui import output, update_check
@@ -196,6 +196,30 @@ def _auto_login_and_exit(state: AppState, *, json_mode: bool) -> NoReturn:
196196
)
197197

198198

199+
def _run_body(
200+
ctx: typer.Context,
201+
fn: Callable[[AppState, bool], None],
202+
*,
203+
json_mode: bool,
204+
has_deferred: bool,
205+
) -> None:
206+
"""Run the command body, wrapped (or not) by the telemetry/update-check machinery.
207+
208+
Called inside ``run_command``'s ``try`` so telemetry sees the raw CLIError (and its
209+
error_type) before it's folded into a ``typer.Exit``. ``config path`` opts into
210+
``has_deferred`` (a corrupt config.toml): it reports a contents-independent location,
211+
so it runs just the body and skips the telemetry/update-check wrappers that would
212+
re-parse the broken config.
213+
"""
214+
state: AppState = ctx.obj
215+
if has_deferred:
216+
fn(state, json_mode)
217+
return
218+
with telemetry.track(ctx.command_path):
219+
fn(state, json_mode)
220+
update_check.maybe_notify(json_mode=json_mode)
221+
222+
199223
def run_command(
200224
ctx: typer.Context,
201225
fn: Callable[[AppState, bool], None],
@@ -219,23 +243,20 @@ def run_command(
219243
# exit, without telemetry/update-check, both of which would just re-parse it.
220244
_fail(deferred, json_mode=json_mode)
221245
try:
222-
if deferred is not None:
223-
# `config path` opted in (tolerate_unreadable_config): it reports a
224-
# contents-independent location, so run just the body and skip the
225-
# telemetry/update-check wrappers that re-parse the broken config.
226-
fn(state, json_mode)
227-
else:
228-
# Inside the try so telemetry sees the raw CLIError (and its error_type)
229-
# before it's folded into a typer.Exit below.
230-
with telemetry.track(ctx.command_path):
231-
fn(state, json_mode)
232-
update_check.maybe_notify(json_mode=json_mode)
246+
_run_body(ctx, fn, json_mode=json_mode, has_deferred=deferred is not None)
233247
except NotAuthenticated as err:
234248
if not auto_login or not _should_auto_login(err):
235249
_fail(err, json_mode=json_mode)
236250
_auto_login_and_exit(state, json_mode=json_mode)
237251
except CLIError as err:
238252
_fail(err, json_mode=json_mode)
253+
except KeyboardInterrupt:
254+
# Ctrl-C (and the SIGTERM that core.signals routes through the same path) is a
255+
# cancel, not a crash: exit 130 so `assembly … && next` composes correctly,
256+
# instead of the bare "Aborted!" / exit 1 Click would otherwise produce. The
257+
# interactive commands print their own "Stopped." before re-raising; this is the
258+
# single place the cancel maps to its exit code.
259+
raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None
239260
except (typer.Exit, typer.Abort, BrokenPipeError):
240261
# Deliberate control flow (and the closed-pipe contract handled in main.run);
241262
# these must reach Click/the entry point untouched.

aai_cli/commands/agent/_exec.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from aai_cli.agent.voices import VOICE_NAMES
2323
from aai_cli.app.agent_shared import resolve_system_prompt as _resolve_system_prompt
2424
from aai_cli.app.context import AppState
25-
from aai_cli.core import choices, client, signals
25+
from aai_cli.core import choices, client, errors, signals
2626
from aai_cli.core.errors import UsageError
2727
from aai_cli.streaming.session import resolve_output_modes
2828
from aai_cli.streaming.sources import FileSource
@@ -135,7 +135,10 @@ def run_agent(opts: AgentOptions, state: AppState, *, json_mode: bool) -> None:
135135
with signals.terminate_as_interrupt():
136136
run_session(api_key, renderer=renderer, player=player, mic=audio, config=run_config)
137137
except KeyboardInterrupt:
138+
# Ctrl-C (or a supervisor's SIGTERM) ends the session cleanly, then exits 130
139+
# (cancel) so the interrupt isn't reported to a caller as success.
138140
renderer.stopped()
141+
raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None
139142
except BrokenPipeError as exc:
140143
# Downstream consumer (e.g. `| head`) closed the pipe; stop quietly.
141144
raise typer.Exit(code=0) from exc

aai_cli/commands/agent_cascade/_exec.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from aai_cli.agent_cascade.config import DEFAULT_MAX_HISTORY, CascadeConfig
2323
from aai_cli.app.agent_shared import resolve_system_prompt as _resolve_system_prompt
2424
from aai_cli.app.context import AppState
25-
from aai_cli.core import choices, client, config_builder, llm, signals
25+
from aai_cli.core import choices, client, config_builder, errors, llm, signals
2626
from aai_cli.core.errors import UsageError
2727
from aai_cli.streaming import turn_presets
2828
from aai_cli.streaming.session import resolve_output_modes
@@ -218,7 +218,10 @@ def run_agent_cascade(opts: AgentCascadeOptions, state: AppState, *, json_mode:
218218
with signals.terminate_as_interrupt():
219219
engine.run_cascade(renderer=renderer, player=player, config=config, deps=deps)
220220
except KeyboardInterrupt:
221+
# Ctrl-C (or a supervisor's SIGTERM) ends the cascade cleanly, then exits 130
222+
# (cancel) so the interrupt isn't reported to a caller as success.
221223
renderer.stopped()
224+
raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None
222225
except BrokenPipeError as exc:
223226
# Downstream consumer (e.g. `| head`) closed the pipe; stop quietly.
224227
raise typer.Exit(code=0) from exc

aai_cli/commands/dictate/_exec.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212

1313
from dataclasses import dataclass
1414

15+
import typer
16+
1517
from aai_cli.app.context import AppState
16-
from aai_cli.core import choices, stdio, sync_stt
18+
from aai_cli.core import choices, errors, stdio, sync_stt
1719
from aai_cli.core.config_builder import split_csv
1820
from aai_cli.core.hotkey import CTRL_C, CTRL_D, ESC, TerminalKeys
1921
from aai_cli.core.microphone import MicrophoneSource
@@ -222,5 +224,7 @@ def run_dictate(opts: DictateOptions, state: AppState, *, json_mode: bool) -> No
222224
)
223225
_session(keys, api_key, opts, state, json_mode=json_mode, single=single)
224226
except KeyboardInterrupt:
225-
# Ctrl-C is the normal "done dictating" signal: end cleanly, not as an error.
226-
return
227+
# Ctrl-C cancels dictation, so it exits 130 (cancel) — distinct from `q`, which
228+
# ends the session normally (exit 0). The with-block above already restored the
229+
# terminal on the way out.
230+
raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None

aai_cli/commands/llm/_exec.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@
1212
from dataclasses import dataclass
1313
from pathlib import Path
1414

15+
import typer
1516
from rich.markup import escape
1617

1718
from aai_cli.app.context import AppState
18-
from aai_cli.core import choices, client, stdio
19+
from aai_cli.core import choices, client, errors, stdio
1920
from aai_cli.core import llm as gateway
2021
from aai_cli.core.errors import UsageError
2122
from aai_cli.ui import output
@@ -161,16 +162,16 @@ def ask(transcript_text: str) -> str:
161162
return gateway.content_of(response)
162163

163164
transcript: list[str] = []
164-
interrupted = False
165165
with FollowRenderer(json_mode=json_mode) as render:
166-
# Ctrl-C is the normal "stop watching" signal -> exit cleanly (code 0).
166+
# Ctrl-C is the normal "stop watching" signal: exit 130 (cancel) rather than
167+
# masquerading as a clean finish — the renderer's panel closes on the way out.
167168
try:
168169
for turn in stdio.iter_piped_stdin_lines():
169170
transcript.append(turn)
170171
render(ask("\n".join(transcript)), len(transcript))
171172
except KeyboardInterrupt:
172-
interrupted = True
173-
if not transcript and not interrupted:
173+
raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None
174+
if not transcript:
174175
# An empty pipe (`assembly llm -f "…" </dev/null`) would otherwise exit 0
175176
# silently, having asked nothing.
176177
raise UsageError(_FOLLOW_STDIN_MESSAGE)

aai_cli/commands/share/_exec.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from aai_cli.app.context import AppState
1818
from aai_cli.core import env as os_env
19+
from aai_cli.core import errors
1920
from aai_cli.core.errors import CLIError
2021
from aai_cli.init import devserver, procfile, runner, tunnel
2122
from aai_cli.ui import output, steps
@@ -86,9 +87,9 @@ def run_share(opts: ShareOptions, state: AppState, *, json_mode: bool) -> None:
8687
output.emit(payload, _render_share, json_mode=json_mode)
8788
server.wait()
8889
except KeyboardInterrupt:
89-
# Ctrl-C is the expected way to stop a foreground share; the finally
90-
# block below tears down the tunnel and server.
91-
pass
90+
# Ctrl-C is the expected way to stop a foreground share: tear down (finally,
91+
# below) then exit 130 (cancel) so it isn't reported to a caller as success.
92+
raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None
9293
finally:
9394
tunnel.terminate(proxy)
9495
tunnel.terminate(server)

aai_cli/commands/webhooks/_listen.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
from __future__ import annotations
1212

13-
import contextlib
1413
import json
1514
import threading
1615
from collections.abc import Callable
@@ -180,9 +179,10 @@ def run_listen(opts: ListenOptions, *, json_mode: bool) -> None:
180179
"check it for errors.",
181180
)
182181
_announce(public_url, port, json_mode=json_mode)
183-
# Ctrl-C is the expected way to stop a foreground listener.
184-
with contextlib.suppress(KeyboardInterrupt):
185-
server.serve_forever()
182+
# Ctrl-C is the expected way to stop a foreground listener; let it propagate so
183+
# the command exits 130 (cancel). The finally below still closes the socket and
184+
# tears down the tunnel.
185+
server.serve_forever()
186186
finally:
187187
# Close here, not via `with server:` — a tunnel failure raises before the
188188
# serve block, and the bound listening socket must not outlive the command.

aai_cli/core/errors.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@
2121

2222
from aai_cli.core import jsonshape
2323

24+
# The conventional Unix "cancelled by Ctrl-C" code (128 + SIGINT). Not a CLIError
25+
# exit_code — a cancel isn't a failure with a machine-readable error type — but the
26+
# single source of truth scripts can rely on for "the user (or a supervisor's SIGTERM,
27+
# routed through the same clean-stop path) interrupted the command".
28+
CANCELLED_EXIT_CODE = 130
29+
2430

2531
class CLIError(Exception):
2632
"""Base error carrying an exit code, a machine-readable type, and an optional

aai_cli/core/signals.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
33
`stream` (and the other realtime commands) treat Ctrl-C as a clean "user stopped"
44
signal: SIGINT arrives as a ``KeyboardInterrupt`` that the streaming lifecycle catches
5-
to flush its closing state and exit 0. This module lets an *external* stop reach that
6-
same path — see ``terminate_as_interrupt``.
5+
to flush its closing state and exit 130 (the cancel code). This module lets an
6+
*external* stop reach that same path — see ``terminate_as_interrupt``.
77
"""
88

99
from __future__ import annotations

aai_cli/core/telemetry.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
import typer
2727

2828
from aai_cli import __version__
29-
from aai_cli.core import argscan, config, env, procs
29+
from aai_cli.core import argscan, config, env, errors, procs
3030
from aai_cli.core.errors import CLIError
3131

3232
ENV_DISABLED = "AAI_TELEMETRY_DISABLED"
@@ -236,14 +236,25 @@ def _safe_dispatch(
236236
return
237237

238238

239+
def _exit_outcome(code: int) -> str:
240+
"""Map a ``typer.Exit`` code to a telemetry outcome: 0 succeeds, 130 is a cancel
241+
(the Ctrl-C path that exits via ``typer.Exit``), anything else is an error."""
242+
if code == 0:
243+
return "success"
244+
if code == errors.CANCELLED_EXIT_CODE:
245+
return "cancelled"
246+
return "error"
247+
248+
239249
@contextmanager
240250
def track(command: str) -> Generator[None]:
241251
"""Record one command run, deriving the outcome from whatever escapes the body.
242252
243253
CLIErrors keep their machine-readable ``error_type`` as the outcome; a
244-
deliberate ``typer.Exit`` maps through its code; anything else is the
245-
catch-all ``internal_error``. The body's exception always re-raises —
246-
tracking observes control flow, never alters it.
254+
deliberate ``typer.Exit`` maps through its code (a 130 reads as ``cancelled``);
255+
a Ctrl-C is ``cancelled``; anything else is the catch-all ``internal_error``.
256+
The body's exception always re-raises — tracking observes control flow, never
257+
alters it.
247258
"""
248259
if not is_enabled():
249260
yield
@@ -263,8 +274,13 @@ def track(command: str) -> Generator[None]:
263274
raise
264275
except typer.Exit as exc:
265276
code = exc.exit_code
266-
outcome = "success" if code == 0 else "error"
267-
_safe_dispatch(command, started, outcome=outcome, exit_code=code)
277+
_safe_dispatch(command, started, outcome=_exit_outcome(code), exit_code=code)
278+
raise
279+
except KeyboardInterrupt:
280+
# A Ctrl-C that reached here (a command with no interactive handler) is a
281+
# cancel, not an internal_error — record it as such so it doesn't inflate the
282+
# crash rate, then let run_command map it to exit 130.
283+
_safe_dispatch(command, started, outcome="cancelled", exit_code=errors.CANCELLED_EXIT_CODE)
268284
raise
269285
except BaseException as exc:
270286
_safe_dispatch(

0 commit comments

Comments
 (0)