Process management: signals, stdin forwarding, events, output fixes - #16
Merged
Conversation
Ctrl-C during `contree run` used to hard-cancel the entire operation (tearing down the instance) immediately. Now it mirrors a real terminal: send SIGINT to the main process (spid=1) via operation_subprocess_kill and resume streaming so the process's remaining output (e.g. a trailing summary line) still shows up. A second Ctrl-C, or a failure to deliver the signal, falls back to the old hard-cancel behavior.
ListSorter used to drop dict/list values for every formatter, so nested API data (an operation's full result, an event's payload) was unrenderable in JSON output without hand-flattening every field. STREAM formatters (JSON/JSON-pretty) can serialise nested structures natively, so let them keep what tabular formatters still can't. grep's `submatches` relied on the old blanket drop to stay out of normal JSON rows; exclude it explicitly now that JSON keeps nested fields by default.
cmd_show printed decoded stdout/stderr as a separate raw-text write after the JSON row for every stream formatter, including JSON/ JSON-pretty -- mixing a parseable JSON line with raw command output on the same stdout stream broke `contree -o json show UUID | jq`. JSON-like formatters now get stdout/stderr embedded directly in a "result" field (nested, kept intact by ListSorter's new allow_nested), matching run.py's own `_display_operation` convention. The raw-text tail stays, but only for DefaultFormatter.
Replace the old single-shot _read_piped_stdin() (one full buffered read, sent as one payload) with StdInReader/StdinForwarder: stdin is read in a background thread, coalesced into bounded chunks, and forwarded via operation_subprocess_stdin as it arrives, so piping in a large file no longer buffers it all in memory or as one oversized request body. The pipe stays open until local stdin actually closes. The forwarder only starts once the spawned process's first event (spid=1) confirms it's registered server-side -- an earlier write 404s/409s. Detach mode used to skip this confirmation entirely (no event stream of its own), which could silently drop all forwarding and hang the remote process waiting for stdin until timeout; it now reuses the same event-gated path via stream_events_until_close's new stop_after_forwarder, returning once stdin is sent rather than waiting for the command itself.
Adds `contree operation events UUID... (alias ev)`: fetches and prints every recorded event for each operation as one row per event. JSON/JSON-pretty keep the payload nested; table/csv/tsv flatten it to a compact JSON string in the same column, since ListSorter can't represent a nested dict in a tabular row but the body is the point of this command. Events are stored independently of the operation's summarized result and available regardless of whether the operation ran in the foreground, detached, or is still running -- useful when `show`'s result snapshot looks incomplete.
run.md/agent.md/manual.md/skill_body.md described stdin as a single non-TTY read sent as one base64 blob, and said Ctrl-C always cancels the operation -- both stale relative to the background stdin forwarding and the process-signal-first Ctrl-C behavior already in the code. Also documents the new `operation events` command.
Piping any command's output into a pager or `head` and quitting early already gets a clean "Network error: ... Broken pipe" from the handler's own _NETWORK_ERRORS catch (BrokenPipeError subclasses OSError). But formatter.close(), registered as an ExitStack callback, runs during that exception's own unwind and can raise the same BrokenPipeError again while flushing -- outside the try/except that handled it the first time, so it surfaced as a second, unhandled traceback instead of just exiting.
ListSorter now flattens dict/list values to a compact JSON string for tabular formatters instead of dropping them, and TableFormatter drops optional columns (back-to-front) before shrinking any that remain, so wide operation rows still fit a narrow terminal instead of getting badly truncated. Padding/alignment is now TTY-only, so piped output isn't padded to the widest row seen. show/events reuse this instead of hand-rolling their own STREAM vs. tabular branches, and __main__ treats a closed stdout pipe (SIGPIPE convention) as expected shutdown rather than a network error.
auth remove/overwrite, session delete, and skill remove all called input() directly with no EOF handling, so piping a non-interactive invocation (no -y/-f, closed stdin) crashed with an unhandled EOFError instead of a clean error or safe default. Adds a shared ask() prompt helper (types.py) that reprompts on an unrecognized answer and falls back to the given default on EOF or empty input, and wires it into all four confirmation sites.
StdInReader used to outlive the command it read for, racing a long-lived shell's next input read. Move it into pty_utils.py as a platform-conditional ABC (POSIXStdInReader interrupts a blocked read via select() + self-pipe, never touching the fd's blocking mode; Win32StdInReader polls msvcrt) and use it as a context manager in cmd_run so it's always stopped, including if spawn itself raises.
Win32StdInReader always polled msvcrt regardless of whether stdin was a real console, so piped/redirected input was silently dropped and the first-chunk read always timed out into an open-empty-pipe state. Dispatch on os.isatty(fd): console keeps the msvcrt poll, anything else falls back to a plain blocking read like the POSIX reader.
insomnes
requested changes
Aug 19, 2026
A full queue blocks the reader thread in queue.put(), which the wake signal (self-pipe write / stop_event.set()) can't interrupt -- and a blocked Windows piped os.read() has no interrupt primitive at all. Move stop() onto the shared base class: it drains the queue (freeing a producer stuck on put()), joins with a bounded timeout, then forces a close=True sentinel so any consumer unblocks regardless of whether the underlying read itself ever actually exited.
CliClient's global RetryPolicy is unbounded and unsafe, but contree-client documents a 504 on this endpoint as ambiguous -- the chunk may already have been written -- so blind retries can duplicate stdin content. Bypass the retrying call() wrapper for this one request (single attempt only), record any failure on the forwarder instead of just logging at debug, and surface it to the user from cmd_run.
stop_after_forwarder only re-checked whether the forwarder had finished when a new SSE event arrived, so a quiet detached process (forwarder done, no more output) left `run -d` blocked until the next event or process exit. Join the forwarder right where it's started instead of polling is_alive() on a later iteration.
httpx.TransportError, urllib3's HTTPError/TimeoutError, and the other backend-specific transport exceptions don't inherit from OSError, so a failed SIGINT delivery or cancel could escape both handlers as an unhandled network error and skip the hard-cancel fallback entirely. SIGNAL_ERRORS combines ContreeAPIError/OSError with whatever the resolved CliClient backend declares as retryable/nonretryable.
cmd_events routes rows through whatever formatter is active -- DefaultFormatter renders a table, not JSONL -- but the subcommand's help/description/epilog all unconditionally claimed JSONL output. Describe the default as formatter-selected rows and note that -o json is what actually produces JSONL.
cmd_show always embedded decoded stdout/stderr in the "result" field passed to the formatter, but TableFormatter/DefaultFormatter flatten nested dicts into a column -- so the default (non-TTY) path printed the captured stream once in that column and once again raw below. Only attach "result" for structured formatters; keep the raw write for DefaultFormatter unchanged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
run: first Ctrl-C sends SIGINT to the spawned process (spid=1) and keeps streaming its remaining output; a second Ctrl-C (or a failed signal) cancels the whole operation.run: forward local stdin to a running instance in the background instead of buffering it whole; fixes a race where-d/--detachcould return before stdin was fully sent.operation events(new): print an operation's raw event log.output: JSON-like formatters keep nested dict/list fields as-is; tabular formatters (CSV/TSV/Table) flatten them to a compact JSON string instead of silently dropping the field.TableFormatternow drops optional columns before shrinking any that remain, so wide operation rows fit a narrow terminal instead of getting badly truncated. Padding/alignment is TTY-only now, so piped output isn't padded to the widest row seen.show: embed stdout/stderr as JSON fields on the operation result instead of a raw-text tail; unified the STREAM vs. tabular code paths.__main__: treat a closed stdout pipe (e.g.contree run | head) as expected shutdown (exit 141, SIGPIPE convention) instead of logging it as a network error.cli: fix a crash whereauth remove/auth's overwrite prompt,session delete, andskill removeall calledinput()with no EOF handling -- piping a non-interactive invocation with closed stdin and no-y/-fraised an unhandledEOFError. Added a sharedask()prompt helper that reprompts on an unrecognized answer and falls back to the given default on EOF/empty input.