typeclaw 0.25.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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/agent/session-origin.ts +36 -5
  3. package/src/agent/subagent-completion-reminder.ts +16 -1
  4. package/src/agent/tools/channel-react.ts +11 -4
  5. package/src/bundled-plugins/reviewer/skills/code-review.ts +3 -1
  6. package/src/channels/adapters/discord-bot-classify.ts +3 -0
  7. package/src/channels/adapters/discord-bot-reactions.ts +164 -0
  8. package/src/channels/adapters/discord-bot.ts +23 -0
  9. package/src/channels/adapters/github/inbound.ts +60 -13
  10. package/src/channels/adapters/github/review-thread-resolver.ts +28 -3
  11. package/src/channels/adapters/slack-bot-classify.ts +2 -0
  12. package/src/channels/adapters/slack-bot-reactions.ts +167 -0
  13. package/src/channels/adapters/slack-bot.ts +24 -0
  14. package/src/channels/router.ts +191 -7
  15. package/src/channels/schema.ts +41 -0
  16. package/src/cli/inspect.ts +216 -36
  17. package/src/cli/logs.ts +15 -0
  18. package/src/cli/tui.ts +33 -39
  19. package/src/compose/logs.ts +1 -1
  20. package/src/config/config.ts +43 -2
  21. package/src/container/logs.ts +70 -22
  22. package/src/init/index.ts +3 -3
  23. package/src/inspect/index.ts +128 -42
  24. package/src/inspect/item-list.ts +44 -0
  25. package/src/inspect/item.ts +17 -0
  26. package/src/inspect/label.ts +1 -1
  27. package/src/inspect/logs-item.ts +79 -0
  28. package/src/inspect/loop.ts +74 -3
  29. package/src/inspect/open-item.ts +100 -0
  30. package/src/inspect/preview.ts +106 -0
  31. package/src/inspect/session-list.ts +15 -3
  32. package/src/inspect/transcript-view.ts +182 -0
  33. package/src/inspect/tui-item.ts +97 -0
  34. package/src/skills/typeclaw-channel-github/SKILL.md +4 -2
  35. package/src/tui/index.ts +72 -32
  36. package/typeclaw.schema.json +1 -0
