typeclaw 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ import { join } from 'node:path'
3
3
 
4
4
  import type { MinimalSessionOrigin } from '@/agent/session-meta'
5
5
 
6
+ import { previewForHint } from './preview'
6
7
  import { replayJsonl } from './replay'
7
8
 
8
9
  export type SessionSummary = {
@@ -139,18 +140,29 @@ async function peekSession(
139
140
  onWarn?: (msg: string) => void,
140
141
  ): Promise<{ origin: MinimalSessionOrigin | null; firstPrompt: string | null }> {
141
142
  let origin: MinimalSessionOrigin | null = null
142
- let firstPrompt: string | null = null
143
+ const userTexts: string[] = []
143
144
  let bytesRead = 0
144
145
  for await (const event of replayJsonl(path, onWarn !== undefined ? { onWarn } : {})) {
145
146
  if (event.cat === 'meta' && origin === null) origin = event.origin
146
- if (event.cat === 'user' && firstPrompt === null) firstPrompt = event.text
147
- if (origin !== null && firstPrompt !== null) break
147
+ if (event.cat === 'user' && userTexts.length < MAX_PREVIEW_CANDIDATES) userTexts.push(event.text)
148
+ if (origin !== null && userTexts.length >= MAX_PREVIEW_CANDIDATES) break
148
149
  bytesRead += approximateSize(event)
149
150
  if (bytesRead > PREVIEW_MAX_BYTES) break
150
151
  }
152
+ // Resolve the hint after the loop so origin (which selects the extraction
153
+ // strategy) is known even if a user event precedes the meta event. A turn
154
+ // that is pure injected preamble yields null, so fall through to the next user
155
+ // turn for a useful glance.
156
+ let firstPrompt: string | null = null
157
+ for (const text of userTexts) {
158
+ firstPrompt = previewForHint(origin, text)
159
+ if (firstPrompt !== null) break
160
+ }
151
161
  return { origin, firstPrompt }
152
162
  }
153
163
 
164
+ const MAX_PREVIEW_CANDIDATES = 5
165
+
154
166
  function approximateSize(event: { ts: number }): number {
155
167
  return JSON.stringify(event).length
156
168
  }
@@ -0,0 +1,182 @@
1
+ import {
2
+ Key,
3
+ Markdown,
4
+ matchesKey,
5
+ ProcessTerminal,
6
+ type Component,
7
+ type Terminal,
8
+ Text,
9
+ TUI,
10
+ } from '@mariozechner/pi-tui'
11
+
12
+ import { formatToolEnd, formatToolStart, formatUserPromptHistory } from '@/tui/format'
13
+ import { colors, markdownTheme } from '@/tui/theme'
14
+
15
+ import { streamSessionEvents, type LiveSourceFactory, type StreamPhase } from './index'
16
+ import { originLabel, shortSessionId } from './label'
17
+ import type { SessionSummary } from './session-list'
18
+ import type { InspectEvent, InspectFilter } from './types'
19
+
20
+ export type TranscriptViewOutcome = { reason: 'back' | 'exit' }
21
+
22
+ export type TranscriptViewOptions = {
23
+ summary: SessionSummary
24
+ filter: InspectFilter
25
+ sinceMs: number | undefined
26
+ liveSource?: LiveSourceFactory
27
+ createTerminal?: () => Terminal
28
+ }
29
+
30
+ // Read-only pi-tui transcript viewer: the rich counterpart to the line
31
+ // renderer, matching the live TUI's look (markdown assistant blocks, formatted
32
+ // tool panels) but with NO editor and NO websocket writes. It owns its own
33
+ // raw-mode terminal, so the caller must NOT wrap it in a tail scope (a second
34
+ // raw-stdin owner would corrupt input — same rule as the writable tui branch).
35
+ // esc -> back to the list; q / ctrl-c -> exit.
36
+ export function createTranscriptView(opts: TranscriptViewOptions) {
37
+ async function run(): Promise<TranscriptViewOutcome> {
38
+ const terminal = (opts.createTerminal ?? (() => new ProcessTerminal()))()
39
+ const tui = new TUI(terminal)
40
+
41
+ const status = new Text(statusLine('replay'), 0, 0)
42
+ tui.addChild(new Text(header(opts.summary), 0, 0))
43
+ tui.addChild(status)
44
+ tui.start()
45
+ tui.requestRender()
46
+
47
+ // The status line is pinned last (no editor to pin, unlike createTui). Each
48
+ // appended history entry is inserted before it: strip status, add entry,
49
+ // re-add status.
50
+ const append = (component: Component): void => {
51
+ tui.removeChild(status)
52
+ tui.addChild(component)
53
+ tui.addChild(status)
54
+ }
55
+
56
+ let settle: ((o: TranscriptViewOutcome) => void) | null = null
57
+ const outcome = new Promise<TranscriptViewOutcome>((resolve) => {
58
+ settle = resolve
59
+ })
60
+ const finish = (reason: TranscriptViewOutcome['reason']): void => {
61
+ if (settle === null) return
62
+ const fn = settle
63
+ settle = null
64
+ tui.stop()
65
+ fn({ reason })
66
+ }
67
+
68
+ tui.addInputListener((data) => {
69
+ if (matchesKey(data, Key.ctrl('c')) || data === 'q') {
70
+ finish('exit')
71
+ return { consume: true }
72
+ }
73
+ if (matchesKey(data, Key.escape)) {
74
+ finish('back')
75
+ return { consume: true }
76
+ }
77
+ return undefined
78
+ })
79
+
80
+ const abort = new AbortController()
81
+ // Drive the shared read pipeline into the component tree. Batch renders
82
+ // during replay (one render at replay-end) to avoid redraw storms on long
83
+ // transcripts; render per event once live.
84
+ let live = false
85
+ const onEvent = (event: InspectEvent): void => {
86
+ append(componentFor(event))
87
+ if (live) tui.requestRender()
88
+ }
89
+ const onPhase = (phase: StreamPhase): void => {
90
+ if (phase.phase === 'replay-end') {
91
+ tui.requestRender()
92
+ } else if (phase.phase === 'live-start') {
93
+ append(new Text(divider(phase.sessionLive ? 'live' : 'live (broadcasts only)'), 0, 0))
94
+ live = true
95
+ tui.requestRender()
96
+ }
97
+ }
98
+
99
+ const pump = streamSessionEvents({
100
+ summary: opts.summary,
101
+ filter: opts.filter,
102
+ sinceMs: opts.sinceMs,
103
+ onEvent,
104
+ onPhase,
105
+ signal: abort.signal,
106
+ ...(opts.liveSource !== undefined ? { liveSource: opts.liveSource } : {}),
107
+ blockWhenReplayOnly: true,
108
+ })
109
+
110
+ const result = await outcome
111
+ // The viewer was dismissed: stop the pipeline (replay-only idle wait, or a
112
+ // live tail) so it does not run past the closed terminal.
113
+ abort.abort()
114
+ await pump.catch(() => {})
115
+ return result
116
+ }
117
+
118
+ return { run }
119
+ }
120
+
121
+ export function componentFor(event: InspectEvent): Component {
122
+ switch (event.cat) {
123
+ case 'assistant':
124
+ return new Markdown(event.text, 0, 0, markdownTheme)
125
+ case 'user':
126
+ return new Text(formatUserPromptHistory(event.text), 0, 0)
127
+ case 'tool':
128
+ return new Text(
129
+ event.phase === 'start'
130
+ ? formatToolStart(event.name, event.args)
131
+ : formatToolEnd(event.name, event.isError === true, event.result, event.durationMs ?? 0),
132
+ 0,
133
+ 0,
134
+ )
135
+ case 'thinking':
136
+ return new Text(colors.gray(event.redacted === true ? '[redacted thinking]' : event.text), 0, 0)
137
+ case 'meta':
138
+ return new Text(colors.dim(`origin: ${originLabel(event.origin)}`), 0, 0)
139
+ case 'error':
140
+ return new Text(
141
+ event.stopReason === 'aborted' ? colors.yellow(event.message) : colors.red(`error: ${event.message}`),
142
+ 0,
143
+ 0,
144
+ )
145
+ case 'done':
146
+ return new Text(colors.dim(doneSummary(event)), 0, 0)
147
+ case 'broadcast':
148
+ return new Text(colors.dim(`broadcast: ${compact(event.payload)}`), 0, 0)
149
+ case 'cron-fire':
150
+ return new Text(colors.dim(`cron ${event.jobId} fired`), 0, 0)
151
+ case 'inbound':
152
+ return new Text(colors.cyan(`[${event.decision}] ${event.authorName}: ${event.text}`), 0, 0)
153
+ }
154
+ }
155
+
156
+ function doneSummary(event: Extract<InspectEvent, { cat: 'done' }>): string {
157
+ const parts = [`${event.input} in / ${event.output} out tok`, `$${event.cost.toFixed(4)}`]
158
+ if (event.stopReason !== undefined) parts.push(`stop=${event.stopReason}`)
159
+ return parts.join(' · ')
160
+ }
161
+
162
+ function compact(payload: unknown): string {
163
+ if (payload !== null && typeof payload === 'object' && 'kind' in payload) {
164
+ return String((payload as { kind: unknown }).kind)
165
+ }
166
+ const s = JSON.stringify(payload) ?? String(payload)
167
+ return s.length > 200 ? `${s.slice(0, 200)}…` : s
168
+ }
169
+
170
+ function header(summary: SessionSummary): string {
171
+ const id = shortSessionId(summary.sessionId)
172
+ const label = summary.origin === null ? '(unknown origin)' : originLabel(summary.origin)
173
+ return colors.dim(`─── ${id} · ${label} ───`)
174
+ }
175
+
176
+ function statusLine(_phase: 'replay'): string {
177
+ return colors.dim('── read-only · esc to return to list · q to quit ──')
178
+ }
179
+
180
+ function divider(text: string): string {
181
+ return colors.dim(`─── ${text} ───`)
182
+ }
@@ -0,0 +1,97 @@
1
+ import { createTui, type TuiRunResult } from '@/tui'
2
+
3
+ import type { RunInspectResult } from './index'
4
+
5
+ export type TuiRunner = (opts: {
6
+ url: string
7
+ initialPrompt?: string
8
+ expectedVersion?: string
9
+ onVersionMismatch?: (info: { expected: string; actual: string }) => void
10
+ }) => Promise<TuiRunResult>
11
+
12
+ export type RunTuiViewerOptions = {
13
+ resolveUrl: () => Promise<string>
14
+ initialPrompt?: string
15
+ expectedVersion?: string
16
+ onVersionMismatch?: (info: { expected: string; actual: string }) => void
17
+ stderr: (line: string) => void
18
+ runTui?: TuiRunner
19
+ reconnectMaxAttempts?: number
20
+ reconnectBackoffMs?: number
21
+ sleep?: (ms: number) => Promise<void>
22
+ }
23
+
24
+ const DEFAULT_RECONNECT_MAX_ATTEMPTS = 30
25
+ const DEFAULT_RECONNECT_BACKOFF_MS = 1_000
26
+
27
+ // The interactive read+write viewer branch. Unlike session/logs, this does NOT
28
+ // run under the loop's tail scope: createTui owns its own raw-mode pi-tui
29
+ // terminal and esc/ctrl-c handling, so a second raw-stdin owner would corrupt
30
+ // input. The branch maps createTui's outcome into the loop's result contract:
31
+ // detach → back to the list (escToPicker), exit → terminate, lostConnection →
32
+ // reconnect (the self-restart case), connectFailed → error result.
33
+ export async function runTuiViewer(opts: RunTuiViewerOptions): Promise<RunInspectResult> {
34
+ const runTui = opts.runTui ?? defaultRunTui
35
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)))
36
+ const maxAttempts = opts.reconnectMaxAttempts ?? DEFAULT_RECONNECT_MAX_ATTEMPTS
37
+ const backoffMs = opts.reconnectBackoffMs ?? DEFAULT_RECONNECT_BACKOFF_MS
38
+
39
+ let initialPrompt = opts.initialPrompt
40
+ let attempt = 0
41
+
42
+ while (true) {
43
+ let url: string
44
+ try {
45
+ url = await opts.resolveUrl()
46
+ } catch (err) {
47
+ return { ok: false, exitCode: 1, reason: errorMessage(err) }
48
+ }
49
+
50
+ let result: TuiRunResult
51
+ try {
52
+ result = await runTui({
53
+ url,
54
+ ...(initialPrompt !== undefined ? { initialPrompt } : {}),
55
+ ...(opts.expectedVersion !== undefined ? { expectedVersion: opts.expectedVersion } : {}),
56
+ ...(opts.onVersionMismatch !== undefined ? { onVersionMismatch: opts.onVersionMismatch } : {}),
57
+ })
58
+ } catch (err) {
59
+ return { ok: false, exitCode: 1, reason: errorMessage(err) }
60
+ }
61
+
62
+ if (result.reason === 'detach') return { ok: true, exitCode: 0, escToPicker: true }
63
+ if (result.reason === 'exit') return { ok: true, exitCode: result.exitCode }
64
+ if (result.reason === 'connectFailed') return { ok: false, exitCode: 1, reason: 'connection failed' }
65
+
66
+ // lostConnection: the WS dropped post-handshake (self-restart, network
67
+ // blip). Re-resolve the URL because the host port can change across
68
+ // container lifecycles, then reconnect. Clear the initial prompt so a
69
+ // reconnect resuming the same session does not re-send it to the LLM.
70
+ initialPrompt = undefined
71
+ attempt += 1
72
+ if (attempt > maxAttempts) {
73
+ return { ok: false, exitCode: 1, reason: `disconnected; gave up after ${maxAttempts} reconnect attempts` }
74
+ }
75
+ opts.stderr(`reconnecting (attempt ${attempt}/${maxAttempts})...`)
76
+ await sleep(backoffMs)
77
+ }
78
+ }
79
+
80
+ function defaultRunTui(opts: {
81
+ url: string
82
+ initialPrompt?: string
83
+ expectedVersion?: string
84
+ onVersionMismatch?: (info: { expected: string; actual: string }) => void
85
+ }): Promise<TuiRunResult> {
86
+ return createTui({
87
+ url: opts.url,
88
+ exit: () => {},
89
+ ...(opts.initialPrompt !== undefined ? { initialPrompt: opts.initialPrompt } : {}),
90
+ ...(opts.expectedVersion !== undefined ? { expectedVersion: opts.expectedVersion } : {}),
91
+ ...(opts.onVersionMismatch !== undefined ? { onVersionMismatch: opts.onVersionMismatch } : {}),
92
+ }).run()
93
+ }
94
+
95
+ function errorMessage(err: unknown): string {
96
+ return err instanceof Error ? err.message : String(err)
97
+ }
@@ -20,7 +20,7 @@ Before you pick an action, classify the inbound. Skipping this step is how a PR
20
20
 
