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.
Files changed (62) hide show
  1. package/package.json +1 -1
  2. package/scripts/generate-schema.ts +4 -6
  3. package/src/agent/index.ts +26 -4
  4. package/src/agent/multimodal/look-at.ts +1 -2
  5. package/src/agent/session-origin.ts +9 -1
  6. package/src/agent/tools/channel-fetch-attachment.ts +1 -2
  7. package/src/agent/tools/channel-react.ts +9 -3
  8. package/src/agent/tools/channel-reply.ts +30 -1
  9. package/src/agent/tools/channel-send.ts +94 -1
  10. package/src/bundled-plugins/github-cli-auth/gh-review-detect.ts +175 -0
  11. package/src/bundled-plugins/github-cli-auth/index.ts +4 -0
  12. package/src/bundled-plugins/github-cli-auth/review-recorder.ts +93 -0
  13. package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
  14. package/src/bundled-plugins/memory/README.md +3 -21
  15. package/src/bundled-plugins/memory/index.ts +1 -149
  16. package/src/bundled-plugins/reviewer/skills/code-review.ts +3 -1
  17. package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
  18. package/src/channels/adapters/github/inbound.ts +155 -9
  19. package/src/channels/adapters/github/review-thread-resolver.ts +93 -8
  20. package/src/channels/github-false-receipt.ts +87 -0
  21. package/src/channels/github-review-claim.ts +91 -0
  22. package/src/channels/github-review-turn-ledger.ts +71 -0
  23. package/src/channels/persistence.ts +4 -102
  24. package/src/channels/router.ts +191 -7
  25. package/src/channels/schema.ts +20 -5
  26. package/src/cli/channel.ts +2 -1
  27. package/src/cli/init.ts +2 -1
  28. package/src/cli/inspect.ts +216 -36
  29. package/src/cli/logs.ts +15 -0
  30. package/src/cli/tui.ts +33 -39
  31. package/src/compose/logs.ts +1 -1
  32. package/src/config/config.ts +19 -288
  33. package/src/container/logs.ts +70 -22
  34. package/src/container/start.ts +0 -2
  35. package/src/cron/index.ts +3 -44
  36. package/src/cron/schema.ts +2 -96
  37. package/src/init/gitignore.ts +1 -2
  38. package/src/inspect/index.ts +128 -42
  39. package/src/inspect/item-list.ts +44 -0
  40. package/src/inspect/item.ts +17 -0
  41. package/src/inspect/label.ts +1 -1
  42. package/src/inspect/logs-item.ts +79 -0
  43. package/src/inspect/loop.ts +74 -3
  44. package/src/inspect/open-item.ts +100 -0
  45. package/src/inspect/preview.ts +106 -0
  46. package/src/inspect/session-list.ts +15 -3
  47. package/src/inspect/transcript-view.ts +182 -0
  48. package/src/inspect/tui-item.ts +97 -0
  49. package/src/secrets/defaults.ts +1 -18
  50. package/src/secrets/index.ts +0 -2
  51. package/src/secrets/schema.ts +4 -90
  52. package/src/secrets/storage.ts +0 -2
  53. package/src/server/index.ts +0 -4
  54. package/src/skills/typeclaw-channel-github/SKILL.md +3 -1
  55. package/src/skills/typeclaw-config/SKILL.md +9 -11
  56. package/src/skills/typeclaw-permissions/SKILL.md +1 -1
  57. package/src/tui/index.ts +72 -32
  58. package/typeclaw.schema.json +1 -0
  59. package/src/agent/tools/normalize-ref.ts +0 -11
  60. package/src/bundled-plugins/memory/migration.ts +0 -633
  61. package/src/secrets/migrate-kakaotalk.ts +0 -82
  62. 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
- 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
+ }
@@ -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. Three layers depend on it:
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).
@@ -6,8 +6,6 @@ export { type Secret } from './resolve'
6
6
 
7
7
  export { hydrateChannelEnvFromSecrets } from './hydrate'
8
8
 
9
- export { migrateKakaotalkCredentials } from './migrate-kakaotalk'
10
-
11
9
  export {
12
10
  type ExportCodexAuthFileResult,
13
11
  exportCodexAuthFileForAgent,