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.
@@ -2,7 +2,19 @@ import { defineCommand } from 'citty'
2
2
 
3
3
  import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
4
4
  import { findAgentDir } from '@/init'
5
- import { runInspectLoop, streamLive, type LiveSourceFactory, type SessionSummary } from '@/inspect'
5
+ import {
6
+ listViewerItems,
7
+ openViewerItem,
8
+ parseDuration,
9
+ parseFilter,
10
+ resolveSession,
11
+ runInspectLoop,
12
+ runViewerLoop,
13
+ streamLive,
14
+ type LiveSourceFactory,
15
+ type SessionSummary,
16
+ type ViewerItem,
17
+ } from '@/inspect'
6
18
  import { originLabel, shortSessionId } from '@/inspect/label'
7
19
 
8
20
  import { createTailScope } from './inspect-controller'
@@ -13,12 +25,12 @@ const ESC_DEBOUNCE_MS = 50
13
25
  export const inspectCommand = defineCommand({
14
26
  meta: {
15
27
  name: 'inspect',
16
- description: 'observe a session: replay the transcript, then tail live activity (host stage)',
28
+ description: 'session viewer: pick a session, the live TUI, or container logs to observe (host stage)',
17
29
  },
18
30
  args: {
19
31
  session: {
20
32
  type: 'positional',
21
- description: 'session id or short prefix (omit to pick from a list)',
33
+ description: 'session id or short prefix (omit to pick from the list)',
22
34
  required: false,
23
35
  },
24
36
  filter: {
@@ -42,42 +54,175 @@ export const inspectCommand = defineCommand({
42
54
  const sessionArg = typeof args.session === 'string' ? args.session : undefined
43
55
  const filterArg = typeof args.filter === 'string' ? args.filter : undefined
44
56
  const sinceArg = typeof args.since === 'string' ? args.since : undefined
45
-
46
57
  const isJson = args.json === true
47
- const liveSource = isJson ? undefined : await buildLiveSource(cwd)
48
- const interactive = !isJson && Boolean(process.stdin.isTTY)
49
- const liveHint = interactive ? escHintLine(color) : undefined
50
-
51
- const result = await runInspectLoop({
52
- agentDir: cwd,
53
- ...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
54
- ...(filterArg !== undefined ? { filter: filterArg } : {}),
55
- ...(sinceArg !== undefined ? { since: sinceArg } : {}),
56
- json: isJson,
57
- color,
58
- selectSession: (sessions, selectOpts) => clackSelect(sessions, selectOpts?.initialSessionId),
59
- ...(liveSource !== undefined ? { liveSource } : {}),
60
- createTailScope: () => createTailScope({ debounceMs: ESC_DEBOUNCE_MS }),
61
- ...(interactive ? { interactive: true } : {}),
62
- ...(liveHint !== undefined ? { liveHint } : {}),
63
- stdout: (line) => process.stdout.write(`${line}\n`),
64
- stderr: (line) => process.stderr.write(`${line}\n`),
65
- })
66
58
 
67
- if (!result.ok) {
68
- process.stderr.write(`${errorLine(result.reason)}\n`)
69
- process.exit(result.exitCode)
59
+ // JSON mode stays the scriptable, session-only path: no list, no logs/tui
60
+ // rows, explicit session id required. Behavior is unchanged from before the
61
+ // viewer merge.
62
+ if (isJson) {
63
+ const result = await runInspectLoop({
64
+ agentDir: cwd,
65
+ ...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
66
+ ...(filterArg !== undefined ? { filter: filterArg } : {}),
67
+ ...(sinceArg !== undefined ? { since: sinceArg } : {}),
68
+ json: true,
69
+ color,
70
+ selectSession: (sessions, selectOpts) => clackSelectSession(sessions, selectOpts?.initialSessionId),
71
+ createTailScope: () => createTailScope({ debounceMs: ESC_DEBOUNCE_MS }),
72
+ stdout: (line) => process.stdout.write(`${line}\n`),
73
+ stderr: (line) => process.stderr.write(`${line}\n`),
74
+ })
75
+ finish(result)
76
+ return
70
77
  }
71
- process.exit(result.exitCode)
78
+
79
+ const exitCode = await runInspectViewer({
80
+ cwd,
81
+ ...(sessionArg !== undefined ? { sessionArg } : {}),
82
+ ...(filterArg !== undefined ? { filterArg } : {}),
83
+ ...(sinceArg !== undefined ? { sinceArg } : {}),
84
+ color,
85
+ })
86
+ process.exit(exitCode)
72
87
  },
73
88
  })
74
89
 
75
- async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefined> {
76
- const precheck = await requireContainerRunning({ cwd })
77
- if (!precheck.ok) {
78
- process.stderr.write(`${c.yellow('⚠')} ${precheck.reason}; tailing live events disabled\n`)
79
- return undefined
90
+ export type RunInspectViewerOptions = {
91
+ cwd: string
92
+ sessionArg?: string
93
+ filterArg?: string
94
+ sinceArg?: string
95
+ color?: boolean
96
+ // Set false by the `tui` detach handoff: the live session was just ended, so
97
+ // no row should be offered as writable (see listViewerItems).
98
+ allowWritable?: boolean
99
+ }
100
+
101
+ // The interactive session-viewer: list → open → back to list. Shared by the
102
+ // `inspect` command and `tui`'s esc-detach fallthrough. Returns an exit code
103
+ // instead of calling process.exit so callers can chain (e.g. tui drops here).
104
+ export async function runInspectViewer(opts: RunInspectViewerOptions): Promise<number> {
105
+ const { cwd } = opts
106
+ const color = opts.color ?? useColor()
107
+
108
+ const filterResult = parseFilter(opts.filterArg)
109
+ if (!filterResult.ok) {
110
+ process.stderr.write(`${errorLine(filterResult.reason)}\n`)
111
+ return 2
112
+ }
113
+ let sinceMs: number | undefined
114
+ if (opts.sinceArg !== undefined) {
115
+ const d = parseDuration(opts.sinceArg)
116
+ if (!d.ok) {
117
+ process.stderr.write(`${errorLine(d.reason)}\n`)
118
+ return 2
119
+ }
120
+ sinceMs = Date.now() - d.ms
121
+ }
122
+
123
+ const containerRunning = (await requireContainerRunning({ cwd })).ok
124
+ if (!containerRunning) {
125
+ process.stderr.write(`${c.yellow('⚠')} container not running; showing read-only history and logs only\n`)
126
+ }
127
+
128
+ const sessionsDir = `${cwd}/sessions`
129
+
130
+ // Resolve a session arg (id or short prefix) to a full session id BEFORE the
131
+ // loop: runViewerLoop matches preselectKey against exact itemKeys, so a bare
132
+ // prefix would otherwise miss every row and report "no sessions". 'logs' is a
133
+ // reserved key, not a session, so it bypasses resolution.
134
+ let preselectKey: string | undefined
135
+ if (opts.sessionArg !== undefined && opts.sessionArg !== 'logs') {
136
+ const resolved = await resolveSession(sessionsDir, opts.sessionArg, (l) => process.stderr.write(`${l}\n`))
137
+ if (!resolved.ok) {
138
+ const reason =
139
+ resolved.reason === 'ambiguous'
140
+ ? `Ambiguous session prefix "${opts.sessionArg}" matches ${resolved.matches.length} sessions. Use a longer prefix or run \`typeclaw inspect\` without args.`
141
+ : `No session matching "${opts.sessionArg}" in ${sessionsDir}/`
142
+ process.stderr.write(`${errorLine(reason)}\n`)
143
+ return resolved.reason === 'ambiguous' ? 2 : 1
144
+ }
145
+ preselectKey = resolved.summary.sessionId
146
+ } else if (opts.sessionArg === 'logs') {
147
+ preselectKey = 'logs'
148
+ }
149
+
150
+ const interactive = Boolean(process.stdin.isTTY)
151
+ const liveHint = interactive ? escHintLine(color) : undefined
152
+ const liveSource = containerRunning ? await buildLiveSource(cwd) : undefined
153
+
154
+ const stdout = (line: string): void => {
155
+ process.stdout.write(`${line}\n`)
156
+ }
157
+ const stderr = (line: string): void => {
158
+ process.stderr.write(`${line}\n`)
159
+ }
160
+
161
+ const open = openViewerItem({
162
+ cwd,
163
+ filter: filterResult.filter,
164
+ sinceMs,
165
+ json: false,
166
+ color,
167
+ interactive,
168
+ stdout,
169
+ stderr,
170
+ resolveTuiUrl: () => resolveTuiUrl(cwd),
171
+ ...(liveSource !== undefined ? { liveSource } : {}),
172
+ ...(liveHint !== undefined ? { liveHint } : {}),
173
+ })
174
+
175
+ const cliAllowWritable = opts.allowWritable !== false
176
+ const result = await runViewerLoop<ViewerItem>({
177
+ listItems: async ({ allowWritable: loopAllowWritable }) => {
178
+ const listOpts: Parameters<typeof listViewerItems>[0] = {
179
+ sessionsDir,
180
+ containerRunning,
181
+ // Compose the CLI-level permission (false on tui detach handoff) with
182
+ // the loop-level one (false after returning to the picker from a viewer).
183
+ allowWritable: cliAllowWritable && loopAllowWritable,
184
+ limit: 20,
185
+ onWarn: stderr,
186
+ }
187
+ if (sinceMs !== undefined) listOpts.sinceMs = sinceMs
188
+ return (await listViewerItems(listOpts)).items
189
+ },
190
+ keyOf: (item) => (item.kind === 'logs' ? 'logs' : item.summary.sessionId),
191
+ ...(preselectKey !== undefined ? { preselectKey } : {}),
192
+ selectItem: (items, selectOpts) => clackSelectItem(items, selectOpts.initialKey),
193
+ openItem: open,
194
+ createTailScope: () => createTailScope({ debounceMs: ESC_DEBOUNCE_MS }),
195
+ onEmpty: () => ({
196
+ ok: false,
197
+ exitCode: 1,
198
+ reason: `No sessions found in ${sessionsDir}/.\nStart a session with \`typeclaw tui\` or send a message from a configured channel.`,
199
+ }),
200
+ })
201
+
202
+ if (!result.ok && result.reason !== undefined) {
203
+ process.stderr.write(`${errorLine(result.reason)}\n`)
204
+ }
205
+ return result.exitCode
206
+ }
207
+
208
+ function finish(result: { ok: boolean; exitCode: number; reason?: string }): void {
209
+ if (!result.ok && result.reason !== undefined) {
210
+ process.stderr.write(`${errorLine(result.reason)}\n`)
80
211
  }
212
+ process.exit(result.exitCode)
213
+ }
214
+
215
+ async function resolveTuiUrl(cwd: string): Promise<string> {
216
+ const precheck = await requireContainerRunning({ cwd })
217
+ if (!precheck.ok) throw new Error(precheck.reason)
218
+ const port = await resolveHostPort({ cwd })
219
+ const token = await resolveTuiToken({ cwd })
220
+ const url = new URL(`ws://127.0.0.1:${port}`)
221
+ if (token !== null) url.searchParams.set('token', token)
222
+ return url.toString()
223
+ }
224
+
225
+ async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefined> {
81
226
  const port = await resolveHostPort({ cwd })
82
227
  const token = await resolveTuiToken({ cwd })
83
228
  const baseUrl = new URL(`ws://127.0.0.1:${port}/inspect`)
@@ -94,7 +239,7 @@ async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefin
94
239
  }
95
240
 
96
241
  function escHintLine(color: boolean): string {
97
- const text = '(esc to return to session list · q to quit)'
242
+ const text = '(esc to return to the list · q to quit)'
98
243
  return color ? `\u001b[2m${text}\u001b[0m` : text
99
244
  }
100
245
 
@@ -105,7 +250,29 @@ function useColor(): boolean {
105
250
  return Boolean(process.stdout.isTTY)
106
251
  }
107
252
 
108
- async function clackSelect(
253
+ async function clackSelectItem(items: ViewerItem[], initialKey: string | undefined): Promise<ViewerItem | null> {
254
+ const { select } = await import('@clack/prompts')
255
+ prepareStdinForClack()
256
+ const keyOf = (item: ViewerItem): string => (item.kind === 'logs' ? 'logs' : item.summary.sessionId)
257
+ const preferred =
258
+ initialKey !== undefined && items.some((i) => keyOf(i) === initialKey) ? initialKey : keyOf(items[0]!)
259
+ const picked = await select<string>({
260
+ message: `Pick what to view (showing ${items.length})`,
261
+ options: items.map((item) => ({
262
+ value: keyOf(item),
263
+ label: itemLabel(item),
264
+ ...itemHint(item),
265
+ })),
266
+ initialValue: preferred,
267
+ })
268
+ if (isCancel(picked)) {
269
+ cancel('Cancelled.')
270
+ return null
271
+ }
272
+ return items.find((i) => keyOf(i) === picked) ?? null
273
+ }
274
+
275
+ async function clackSelectSession(
109
276
  sessions: SessionSummary[],
110
277
  initialSessionId: string | undefined,
111
278
  ): Promise<SessionSummary | null> {
@@ -119,7 +286,7 @@ async function clackSelect(
119
286
  message: `Pick a session to inspect (showing ${sessions.length})`,
120
287
  options: sessions.map((s) => ({
121
288
  value: s.sessionId,
122
- label: formatRowLabel(s),
289
+ label: sessionRowLabel(s),
123
290
  ...(s.firstPrompt !== null ? { hint: truncate(s.firstPrompt, 60) } : { hint: '(no prompt)' }),
124
291
  })),
125
292
  initialValue: preferred,
@@ -131,7 +298,20 @@ async function clackSelect(
131
298
  return sessions.find((s) => s.sessionId === picked) ?? null
132
299
  }
133
300
 
134
- function formatRowLabel(s: SessionSummary): string {
301
+ function itemLabel(item: ViewerItem): string {
302
+ if (item.kind === 'logs') return `${c.dim('▤')} container logs`
303
+ if (item.kind === 'tui') return `${c.green('●')} ${c.bold('live TUI')} ${sessionRowLabel(item.summary)}`
304
+ return `${c.dim('○')} ${sessionRowLabel(item.summary)}`
305
+ }
306
+
307
+ function itemHint(item: ViewerItem): { hint: string } {
308
+ if (item.kind === 'logs') return { hint: 'read-only · works offline' }
309
+ if (item.kind === 'tui') return { hint: 'read+write · esc detaches and ends the live session' }
310
+ if (item.summary.firstPrompt !== null) return { hint: truncate(item.summary.firstPrompt, 60) }
311
+ return { hint: '(no prompt)' }
312
+ }
313
+
314
+ function sessionRowLabel(s: SessionSummary): string {
135
315
  const id = shortSessionId(s.sessionId)
136
316
  const label = s.origin === null ? '(unknown origin)' : originLabel(s.origin)
137
317
  const when = formatRelative(s.mtimeMs)
package/src/cli/logs.ts CHANGED
@@ -3,6 +3,7 @@ import { defineCommand } from 'citty'
3
3
  import { logs, parseTailValue } from '@/container'
4
4
  import { findAgentDir } from '@/init'
5
5
 
6
+ import { runInspectViewer } from './inspect'
6
7
  import { c, errorLine } from './ui'
7
8
 
8
9
  export const logsCommand = defineCommand({
@@ -22,6 +23,11 @@ export const logsCommand = defineCommand({
22
23
  alias: 'n',
23
24
  description: 'number of lines to show from the end of the logs (non-negative integer or "all")',
24
25
  },
26
+ list: {
27
+ type: 'boolean',
28
+ description: 'open the session viewer on the logs entry instead of dumping logs',
29
+ default: false,
30
+ },
25
31
  },
26
32
  async run({ args }) {
27
33
  const cwd = findAgentDir(process.cwd()) ?? process.cwd()
@@ -36,6 +42,15 @@ export const logsCommand = defineCommand({
36
42
  tail = parsed.value
37
43
  }
38
44
 
45
+ // The viewer is strictly opt-in via --list, so the default `typeclaw logs`
46
+ // (piped, redirected, -f, or a plain TTY dump) keeps the raw `docker logs`
47
+ // pump that `typeclaw logs | grep` and CI depend on. --list drops into the
48
+ // session viewer pre-opened on the logs entry, where esc returns to the list.
49
+ if (args.list) {
50
+ const exitCode = await runInspectViewer({ cwd, sessionArg: 'logs' })
51
+ process.exit(exitCode)
52
+ }
53
+
39
54
  if (args.follow) {
40
55
  console.log(c.cyan('Streaming container logs...'))
41
56
  } else {
package/src/cli/tui.ts CHANGED
@@ -3,14 +3,16 @@ import { defineCommand } from 'citty'
3
3
  import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
4
4
  import { findAgentDir } from '@/init'
5
5
  import { CLI_VERSION } from '@/init/cli-version'
6
- import { createTui, formatVersionMismatchWarning } from '@/tui'
6
+ import { runTuiViewer } from '@/inspect'
7
+ import { formatVersionMismatchWarning } from '@/tui'
7
8
 
9
+ import { runInspectViewer } from './inspect'
8
10
  import { errorLine } from './ui'
9
11
 
10
12
  export const tui = defineCommand({
11
13
  meta: {
12
14
  name: 'tui',
13
- description: 'start the tui client',
15
+ description: 'open the live agent session in the read+write viewer (host stage)',
14
16
  },
15
17
  args: {
16
18
  prompt: {
@@ -25,50 +27,42 @@ export const tui = defineCommand({
25
27
  },
26
28
  },
27
29
  async run({ args }) {
28
- const resolveUrl: () => Promise<string> = args.url !== undefined ? async () => args.url as string : defaultUrl
30
+ const cwd = findAgentDir(process.cwd()) ?? process.cwd()
31
+ const resolveUrl: () => Promise<string> =
32
+ args.url !== undefined ? async () => args.url as string : () => defaultUrl(cwd)
29
33
 
30
- let initialPrompt: string | undefined = args.prompt
31
- let attempt = 0
32
- const RECONNECT_MAX_ATTEMPTS = 30
33
- const RECONNECT_BACKOFF_MS = 1_000
34
+ const result = await runTuiViewer({
35
+ resolveUrl,
36
+ ...(args.prompt !== undefined ? { initialPrompt: args.prompt } : {}),
37
+ expectedVersion: CLI_VERSION,
38
+ onVersionMismatch: (info) => {
39
+ process.stderr.write(`${formatVersionMismatchWarning(info)}\n`)
40
+ },
41
+ stderr: (line) => process.stderr.write(`${line}\n`),
42
+ })
34
43
 
35
- while (true) {
36
- const url = await resolveUrl()
37
- const tui = createTui({
38
- url,
39
- ...(initialPrompt !== undefined ? { initialPrompt } : {}),
40
- expectedVersion: CLI_VERSION,
41
- onVersionMismatch: (info) => {
42
- process.stderr.write(`${formatVersionMismatchWarning(info)}\n`)
43
- },
44
- })
45
- const outcome = await tui.run()
46
- if (!outcome.lostConnection) return
47
- // The TUI lost its WS post-handshake (container restart, network blip,
48
- // hostd hiccup). Re-resolve the URL because the host port can change
49
- // across container lifecycles (see resolveHostPort), then reconnect.
50
- // The initial prompt is intentionally cleared after the first cycle:
51
- // on a reconnect, the agent is resuming the same session — replaying
52
- // the prompt would re-send it to the LLM.
53
- initialPrompt = undefined
54
- attempt += 1
55
- if (attempt > RECONNECT_MAX_ATTEMPTS) {
56
- console.error(errorLine(`disconnected; gave up after ${RECONNECT_MAX_ATTEMPTS} reconnect attempts`))
57
- process.exit(1)
58
- }
59
- process.stderr.write(`reconnecting (attempt ${attempt}/${RECONNECT_MAX_ATTEMPTS})...\n`)
60
- await new Promise((resolve) => setTimeout(resolve, RECONNECT_BACKOFF_MS))
44
+ // Esc detached from the live session: drop into the viewer list so the user
45
+ // can pick another session or the container logs — `tui` is just a deep-link
46
+ // into the session viewer, pre-opened on the live session. allowWritable
47
+ // is false because detaching ended the live session, so no row may be
48
+ // offered as a writable "live TUI" anymore.
49
+ if (result.ok && result.escToPicker === true) {
50
+ const viewerExit = await runInspectViewer({ cwd, allowWritable: false })
51
+ process.exit(viewerExit)
52
+ return
61
53
  }
54
+
55
+ if (!result.ok) {
56
+ process.stderr.write(`${errorLine(result.reason)}\n`)
57
+ process.exit(result.exitCode)
58
+ }
59
+ process.exit(result.exitCode)
62
60
  },
63
61
  })
64
62
 
65
- async function defaultUrl(): Promise<string> {
66
- const cwd = findAgentDir(process.cwd()) ?? process.cwd()
63
+ async function defaultUrl(cwd: string): Promise<string> {
67
64
  const precheck = await requireContainerRunning({ cwd })
68
- if (!precheck.ok) {
69
- console.error(errorLine(precheck.reason))
70
- process.exit(1)
71
- }
65
+ if (!precheck.ok) throw new Error(precheck.reason)
72
66
  const port = await resolveHostPort({ cwd })
73
67
  const token = await resolveTuiToken({ cwd })
74
68
  const url = new URL(`ws://127.0.0.1:${port}`)
@@ -100,7 +100,7 @@ export async function composeLogs({
100
100
  follow,
101
101
  ...(tail !== undefined ? { tail } : {}),
102
102
  })
103
- const proc = bun.spawn({ cmd, stdout: 'pipe', stderr: 'pipe' })
103
+ const proc = bun.spawn({ cmd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
104
104
  return { agent, proc }
105
105
  })
106
106
 
@@ -44,27 +44,48 @@ export async function logs({
44
44
  return { ok: false, reason: `Container ${plan.containerName} not found. Run \`typeclaw start\` first.` }
45
45
  }
46
46
 
47
- const proc = bun.spawn({ cmd: buildDockerLogsCmd(plan), cwd, stdout: 'pipe', stderr: 'pipe' })
47
+ // stdin:'ignore' `docker logs` never reads stdin, and letting the child
48
+ // hold the TTY breaks the viewer's raw-mode keypress listener (esc/q/ctrl-c
49
+ // stop reaching it, freezing the logs view with no way out).
50
+ const proc = bun.spawn({ cmd: buildDockerLogsCmd(plan), cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
48
51
 
52
+ // `docker logs -f` never exits on its own; aborting the signal must kill it
53
+ // so the pumps' stream readers end. Escalate to SIGKILL if SIGTERM is
54
+ // ignored, otherwise Promise.all(pumps) could hang until the pipes close.
55
+ let killTimer: ReturnType<typeof setTimeout> | undefined
49
56
  const onAbort = (): void => {
50
57
  try {
51
58
  proc.kill('SIGTERM')
59
+ killTimer = setTimeout(() => {
60
+ try {
61
+ proc.kill('SIGKILL')
62
+ } catch {
63
+ // already exited
64
+ }
65
+ }, 2_000)
52
66
  } catch {
53
67
  // already exited
54
68
  }
55
69
  }
56
70
  signal?.addEventListener('abort', onAbort, { once: true })
71
+ // The signal may already be aborted before we attached the listener (esc
72
+ // pressed during container existence check); addEventListener would then
73
+ // never fire, leaving docker logs -f running forever.
74
+ if (signal?.aborted === true) onAbort()
57
75
 
58
- const colorOut = useColor ?? supportsColor(out)
59
- const colorErr = useColor ?? supportsColor(err)
60
- await Promise.all([
61
- pumpWithTimestamps(proc.stdout, out, makeLogTimestampReformatter(undefined, { color: colorOut })),
62
- pumpWithTimestamps(proc.stderr, err, makeLogTimestampReformatter(undefined, { color: colorErr })),
63
- ])
64
- const exitCode = await proc.exited
65
- signal?.removeEventListener('abort', onAbort)
66
-
67
- return { ok: true, containerName: plan.containerName, exitCode }
76
+ try {
77
+ const colorOut = useColor ?? supportsColor(out)
78
+ const colorErr = useColor ?? supportsColor(err)
79
+ await Promise.all([
80
+ pumpWithTimestamps(proc.stdout, out, makeLogTimestampReformatter(undefined, { color: colorOut }), signal),
81
+ pumpWithTimestamps(proc.stderr, err, makeLogTimestampReformatter(undefined, { color: colorErr }), signal),
82
+ ])
83
+ const exitCode = await proc.exited
84
+ return { ok: true, containerName: plan.containerName, exitCode }
85
+ } finally {
86
+ if (killTimer !== undefined) clearTimeout(killTimer)
87
+ signal?.removeEventListener('abort', onAbort)
88
+ }
68
89
  } catch (error) {
69
90
  return { ok: false, reason: error instanceof Error ? error.message : String(error) }
70
91
  }
@@ -101,30 +122,57 @@ export function buildDockerLogsCmd(plan: LogsPlan): string[] {
101
122
 
102
123
  // Exported for `compose/logs.ts` so the multi-agent path reuses the same
103
124
  // reformatter and stays consistent with single-agent output.
125
+ //
126
+ // Abort handling is load-bearing for the interactive logs viewer: killing
127
+ // `docker logs -f` does NOT reliably make Bun's pending `reader.read()` resolve
128
+ // (the killed child may not promptly EOF its piped stdout — see the OrbStack
129
+ // /proc quirk). Without cancelling the reader on abort, esc would hang forever.
130
+ // So on abort we cancel the reader, which unblocks the pending read; the caller
131
+ // still kills the process for OS-side cleanup.
104
132
  export async function pumpWithTimestamps(
105
133
  stream: ReadableStream<Uint8Array>,
106
134
  sink: NodeJS.WritableStream,
107
135
  reformatter: TimestampReformatter = makeLogTimestampReformatter(),
136
+ signal?: AbortSignal,
108
137
  ): Promise<void> {
109
138
  const decoder = new TextDecoder()
110
139
  const reader = stream.getReader()
140
+ let aborted = signal?.aborted === true
141
+ const onAbort = (): void => {
142
+ aborted = true
143
+ void reader.cancel().catch(() => {})
144
+ }
145
+ if (aborted) onAbort()
146
+ else signal?.addEventListener('abort', onAbort, { once: true })
147
+
111
148
  try {
112
149
  while (true) {
113
- const { done, value } = await reader.read()
114
- if (done) break
115
- if (value && value.byteLength > 0) {
116
- const out = reformatter.write(decoder.decode(value, { stream: true }))
150
+ if (aborted) break
151
+ const chunk = await reader.read().catch((error: unknown) => {
152
+ if (aborted || signal?.aborted === true) return null
153
+ throw error
154
+ })
155
+ if (chunk === null || chunk.done || aborted) break
156
+ if (chunk.value && chunk.value.byteLength > 0) {
157
+ const out = reformatter.write(decoder.decode(chunk.value, { stream: true }))
117
158
  if (out.length > 0) sink.write(out)
118
159
  }
119
160
  }
120
- const tail = decoder.decode()
121
- if (tail.length > 0) {
122
- const out = reformatter.write(tail)
123
- if (out.length > 0) sink.write(out)
161
+ if (!aborted) {
162
+ const tail = decoder.decode()
163
+ if (tail.length > 0) {
164
+ const out = reformatter.write(tail)
165
+ if (out.length > 0) sink.write(out)
166
+ }
167
+ const flushed = reformatter.flush()
168
+ if (flushed.length > 0) sink.write(flushed)
124
169
  }
125
- const flushed = reformatter.flush()
126
- if (flushed.length > 0) sink.write(flushed)
127
170
  } finally {
128
- reader.releaseLock()
171
+ signal?.removeEventListener('abort', onAbort)
172
+ try {
173
+ reader.releaseLock()
174
+ } catch {
175
+ // harmless if cancel/abort raced with stream teardown
176
+ }
129
177
  }
130
178
  }