21
21
  1. **Is this a PR, and do I have an unresolved blocking obligation on it?** On any `pr:N` inbound, before anything else, check whether you owe this PR a verdict you have not yet landed. Check **both** signals below — checking only formal review state misses the very failure this gate exists to catch, because a prior block may never have become formal state:
22
22
  - **Formal review state.** Run the step-1 re-review query in the PR review flow (`gh api --paginate --slurp /repos/owner/repo/pulls/<N>/reviews --jq '…'` filtered to `{CHANGES_REQUESTED, APPROVED}`). If your latest **blocking decision** is `CHANGES_REQUESTED`, you have a live sticky block.
23
- - **Flat-comment blockers you authored.** A prior "request changes" may have been posted as a plain PR/issue comment instead of a formal review — in which case **no `CHANGES_REQUESTED` row exists** and the query above returns empty even though you blocked the PR in prose. So also scan your own recent comments (`gh api /repos/owner/repo/issues/<N>/comments --jq '[.[] | select(.user.login == "<your-login>")]'`) for one that requested changes / raised blockers and has not since been superseded by a formal review or a clear retraction. For routing, a blocking comment you wrote is as binding as a formal `CHANGES_REQUESTED`.
23
+ - **Flat-comment blockers you authored.** A prior "request changes" may have been posted as a plain PR/issue comment instead of a formal review — in which case **no `CHANGES_REQUESTED` row exists** and the query above returns empty even though you blocked the PR in prose. So also scan your own recent comments (`gh api /repos/owner/repo/issues/<N>/comments --jq '[.[] | select(.user.login == "<your-login>")]'`) for one that requested changes / raised blockers and has not since been superseded by a formal review or a clear retraction. For routing, a blocking comment you wrote is as binding as a formal `CHANGES_REQUESTED`. **A courtesy acknowledgement is not a retraction.** A reply you posted like "nice, that closes the hole" / "thanks, looks good" / "✅" does **not** supersede or retract a blocker you raised — it is a chat ack, not a verdict, and it carries no review state. The blocker stays binding until you land a **formal** `APPROVE`/`REQUEST_CHANGES` (or dismiss your prior review). So when an earlier "✅ thanks" of yours is the only thing between your blocker and the author's address-comment, the blocker is **still live** and this inbound is a re-review — do not let your own ack downgrade it.
24
24
 
