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
|
@@ -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
|
-
|
|
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' &&
|
|
147
|
-
if (origin !== null &&
|
|
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.
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
|
270
|
-
//
|
|
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)
|
|
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
|
-
|
|
303
|
+
detach()
|
|
304
|
+
return { consume: true }
|
|
277
305
|
})
|
|
278
306
|
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
379
|
+
const result = await outcome
|
|
340
380
|
tui.stop()
|
|
341
|
-
return
|
|
381
|
+
return result
|
|
342
382
|
}
|
|
343
383
|
|
|
344
384
|
return { run }
|