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.
- package/package.json +1 -1
- package/src/agent/session-origin.ts +9 -1
- package/src/bundled-plugins/reviewer/skills/code-review.ts +3 -1
- package/src/channels/adapters/github/inbound.ts +52 -9
- package/src/channels/adapters/github/review-thread-resolver.ts +28 -3
- package/src/channels/router.ts +189 -7
- package/src/cli/inspect.ts +216 -36
- package/src/cli/logs.ts +15 -0
- package/src/cli/tui.ts +33 -39
- package/src/compose/logs.ts +1 -1
- package/src/container/logs.ts +70 -22
- package/src/inspect/index.ts +128 -42
- package/src/inspect/item-list.ts +44 -0
- package/src/inspect/item.ts +17 -0
- package/src/inspect/label.ts +1 -1
- package/src/inspect/logs-item.ts +79 -0
- package/src/inspect/loop.ts +74 -3
- package/src/inspect/open-item.ts +100 -0
- package/src/inspect/preview.ts +106 -0
- package/src/inspect/session-list.ts +15 -3
- package/src/inspect/transcript-view.ts +182 -0
- package/src/inspect/tui-item.ts +97 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +3 -1
- package/src/tui/index.ts +72 -32
package/src/cli/inspect.ts
CHANGED
|
@@ -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 {
|
|
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: '
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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 {
|
|
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: '
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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}`)
|
package/src/compose/logs.ts
CHANGED
|
@@ -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
|
|
package/src/container/logs.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
}
|