25
25
  If **either** signal shows an unresolved blocker you raised, this inbound is a **re-review** — go to the **PR review flow** regardless of how it is phrased. An author commenting "fixed both issues" / "addressed your feedback" / "pushed a fix" is a re-review trigger, **not** a thread-resolve trigger. A re-review is closed by re-deciding the verdict and landing a **formal** review via `POST /pulls/<N>/reviews`: `APPROVE` clears a sticky `CHANGES_REQUESTED`; a comment or a flat reply clears neither a formal block nor a flat-comment blocker — it just strands the verdict again, which is the original bug.
26
26
 
@@ -201,6 +201,8 @@ A review you posted leaves inline comment threads open on the PR. When one of **
201
201
 
202
202
  **The base principle: whoever opened the thread closes it.** Resolve only threads whose root comment **you** authored. Never resolve a human reviewer's thread on your behalf — that erases their open question. The thread you can resolve is the one you started; the inbound that brings you here is a **review-thread reply on `pr:N` with `thread` set**, replying inside a thread you opened.
203
203
 
204
+ > **Thread cleanup is not the same as discharging a PR-level block.** Resolving an inline thread closes that one thread; it carries **no** review state and does **not** clear a PR-level blocking obligation (a sticky `CHANGES_REQUESTED`, or a flat blocker you authored on the PR conversation). Those are two separate duties. If triage #1 found a live PR-level block, that inbound is a **re-review** and the PR review flow wins over this section — you owe a formal `APPROVE`/`REQUEST_CHANGES`, and resolving threads (or a chat ✅) must **not** be your final response. Reach this section only for a thread-scoped reply on a PR where you owe no PR-level verdict (triage #1 came back clean). When in doubt, re-run triage #1 first.
205
+
204
206
  ### When a thread counts as addressed
205
207
 
206
208
  Do not resolve on a bare "done" claim. A reply that says "fixed" is a prompt to check, not proof. Before resolving, **verify the fix at the PR's current head SHA**:
package/src/tui/index.ts CHANGED
@@ -50,13 +50,21 @@ export type TuiOptions = {
50
50
  onVersionMismatch?: (info: VersionMismatch) => void
51
51
  }
52
52
 
53
- // Outcome of a single `run()` cycle. The CLI's reconnect loop reads this to
54
- // decide whether to spin again or exit. `lostConnection` is true when the
55
- // WS closed AFTER the connected handshake without a deliberate /quit or
56
- // Ctrl+C — exactly the case a self-restart produces, and the only one
57
- // where a fresh connect can recover the session. Quit / Ctrl+C / pre-
58
- // handshake errors all resolve with `lostConnection: false`.
59
- export type TuiRunOutcome = { lostConnection: boolean }
53
+ // Outcome of a single `run()` cycle.
54
+ // - 'detach': idle Esc return to the session-viewer list. Closing the WS
55
+ // ends the server-side AgentSession (accepted; the list re-shows it as a
56
+ // read-only transcript).
57
+ // - 'exit': deliberate /quit or Ctrl+C terminate the client.
58
+ // - 'lostConnection': WS closed AFTER the handshake without a deliberate
59
+ // quit/detach exactly the self-restart case, and the only one where a
60
+ // fresh connect can recover the session.
61
+ // - 'connectFailed': pre-handshake connect/handshake error.
62
+ // The CLI reconnect loop spins only on 'lostConnection'.
63
+ export type TuiRunResult =
64
+ | { reason: 'detach' }
65
+ | { reason: 'exit'; exitCode: number }
66
+ | { reason: 'lostConnection' }
67
+ | { reason: 'connectFailed' }
60
68
 
61
69
  export function createTui({
62
70
  url,
@@ -68,7 +76,7 @@ export function createTui({
68
76
  expectedVersion,
69
77
  onVersionMismatch,
70
78
  }: TuiOptions) {
71
- async function run(): Promise<TuiRunOutcome> {
79
+ async function run(): Promise<TuiRunResult> {
72
80
  const terminal = createTerminal()
73
81
  const tui = new TUI(terminal)
74
82
  const displayUrl = redactUrl(url)
@@ -78,13 +86,19 @@ export function createTui({
78
86
  tui.start()
79
87
  tui.requestRender()
80
88
 
81
- const client = await createClient(url).catch((err) => {
89
+ // Pre-handshake failures resolve 'connectFailed' (not throw): the standalone
90
+ // CLI injects exit=process.exit so exit(1) ends the process and the return is
91
+ // moot; the viewer injects a no-op exit so run() resolves cleanly and the
92
+ // caller maps connectFailed into an error result instead of an uncaught reject.
93
+ const maybeClient = await createClient(url).catch((err) => {
82
94
  status.setText(colors.red(`connection error: ${err instanceof Error ? err.message : String(err)}`))
83
95
  tui.requestRender()
84
96
  tui.stop()
85
97
  exit(1)
86
- throw err
98
+ return null
87
99
  })
100
+ if (maybeClient === null) return { reason: 'connectFailed' }
101
+ const client = maybeClient
88
102
 
89
103
  const handshake = await waitForConnected(client, displayUrl, handshakeTimeoutMs).catch((err) => {
90
104
  status.setText(colors.red(`connection error: ${err instanceof Error ? err.message : String(err)}`))
@@ -92,10 +106,10 @@ export function createTui({
92
106
  client.close()
93
107
  tui.stop()
94
108
  exit(1)
95
- throw err
109
+ return null
96
110
  })
111
+ if (handshake === null) return { reason: 'connectFailed' }
97
112
 
98
- let userInitiatedShutdown = false
99
113
  const { sessionId, serverVersion } = handshake
100
114
  status.setText(colors.dim(`session: ${sessionId}`))
101
115
  tui.requestRender()
@@ -231,12 +245,23 @@ export function createTui({
231
245
  }
232
246
  })
233
247
 
234
- const closed = new Promise<boolean>((resolve) => {
235
- client.onClose(() => {
236
- appendHistory(new Text(colors.dim('disconnected'), 0, 0))
237
- tui.requestRender()
238
- resolve(!userInitiatedShutdown)
239
- })
248
+ let settleOutcome: ((result: TuiRunResult) => void) | null = null
249
+ const outcome = new Promise<TuiRunResult>((resolve) => {
250
+ settleOutcome = resolve
251
+ })
252
+ const settle = (result: TuiRunResult): void => {
253
+ if (settleOutcome === null) return
254
+ const fn = settleOutcome
255
+ settleOutcome = null
256
+ fn(result)
257
+ }
258
+
259
+ client.onClose(() => {
260
+ appendHistory(new Text(colors.dim('disconnected'), 0, 0))
261
+ tui.requestRender()
262
+ // A user-initiated detach/exit already closed the WS deliberately and
263
+ // settled the outcome; onClose then fires but must not override it.
264
+ settle({ reason: 'lostConnection' })
240
265
  })
241
266
 
242
267
  function send(text: string): Promise<void> {
@@ -249,7 +274,7 @@ export function createTui({
249
274
 
250
275
  function runTuiCommand(command: TuiCommandName): boolean {
251
276
  if (command === 'quit') {
252
- shutdown(0)
277
+ exitWith(0)
253
278
  return true
254
279
  }
255
280
  if (command === 'reload') {
@@ -266,29 +291,44 @@ export function createTui({
266
291
  return true
267
292
  }
268
293
 
269
- // Esc aborts an in-flight reply. The Editor does not bind Esc, so a
270
- // top-level input listener can intercept it without fighting the editor.
294
+ // Esc means "abort the in-flight reply" while a turn is generating, and
295
+ // "detach back to the session list" when idle. The Editor does not bind
296
+ // Esc, so a top-level listener intercepts it without fighting the editor.
271
297
  tui.addInputListener((data) => {
272
- if (matchesKey(data, Key.escape) && replyInFlight) {
298
+ if (!matchesKey(data, Key.escape)) return undefined
299
+ if (replyInFlight) {
273
300
  client.send({ type: 'abort' })
274
301
  return { consume: true }
275
302
  }
276
- return undefined
303
+ detach()
304
+ return { consume: true }
277
305
  })
278
306
 
279
- const shutdown = (code: number) => {
280
- userInitiatedShutdown = true
307
+ // Settle BEFORE closing the client: client.close() fires onClose, which
308
+ // settles 'lostConnection'. settle() is idempotent, so the first call wins —
309
+ // settling the deliberate outcome first keeps the later onClose a no-op.
310
+ const teardown = (): void => {
281
311
  tui.stop()
282
312
  client.close()
313
+ }
314
+
315
+ const exitWith = (code: number): void => {
316
+ settle({ reason: 'exit', exitCode: code })
317
+ teardown()
283
318
  exit(code)
284
319
  }
285
320
 
286
- // Ctrl+C exits cleanly. In raw mode the kernel does NOT generate SIGINT,
287
- // so we must intercept the \x03 byte ourselves. The Editor would otherwise
288
- // swallow it. tui.stop() restores raw-mode/cursor/echo before we exit.
321
+ const detach = (): void => {
322
+ settle({ reason: 'detach' })
323
+ teardown()
324
+ }
325
+
326
+ // Ctrl+C exits the client. In raw mode the kernel does NOT generate SIGINT,
327
+ // so we intercept the \x03 byte ourselves; the Editor would otherwise
328
+ // swallow it. teardown() restores raw-mode/cursor/echo before we settle.
289
329
  tui.addInputListener((data) => {
290
330
  if (matchesKey(data, Key.ctrl('c'))) {
291
- shutdown(0)
331
+ exitWith(0)
292
332
  return { consume: true }
293
333
  }
294
334
  return undefined
@@ -330,15 +370,15 @@ export function createTui({
330
370
  const command = parseBareTuiCommand(initialPrompt)
331
371
  if (command !== null) {
332
372
  runTuiCommand(command)
333
- if (command === 'quit') return { lostConnection: false }
373
+ if (command === 'quit') return { reason: 'exit', exitCode: 0 }
334
374
  } else {
335
375
  await send(initialPrompt)
336
376
  }
337
377
  }
338
378
 
339
- const lostConnection = await closed
379
+ const result = await outcome
340
380
  tui.stop()
341
- return { lostConnection }
381
+ return result
342
382
  }
343
383
 
344
384
  return { run }