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.
- package/package.json +1 -1
- package/src/agent/session-origin.ts +36 -5
- package/src/agent/subagent-completion-reminder.ts +16 -1
- package/src/agent/tools/channel-react.ts +11 -4
- package/src/bundled-plugins/reviewer/skills/code-review.ts +3 -1
- package/src/channels/adapters/discord-bot-classify.ts +3 -0
- package/src/channels/adapters/discord-bot-reactions.ts +164 -0
- package/src/channels/adapters/discord-bot.ts +23 -0
- package/src/channels/adapters/github/inbound.ts +60 -13
- package/src/channels/adapters/github/review-thread-resolver.ts +28 -3
- package/src/channels/adapters/slack-bot-classify.ts +2 -0
- package/src/channels/adapters/slack-bot-reactions.ts +167 -0
- package/src/channels/adapters/slack-bot.ts +24 -0
- package/src/channels/router.ts +191 -7
- package/src/channels/schema.ts +41 -0
- 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/config/config.ts +43 -2
- package/src/container/logs.ts +70 -22
- package/src/init/index.ts +3 -3
- 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 +4 -2
- package/src/tui/index.ts +72 -32
- 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
|
-
|
|
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
|
|
|
@@ -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**:
|