@@ -0,0 +1,100 @@
1
+ import type { LiveSourceFactory, RunInspectResult } from './index'
2
+ import { createTranscriptView, streamInspectTarget } from './index'
3
+ import type { ViewerItem } from './item'
4
+ import { streamLogs } from './logs-item'
5
+ import type { OpenItemContext, OpenItemResult, TailController } from './loop'
6
+ import { runTuiViewer } from './tui-item'
7
+ import type { InspectFilter } from './types'
8
+
9
+ export type OpenViewerDeps = {
10
+ cwd: string
11
+ filter: InspectFilter
12
+ sinceMs: number | undefined
13
+ json: boolean
14
+ color: boolean
15
+ interactive: boolean
16
+ stdout: (line: string) => void
17
+ stderr: (line: string) => void
18
+ liveSource?: LiveSourceFactory
19
+ liveHint?: string
20
+ resolveTuiUrl: () => Promise<string>
21
+ expectedVersion?: string
22
+ onVersionMismatch?: (info: { expected: string; actual: string }) => void
23
+ }
24
+
25
+ // Dispatches a selected list item to its viewer. The tui branch and the
26
+ // interactive read-only transcript view each own their own raw-mode pi-tui
27
+ // terminal, so they run WITHOUT the loop's tail scope (two raw-stdin owners
28
+ // would corrupt input). The line/JSON session path and logs run UNDER the tail
29
+ // scope, which owns the raw-mode esc/q/ctrl-c handling.
30
+ export function openViewerItem(deps: OpenViewerDeps) {
31
+ return async (item: ViewerItem, ctx: OpenItemContext): Promise<OpenItemResult> => {
32
+ if (item.kind === 'tui') {
33
+ const result = await runTuiViewer({
34
+ resolveUrl: deps.resolveTuiUrl,
35
+ stderr: deps.stderr,
36
+ ...(deps.expectedVersion !== undefined ? { expectedVersion: deps.expectedVersion } : {}),
37
+ ...(deps.onVersionMismatch !== undefined ? { onVersionMismatch: deps.onVersionMismatch } : {}),
38
+ })
39
+ // Detaching the writable tui viewer ends the live session — the only path
40
+ // that should suppress the writable row on the next list refresh.
41
+ return { result, endedWritableSession: result.ok && result.escToPicker === true }
42
+ }
43
+
44
+ // Interactive-TTY read-only session -> rich pi-tui transcript view. Owns its
45
+ // own terminal, so it bypasses the tail scope. JSON/non-TTY falls through to
46
+ // the scriptable line renderer below. Read-only: esc does NOT end a live
47
+ // session, so endedWritableSession stays unset.
48
+ if (item.kind === 'session' && deps.interactive && !deps.json) {
49
+ const view = createTranscriptView({
50
+ summary: item.summary,
51
+ filter: deps.filter,
52
+ sinceMs: deps.sinceMs,
53
+ ...(deps.liveSource !== undefined ? { liveSource: deps.liveSource } : {}),
54
+ })
55
+ const outcome = await view.run()
56
+ const result: RunInspectResult =
57
+ outcome.reason === 'back' ? { ok: true, exitCode: 0, escToPicker: true } : { ok: true, exitCode: 0 }
58
+ return { result }
59
+ }
60
+
61
+ const scope = ctx.createTailScope()
62
+ try {
63
+ if (item.kind === 'logs') {
64
+ const logsResult = await streamLogs({
65
+ cwd: deps.cwd,
66
+ color: deps.color,
67
+ stdout: deps.stdout,
68
+ stderr: deps.stderr,
69
+ signal: scope.signal,
70
+ ...(deps.liveHint !== undefined ? { liveHint: deps.liveHint } : {}),
71
+ })
72
+ // Logs esc does NOT touch the live session — no endedWritableSession.
73
+ return { result: toResult(logsResult.escToPicker, scope) }
74
+ }
75
+
76
+ const sessionResult = await streamInspectTarget({
77
+ agentDir: deps.cwd,
78
+ target: { summary: item.summary, filter: deps.filter, sinceMs: deps.sinceMs },
79
+ json: deps.json,
80
+ color: deps.color,
81
+ stdout: deps.stdout,
82
+ stderr: deps.stderr,
83
+ signal: scope.signal,
84
+ ...(deps.liveSource !== undefined ? { liveSource: deps.liveSource } : {}),
85
+ ...(deps.liveHint !== undefined ? { liveHint: deps.liveHint } : {}),
86
+ ...(deps.interactive ? { interactive: true } : {}),
87
+ })
88
+ const escToPicker = sessionResult.ok && sessionResult.escToPicker === true
89
+ return { result: toResult(escToPicker, scope) }
90
+ } finally {
91
+ scope.dispose()
92
+ }
93
+ }
94
+ }
95
+
96
+ function toResult(escToPicker: boolean, scope: TailController): RunInspectResult {
97
+ if (scope.intent() === 'exit') return { ok: true, exitCode: 0 }
98
+ if (escToPicker) return { ok: true, exitCode: 0, escToPicker: true }
99
+ return { ok: true, exitCode: 0 }
100
+ }
@@ -0,0 +1,106 @@
1
+ import type { MinimalSessionOrigin } from '@/agent/session-meta'
2
+
3
+ // Builds the one-line session-list hint from a session's first user turn.
4
+ // User turns are wrapped in runtime-injected preamble (<current-time>, role
5
+ // anchors, SYSTEM MESSAGE fences, channel context sections, …) that grows over
6
+ // time. Rather than chase every block, this keys off stable semantic
7
+ // boundaries and prefers null over a misleading hint — it is a cosmetic glance,
8
+ // not a faithful reconstruction.
9
+ export function previewForHint(origin: MinimalSessionOrigin | null, text: string): string | null {
10
+ // A subagent's first message is a machine payload (Parent session:, …) and
11
+ // the row label already names the subagent.
12
+ if (origin?.kind === 'subagent') return null
13
+ if (origin?.kind === 'channel') return channelPreview(text)
14
+ return structuralPreview(text)
15
+ }
16
+
17
+ // `[ISO] <@authorId> (authorName) [bot]: actual text` — the only line carrying
18
+ // human-typed text in a channel turn. The stamp is omitted when ts<=0, so it is
19
+ // optional here; name and bot tag are also optional.
20
+ const AUTHOR_LINE = /^(?:\[[^\]]+\]\s+)?<[^>]+>(?:\s+\([^)]+\))?(?:\s+\[bot\])?:\s*(.*)$/
21
+
22
+ const CURRENT_MESSAGE_HEADER = /^## Current messages? \(addressed to you\)\s*$/
23
+
24
+ // Channel preview: extract ONLY from the "## Current message(s) (addressed to
25
+ // you)" section — never the "## Recent context" section above it, which is other
26
+ // people's messages the agent was only made aware of. Returns null if no
27
+ // addressed message is found.
28
+ function channelPreview(text: string): string | null {
29
+ const lines = text.split('\n')
30
+ const headerIdx = lines.findIndex((l) => CURRENT_MESSAGE_HEADER.test(l))
31
+ if (headerIdx === -1) return null
32
+
33
+ for (let i = headerIdx + 1; i < lines.length; i++) {
34
+ const match = AUTHOR_LINE.exec(lines[i]!)
35
+ if (match === null) continue
36
+ const payload = collectMessage(match[1] ?? '', lines, i + 1)
37
+ const trimmed = payload.trim()
38
+ return trimmed.length > 0 ? trimmed : null
39
+ }
40
+ return null
41
+ }
42
+
43
+ // An author line's text plus any continuation lines (a multi-line message
44
+ // continues without the author prefix) until the next author line, a structural
45
+ // boundary (## / ---), or a quote anchor (>).
46
+ function collectMessage(first: string, lines: string[], start: number): string {
47
+ const parts = [first]
48
+ for (let i = start; i < lines.length; i++) {
49
+ const line = lines[i]!
50
+ if (AUTHOR_LINE.test(line) || line.startsWith('## ') || line.startsWith('---') || line.startsWith('>')) break
51
+ parts.push(line)
52
+ }
53
+ return parts.join(' ')
54
+ }
55
+
56
+ // Origin-agnostic fallback (TUI, cron, system, unknown): skip leading injected
57
+ // structure — XML-tag blocks, `--- … ---` SYSTEM MESSAGE fences, `## …`
58
+ // sections, and blank lines — then take the first remaining real line. This
59
+ // degrades gracefully as new runtime notices (which follow the same framing
60
+ // conventions) are added, without needing per-block updates.
61
+ function structuralPreview(text: string): string | null {
62
+ const lines = text.split('\n')
63
+ let i = 0
64
+ while (i < lines.length) {
65
+ const line = lines[i]!
66
+ const trimmed = line.trim()
67
+ if (trimmed === '') {
68
+ i++
69
+ } else if (trimmed.startsWith('<')) {
70
+ i = skipXmlOrTagLine(lines, i)
71
+ } else if (trimmed.startsWith('---')) {
72
+ i = skipFence(lines, i)
73
+ } else if (trimmed.startsWith('#')) {
74
+ i++
75
+ } else {
76
+ return looksInjected(trimmed) ? null : trimmed
77
+ }
78
+ }
79
+ return null
80
+ }
81
+
82
+ // Skips a leading XML block. If the opening tag closes on the same line or
83
+ // spans lines, advance past the matching close tag; otherwise skip just the one
84
+ // line (a stray `<…>` shouldn't swallow the rest).
85
+ function skipXmlOrTagLine(lines: string[], start: number): number {
86
+ const open = /^<([a-zA-Z][\w-]*)/.exec(lines[start]!.trim())
87
+ if (open === null) return start + 1
88
+ const close = `</${open[1]}>`
89
+ for (let i = start; i < lines.length; i++) {
90
+ if (lines[i]!.includes(close)) return i + 1
91
+ }
92
+ return start + 1
93
+ }
94
+
95
+ // Skips a `---` fenced block (SYSTEM MESSAGE framing): from the opening `---`
96
+ // to the next standalone `---`.
97
+ function skipFence(lines: string[], start: number): number {
98
+ for (let i = start + 1; i < lines.length; i++) {
99
+ if (lines[i]!.trim() === '---') return i + 1
100
+ }
101
+ return start + 1
102
+ }
103
+
104
+ function looksInjected(line: string): boolean {
105
+ return line.startsWith('**[SYSTEM') || line.startsWith('[security/')
106
+ }
@@ -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
 
