typeclaw 0.26.0 → 0.28.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/scripts/generate-schema.ts +4 -6
- package/src/agent/index.ts +26 -4
- package/src/agent/multimodal/look-at.ts +1 -2
- package/src/agent/session-origin.ts +9 -1
- package/src/agent/tools/channel-fetch-attachment.ts +1 -2
- package/src/agent/tools/channel-react.ts +9 -3
- package/src/agent/tools/channel-reply.ts +30 -1
- package/src/agent/tools/channel-send.ts +94 -1
- package/src/bundled-plugins/github-cli-auth/gh-review-detect.ts +175 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +4 -0
- package/src/bundled-plugins/github-cli-auth/review-recorder.ts +93 -0
- package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
- package/src/bundled-plugins/memory/README.md +3 -21
- package/src/bundled-plugins/memory/index.ts +1 -149
- package/src/bundled-plugins/reviewer/skills/code-review.ts +3 -1
- package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
- package/src/channels/adapters/github/inbound.ts +155 -9
- package/src/channels/adapters/github/review-thread-resolver.ts +93 -8
- package/src/channels/github-false-receipt.ts +87 -0
- package/src/channels/github-review-claim.ts +91 -0
- package/src/channels/github-review-turn-ledger.ts +71 -0
- package/src/channels/persistence.ts +4 -102
- package/src/channels/router.ts +191 -7
- package/src/channels/schema.ts +20 -5
- package/src/cli/channel.ts +2 -1
- package/src/cli/init.ts +2 -1
- 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 +19 -288
- package/src/container/logs.ts +70 -22
- package/src/container/start.ts +0 -2
- package/src/cron/index.ts +3 -44
- package/src/cron/schema.ts +2 -96
- package/src/init/gitignore.ts +1 -2
- 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/secrets/defaults.ts +1 -18
- package/src/secrets/index.ts +0 -2
- package/src/secrets/schema.ts +4 -90
- package/src/secrets/storage.ts +0 -2
- package/src/server/index.ts +0 -4
- package/src/skills/typeclaw-channel-github/SKILL.md +3 -1
- package/src/skills/typeclaw-config/SKILL.md +9 -11
- package/src/skills/typeclaw-permissions/SKILL.md +1 -1
- package/src/tui/index.ts +72 -32
- package/typeclaw.schema.json +1 -0
- package/src/agent/tools/normalize-ref.ts +0 -11
- package/src/bundled-plugins/memory/migration.ts +0 -633
- package/src/secrets/migrate-kakaotalk.ts +0 -82
- package/src/secrets/migrate.ts +0 -96
|
@@ -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
|
+
}
|
package/src/secrets/defaults.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { KNOWN_PROVIDERS, type KnownProviderId } from '@/config/providers'
|
|
|
2
2
|
|
|
3
3
|
// DEFAULT_ENV_NAMES is the single source of truth for the env-var name each
|
|
4
4
|
// secret-bearing field uses when the user does not override it via the `env`
|
|
5
|
-
// field of a `Secret` object.
|
|
5
|
+
// field of a `Secret` object. Two layers depend on it:
|
|
6
6
|
//
|
|
7
7
|
// 1. resolveSecret (src/secrets/resolve.ts) — when the on-disk Secret has
|
|
8
8
|
// no explicit `env`, it falls back to this table to know which env var
|
|
@@ -11,10 +11,6 @@ import { KNOWN_PROVIDERS, type KnownProviderId } from '@/config/providers'
|
|
|
11
11
|
// resolved channel field values into `process.env`, it uses these names
|
|
12
12
|
// so that `src/channels/manager.ts` (which reads `env.DISCORD_BOT_TOKEN`
|
|
13
13
|
// etc. directly) keeps working without per-adapter refactoring.
|
|
14
|
-
// 3. parseSecretsFile legacy upgrade — when reading a v1 file with the old
|
|
15
|
-
// `{ ENV_NAME: value }` channel shape, it inverts this table to rename
|
|
16
|
-
// the keys to the new per-adapter field names.
|
|
17
|
-
//
|
|
18
14
|
// Providers come from `KNOWN_PROVIDERS[id].apiKeyEnv` — derived, not duplicated.
|
|
19
15
|
// OAuth-only providers are intentionally absent: OAuth credentials are not
|
|
20
16
|
// env-injectable (refresh tokens are stateful).
|
|
@@ -31,19 +27,6 @@ export function isKnownAdapterId(id: string): id is KnownAdapterId {
|
|
|
31
27
|
return id in CHANNEL_FIELD_ENV
|
|
32
28
|
}
|
|
33
29
|
|
|
34
|
-
// Reverse map: env-var name -> { adapterId, fieldName }. Built from
|
|
35
|
-
// CHANNEL_FIELD_ENV so adding a new adapter field updates both directions
|
|
36
|
-
// automatically. Used exclusively by the legacy v1 channels-shape upgrade.
|
|
37
|
-
export const CHANNEL_ENV_TO_FIELD: Record<string, { adapterId: KnownAdapterId; fieldName: string }> = (() => {
|
|
38
|
-
const out: Record<string, { adapterId: KnownAdapterId; fieldName: string }> = {}
|
|
39
|
-
for (const [adapterId, fields] of Object.entries(CHANNEL_FIELD_ENV)) {
|
|
40
|
-
for (const [fieldName, envName] of Object.entries(fields)) {
|
|
41
|
-
out[envName] = { adapterId: adapterId as KnownAdapterId, fieldName }
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
return out
|
|
45
|
-
})()
|
|
46
|
-
|
|
47
30
|
// Returns the default env-var name for a known channel field, or undefined
|
|
48
31
|
// when the adapter or field is not in CHANNEL_FIELD_ENV (forward-compat: a
|
|
49
32
|
// future adapter contributed via plugin would not appear in this table).
|
package/src/secrets/index.ts
CHANGED