@@ -189,7 +189,7 @@ The `reviewer` subagent is the analyst; you are the integration layer between it
189
189
 
190
190
  A finding is "actionable" if its severity is `blocker`, `concern`, or `nit`. The inline-review post in step 4 applies whenever the actionable count is **at least one**. When the reviewer returns **exactly zero** actionable findings (only `praise`, or none), there is nothing to anchor inline — handle by verdict:
191
191
 
192
- - `approve` → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array). **If the operator approval policy above disabled approval, submit a `COMMENT` review instead — same `<summary>` as the review body, `event: "COMMENT"`, no `comments[]` array. Keep it a formal review, not a top-level issue comment, so the review metadata and flow are preserved.** (Re-review caveat: a `COMMENT` review does **not** clear a sticky `CHANGES_REQUESTED` block. If this is a re-review under approval-disabled policy, follow the step-4 re-review branch — dismiss your prior review — instead of relying on this `COMMENT`.)
192
+ - `approve` → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array). **This is still a formal review via `POST /pulls/<N>/reviews`, NOT a `channel_reply`.** A zero-findings approval is the single most common place this goes wrong: with nothing to anchor inline, the model is tempted to just `channel_reply({ text: "Approved …" })` and end the turn. That posts a plain PR comment and leaves the PR **"awaiting review"** with no approval — the verdict never reaches GitHub's review API. Never start a top-level `channel_reply` on a `pr:N` with "Approved" / "LGTM" / "Request changes": those are verdicts, and verdicts are always formal reviews. Submit the `APPROVE` via `gh api`, confirm it landed (step 5), then `skip_response`. **If the operator approval policy above disabled approval, submit a `COMMENT` review instead — same `<summary>` as the review body, `event: "COMMENT"`, no `comments[]` array. Keep it a formal review, not a top-level issue comment, so the review metadata and flow are preserved.** (Re-review caveat: a `COMMENT` review does **not** clear a sticky `CHANGES_REQUESTED` block. If this is a re-review under approval-disabled policy, follow the step-4 re-review branch — dismiss your prior review — instead of relying on this `COMMENT`.)
193
193
  - `comment` → post the summary as a top-level PR comment via `gh api -X POST /repos/.../issues/<N>/comments` instead of submitting an empty review. **Exception — re-reviews:** if this is a re-review (you have an unresolved blocking obligation — a formal `CHANGES_REQUESTED` **or** an unretracted flat-comment blocker), a top-level comment discharges neither. Do not use this branch; resolve it via the step-4 re-review branch (`APPROVE` if resolved and approval is enabled, the dismissal endpoint if a formal block is resolved but approval is disabled, `REQUEST_CHANGES` if not resolved).
194
194
  - `request-changes` → submit `REQUEST_CHANGES` with the `<summary>` as the review body and no `comments[]` array. This combination is rare (the reviewer's contract says `request-changes` requires at least one blocker or load-bearing concern); if it happens, faithfully encode the verdict and trust the reviewer's reasoning is in the summary.
195
195
 
@@ -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**: