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
package/src/cron/index.ts CHANGED
@@ -1,17 +1,10 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { readFile, writeFile } from 'node:fs/promises'
2
+ import { readFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
4
 
5
5
  import type { SubagentRegistry } from '@/agent/subagents'
6
- import { commitSystemFile } from '@/git/system-commit'
7
6
 
8
- import {
9
- buildCronMigrationCommitMessage,
10
- type CronFile,
11
- type CronMigrationStep,
12
- migrateLegacyCronShape,
13
- parseCronFile,
14
- } from './schema'
7
+ import { type CronFile, parseCronFile } from './schema'
15
8
 
16
9
  export { createCronReloadable, type CreateCronReloadableOptions } from './reloadable'
17
10
  export {
@@ -31,16 +24,12 @@ export {
31
24
  } from './scheduler'
32
25
  export { aggregateCronList, type CronListEntry, type CronListSource } from './list'
33
26
  export {
34
- buildCronMigrationCommitMessage,
35
27
  cronFileSchema,
36
28
  cronJobSchema,
37
29
  type CronFile,
38
30
  type CronJob,
39
- type CronMigrationResult,
40
- type CronMigrationStep,
41
31
  type ExecJob,
42
32
  type HandlerJob,
43
- migrateLegacyCronShape,
44
33
  parseCronJson,
45
34
  type ParseCronJsonOptions,
46
35
  type ParseCronResult,
@@ -54,12 +43,6 @@ export type LoadCronResult = { ok: true; file: CronFile | null } | { ok: false;
54
43
 
55
44
  export type LoadCronOptions = {
56
45
  subagents?: SubagentRegistry
57
- // When true (the default), legacy-shape migrations are written back
58
- // to cron.json on disk and committed by the system-commit helper.
59
- // Read-only inspection callers must pass `false` so an unaware
60
- // `typeclaw cron list` against a legacy file does not produce a
61
- // commit on whatever branch the user happens to be on.
62
- persistMigrations?: boolean
63
46
  }
64
47
 
65
48
  export async function loadCron(agentDir: string, options: LoadCronOptions = {}): Promise<LoadCronResult> {
@@ -80,36 +63,12 @@ export async function loadCron(agentDir: string, options: LoadCronOptions = {}):
80
63
  return { ok: false, reason: `cron.json is not valid JSON: ${errorMessage(err)}` }
81
64
  }
82
65
 
83
- const migrated = migrateLegacyCronShape(parsed)
84
- const persistMigrations = options.persistMigrations ?? true
85
- if (migrated.changed && persistMigrations) {
86
- await persistMigratedCron(path, migrated.json, agentDir, migrated.applied)
87
- }
88
-
89
- const result = parseCronFile(migrated.json, options.subagents !== undefined ? { subagents: options.subagents } : {})
66
+ const result = parseCronFile(parsed, options.subagents !== undefined ? { subagents: options.subagents } : {})
90
67
  if (!result.ok) return { ok: false, reason: result.reason }
91
68
 
92
69
  return { ok: true, file: result.file }
93
70
  }
94
71
 
95
- async function persistMigratedCron(
96
- path: string,
97
- json: unknown,
98
- agentDir: string,
99
- applied: readonly CronMigrationStep[],
100
- ): Promise<void> {
101
- try {
102
- await writeFile(path, `${JSON.stringify(json, null, 2)}\n`)
103
- } catch {
104
- return
105
- }
106
-
107
- const message = buildCronMigrationCommitMessage(applied)
108
- if (message !== null) {
109
- await commitSystemFile(agentDir, CRON_FILE, message)
110
- }
111
- }
112
-
113
72
  function errorMessage(err: unknown): string {
114
73
  return err instanceof Error ? err.message : String(err)
115
74
  }
@@ -64,99 +64,7 @@ export type ParseCronOptions = {
64
64
  subagents?: SubagentRegistry
65
65
  }
66
66
 
67
- // One-shot rewrite for cron.json files that predate PR #171, when
68
- // `scheduledByRole` became mandatory on every job. The schema gate
69
- // (`parseCronFile`) rejects legacy entries with a precise remediation
70
- // message, but rejecting on every container boot is a stuck state for
71
- // the user — the agent crashes in a tight restart loop with no path
72
- // forward except hand-editing cron.json.
73
- //
74
- // The migration stamps `scheduledByRole: 'owner'` on every job that's
75
- // missing it. `owner` is the right default for two reasons:
76
- // 1. Before #171 there was no role concept; every cron job ran with
77
- // the same (effectively-owner) privileges the agent had.
78
- // 2. The schema gate's own error message tells users to add
79
- // `"scheduledByRole": "owner"` for manually-authored entries —
80
- // we just do it for them.
81
- //
82
- // Mirrors `migrateLegacyConfigShape` in src/config/config.ts: pure
83
- // function, returns the rewritten JSON plus an `applied` array so
84
- // callers can build a meaningful commit message. Returns `changed:
85
- // false` on canonical input so the persist + commit path stays
86
- // untouched on the happy path.
87
- export type CronMigrationStep = { kind: 'stamp-scheduled-by-role-owner'; jobIds: string[] }
88
-
89
- export type CronMigrationResult = { json: unknown; changed: boolean; applied: CronMigrationStep[] }
90
-
91
- export function migrateLegacyCronShape(json: unknown): CronMigrationResult {
92
- if (typeof json !== 'object' || json === null || Array.isArray(json)) {
93
- return { json, changed: false, applied: [] }
94
- }
95
-
96
- const obj = json as Record<string, unknown>
97
- const jobs = obj.jobs
98
- if (!Array.isArray(jobs)) {
99
- return { json, changed: false, applied: [] }
100
- }
101
-
102
- const stampedIds: string[] = []
103
- const nextJobs = jobs.map((job) => {
104
- if (typeof job !== 'object' || job === null || Array.isArray(job)) return job
105
- const record = job as Record<string, unknown>
106
- if ('scheduledByRole' in record) return job
107
- const id = typeof record.id === 'string' ? record.id : '<unknown>'
108
- stampedIds.push(id)
109
- return { ...record, scheduledByRole: 'owner' }
110
- })
111
-
112
- if (stampedIds.length === 0) {
113
- return { json, changed: false, applied: [] }
114
- }
115
-
116
- return {
117
- json: { ...obj, jobs: nextJobs },
118
- changed: true,
119
- applied: [{ kind: 'stamp-scheduled-by-role-owner', jobIds: stampedIds }],
120
- }
121
- }
122
-
123
- // Builds a one-line git commit subject (plus enumerating body) for a
124
- // cron.json migration. Returns null when no steps were applied — callers
125
- // should not commit in that case. Mirrors `buildConfigMigrationCommitMessage`
126
- // in src/config/config.ts.
127
- export function buildCronMigrationCommitMessage(applied: readonly CronMigrationStep[]): string | null {
128
- const first = applied[0]
129
- if (first === undefined) return null
130
-
131
- const subject =
132
- applied.length === 1
133
- ? `cron.json: ${shortCronStepLabel(first)}`
134
- : `cron.json: migrate legacy shape (${applied.length} steps)`
135
-
136
- const bodyLines: string[] = applied.map((step) => `- ${describeCronStep(step)}`)
137
- return `${subject}\n\n${bodyLines.join('\n')}\n`
138
- }
139
-
140
- function shortCronStepLabel(step: CronMigrationStep): string {
141
- switch (step.kind) {
142
- case 'stamp-scheduled-by-role-owner':
143
- return `stamp scheduledByRole: "owner" on ${step.jobIds.length} legacy job${step.jobIds.length === 1 ? '' : 's'}`
144
- }
145
- }
146
-
147
- function describeCronStep(step: CronMigrationStep): string {
148
- switch (step.kind) {
149
- case 'stamp-scheduled-by-role-owner':
150
- return `stamp scheduledByRole: "owner" on jobs without provenance (PR #171 backfill): ${step.jobIds.join(', ')}`
151
- }
152
- }
153
-
154
- export type ParseCronJsonOptions = ParseCronOptions & {
155
- // Apply `migrateLegacyCronShape` before schema validation. Defaults to true
156
- // so the guard accepts the same legacy shapes `loadCron` would auto-migrate
157
- // on disk; pass false to validate the exact bytes (used in tests).
158
- migrate?: boolean
159
- }
67
+ export type ParseCronJsonOptions = ParseCronOptions
160
68
 
161
69
  export function parseCronJson(raw: string, options: ParseCronJsonOptions = {}): ParseCronResult {
162
70
  let json: unknown
@@ -166,9 +74,7 @@ export function parseCronJson(raw: string, options: ParseCronJsonOptions = {}):
166
74
  return { ok: false, reason: `cron.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}` }
167
75
  }
168
76
 
169
- const shouldMigrate = options.migrate ?? true
170
- const migrated = shouldMigrate ? migrateLegacyCronShape(json) : { json, changed: false, applied: [] }
171
- return parseCronFile(migrated.json, options.subagents !== undefined ? { subagents: options.subagents } : {})
77
+ return parseCronFile(json, options.subagents !== undefined ? { subagents: options.subagents } : {})
172
78
  }
173
79
 
174
80
  export function parseCronFile(raw: unknown, options: ParseCronOptions = {}): ParseCronResult {
@@ -38,8 +38,7 @@ export function buildGitignore(config: GitignoreConfig = { append: [] }): string
38
38
  #
39
39
  # auth.json is the pre-rename name for secrets.json; kept here permanently
40
40
  # as a safety net so an agent folder cloned from a pre-rename machine never
41
- # stages credentials by accident, even if its agent boot hasn't yet run the
42
- # auth.json -> secrets.json migration.
41
+ # stages credentials by accident.
43
42
  #
44
43
  # .typeclaw/home/ is the persistent-$HOME overlay populated by the
45
44
  # entrypoint shim's \`link_persistent_home_files\` (see
@@ -16,8 +16,26 @@ export { replayJsonl } from './replay'
16
16
  export { streamLive } from './live'
17
17
  export { parseDuration, parseFilter } from './types'
18
18
  export type { InspectCategory, InspectEvent, InspectFilter } from './types'
19
- export { runInspectLoop } from './loop'
20
- export type { RunInspectLoopOptions } from './loop'
19
+ export { runInspectLoop, runViewerLoop } from './loop'
20
+ export type {
21
+ OpenItem,
22
+ OpenItemContext,
23
+ RunInspectLoopOptions,
24
+ RunViewerLoopOptions,
25
+ SelectItem,
26
+ TailController,
27
+ } from './loop'
28
+ export type { ViewerItem } from './item'
29
+ export { isWritable, itemKey } from './item'
30
+ export { listViewerItems } from './item-list'
31
+ export type { ListViewerItemsOptions, ViewerList } from './item-list'
32
+ export { openViewerItem } from './open-item'
33
+ export type { OpenViewerDeps } from './open-item'
34
+ export { runTuiViewer } from './tui-item'
35
+ export type { RunTuiViewerOptions } from './tui-item'
36
+ export { streamLogs } from './logs-item'
37
+ export { createTranscriptView } from './transcript-view'
38
+ export type { TranscriptViewOptions, TranscriptViewOutcome } from './transcript-view'
21
39
 
22
40
  export type RunInspectOptions = {
23
41
  agentDir: string
@@ -51,7 +69,7 @@ export type LiveSourceFactory = (opts: {
51
69
  }) => AsyncIterable<InspectEvent>
52
70
 
53
71
  export type RunInspectResult =
54
- | { ok: true; exitCode: 0; escToPicker?: boolean }
72
+ | { ok: true; exitCode: number; escToPicker?: boolean }
55
73
  | { ok: false; exitCode: number; reason: string }
56
74
 
57
75
  export type InspectTarget = {
@@ -164,51 +182,58 @@ async function chooseSession(
164
182
  return { ok: true, summary: picked }
165
183
  }
166
184
 
167
- async function streamSession(opts: {
185
+ // Lifecycle phases surfaced to a consumer so renderers can react (e.g. batch a
186
+ // pi-tui render at replay-end, announce a live divider). `sessionLive` on
187
+ // 'live-start' is the registry hit/miss from the live source's onSubscribed.
188
+ export type StreamPhase =
189
+ | { phase: 'replay-end' }
190
+ | { phase: 'replay-only-idle' }
191
+ | { phase: 'live-start'; sessionLive: boolean }
192
+ | { phase: 'end' }
193
+
194
+ export type StreamSessionEventsOptions = {
168
195
  summary: SessionSummary
169
196
  filter: InspectFilter
170
197
  sinceMs: number | undefined
171
- json: boolean
172
- color: boolean
173
- stdout: (line: string) => void
174
- stderr: (line: string) => void
198
+ onEvent: (event: InspectEvent) => void
199
+ onPhase?: (phase: StreamPhase) => void
200
+ onWarn?: (msg: string) => void
175
201
  liveSource?: LiveSourceFactory
176
202
  signal?: AbortSignal
177
- liveHint?: string
178
- interactive?: boolean
179
- }): Promise<{ escToPicker: boolean }> {
180
- if (!opts.json) writeHeader(opts.summary, opts.color, opts.stdout)
181
- const emit = (event: InspectEvent): void => {
203
+ // When true and replay-only with a signal, block until aborted instead of
204
+ // returning immediately — a stable interactive viewer that esc/q dismisses.
205
+ blockWhenReplayOnly?: boolean
206
+ }
207
+
208
+ // The read path shared by the line renderer and the pi-tui transcript view:
209
+ // replay the JSONL transcript, then optionally live-tail, applying since/filter
210
+ // and honoring signal.aborted (-> escToPicker). Knows nothing about rendering —
211
+ // it just delivers ordered, filtered InspectEvents to onEvent and announces
212
+ // phase transitions via onPhase.
213
+ export async function streamSessionEvents(opts: StreamSessionEventsOptions): Promise<{ escToPicker: boolean }> {
214
+ const aborted = (): boolean => opts.signal?.aborted === true
215
+ const deliver = (event: InspectEvent): void => {
182
216
  if (opts.sinceMs !== undefined && event.ts > 0 && event.ts < opts.sinceMs) return
183
217
  if (!matchesFilter(event, opts.filter)) return
184
- if (opts.json) {
185
- opts.stdout(JSON.stringify({ sessionId: opts.summary.sessionId, ...event }))
186
- } else {
187
- opts.stdout(renderEvent(event, { color: opts.color }))
188
- }
218
+ opts.onEvent(event)
189
219
  }
190
220
 
191
- const aborted = (): boolean => opts.signal?.aborted === true
192
-
193
- for await (const event of replayJsonl(opts.summary.sessionFile, { onWarn: opts.stderr })) {
221
+ for await (const event of replayJsonl(
222
+ opts.summary.sessionFile,
223
+ opts.onWarn !== undefined ? { onWarn: opts.onWarn } : {},
224
+ )) {
194
225
  if (aborted()) return { escToPicker: true }
195
- emit(event)
226
+ deliver(event)
196
227
  }
228
+ opts.onPhase?.({ phase: 'replay-end' })
197
229
 
198
230
  if (opts.liveSource === undefined) {
199
- if (!opts.json) opts.stdout('─── end of transcript ───')
200
- // Already aborted during replay (user pressed esc/q): honor it, don't lose the keystroke.
201
231
  if (aborted()) return { escToPicker: true }
202
- // Interactive replay-only: hold a stable viewer like `dreams` instead of
203
- // bouncing straight back to the picker. Block until the tail scope aborts
204
- // (esc → back, q/ctrl-c → exit). Never block without a signal (non-TTY has
205
- // no listener and would hang) or in json/non-interactive mode (scriptability).
206
- if (opts.interactive === true && !opts.json && opts.signal !== undefined) {
207
- if (opts.liveHint !== undefined && opts.liveHint !== '') {
208
- opts.stdout(divider(opts.color, opts.liveHint))
209
- }
232
+ if (opts.blockWhenReplayOnly === true && opts.signal !== undefined) {
233
+ opts.onPhase?.({ phase: 'replay-only-idle' })
210
234
  await waitForAbort(opts.signal)
211
235
  }
236
+ opts.onPhase?.({ phase: 'end' })
212
237
  return { escToPicker: aborted() }
213
238
  }
214
239
 
@@ -227,24 +252,85 @@ async function streamSession(opts: {
227
252
  let liveAnnounced = false
228
253
  try {
229
254
  for await (const event of liveIter) {
230
- if (!liveAnnounced && !opts.json) {
231
- opts.stdout(
232
- divider(opts.color, sessionLive ? '─── live ───' : '─── live (session not in registry; broadcasts only) ───'),
233
- )
234
- if (opts.liveHint !== undefined && opts.liveHint !== '') {
235
- opts.stdout(divider(opts.color, opts.liveHint))
236
- }
255
+ if (!liveAnnounced) {
256
+ opts.onPhase?.({ phase: 'live-start', sessionLive })
237
257
  liveAnnounced = true
238
258
  }
239
- emit(event)
259
+ deliver(event)
240
260
  }
241
261
  } catch (err) {
242
- opts.stderr(`live tail ended: ${err instanceof Error ? err.message : String(err)}`)
262
+ opts.onWarn?.(`live tail ended: ${err instanceof Error ? err.message : String(err)}`)
243
263
  }
244
- if (!opts.json) opts.stdout('─── end of transcript ───')
264
+ opts.onPhase?.({ phase: 'end' })
245
265
  return { escToPicker: aborted() }
246
266
  }
247
267
 
268
+ // Line/JSON renderer: the original streamSession behavior, now expressed as a
269
+ // streamSessionEvents consumer. Preserves the exact header/divider output and
270
+ // scriptable stdout/JSON contract.
271
+ async function streamSession(opts: {
272
+ summary: SessionSummary
273
+ filter: InspectFilter
274
+ sinceMs: number | undefined
275
+ json: boolean
276
+ color: boolean
277
+ stdout: (line: string) => void
278
+ stderr: (line: string) => void
279
+ liveSource?: LiveSourceFactory
280
+ signal?: AbortSignal
281
+ liveHint?: string
282
+ interactive?: boolean
283
+ }): Promise<{ escToPicker: boolean }> {
284
+ if (!opts.json) writeHeader(opts.summary, opts.color, opts.stdout)
285
+
286
+ const onEvent = (event: InspectEvent): void => {
287
+ if (opts.json) opts.stdout(JSON.stringify({ sessionId: opts.summary.sessionId, ...event }))
288
+ else opts.stdout(renderEvent(event, { color: opts.color }))
289
+ }
290
+
291
+ const emitHint = (): void => {
292
+ if (!opts.json && opts.liveHint !== undefined && opts.liveHint !== '') {
293
+ opts.stdout(divider(opts.color, opts.liveHint))
294
+ }
295
+ }
296
+
297
+ // Replay-only prints the end-of-transcript footer once at the idle point (the
298
+ // viewer then blocks); the terminal 'end' phase must not print it a second
299
+ // time. Live mode skips the idle phase and prints the footer only at 'end'.
300
+ let footerPrinted = false
301
+ const printFooter = (): void => {
302
+ opts.stdout('─── end of transcript ───')
303
+ footerPrinted = true
304
+ }
305
+
306
+ const onPhase = (p: StreamPhase): void => {
307
+ if (opts.json) return
308
+ if (p.phase === 'replay-only-idle') {
309
+ printFooter()
310
+ emitHint()
311
+ } else if (p.phase === 'live-start') {
312
+ opts.stdout(
313
+ divider(opts.color, p.sessionLive ? '─── live ───' : '─── live (session not in registry; broadcasts only) ───'),
314
+ )
315
+ emitHint()
316
+ } else if (p.phase === 'end' && !footerPrinted) {
317
+ printFooter()
318
+ }
319
+ }
320
+
321
+ return streamSessionEvents({
322
+ summary: opts.summary,
323
+ filter: opts.filter,
324
+ sinceMs: opts.sinceMs,
325
+ onEvent,
326
+ onPhase,
327
+ onWarn: opts.stderr,
328
+ ...(opts.liveSource !== undefined ? { liveSource: opts.liveSource } : {}),
329
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
330
+ blockWhenReplayOnly: opts.interactive === true && !opts.json,
331
+ })
332
+ }
333
+
248
334
  function divider(color: boolean, text: string): string {
249
335
  if (color) return `\u001b[2m${text}\u001b[0m`
250
336
  return text
@@ -0,0 +1,44 @@
1
+ import type { ViewerItem } from './item'
2
+ import { listSessions, type ListSessionsOptions, type SessionSummary } from './session-list'
3
+
4
+ export type ListViewerItemsOptions = ListSessionsOptions & {
5
+ containerRunning: boolean
6
+ includeLogs?: boolean
7
+ // Defaults to true. The detach-to-list path (after `typeclaw tui` esc) sets
8
+ // this false: detaching ENDS the server-side session, so the just-killed
9
+ // (most-recent) tui transcript — and any older tui transcript the heuristic
10
+ // would otherwise promote — must NOT be offered as a writable live row.
11
+ allowWritable?: boolean
12
+ }
13
+
14
+ export type ViewerList = {
15
+ items: ViewerItem[]
16
+ writableSessionId: string | null
17
+ }
18
+
19
+ // Builds the session-viewer list. The writable TUI row is a heuristic, not an
20
+ // authoritative query: when the container is up, the single most-recent
21
+ // tui-origin session becomes the read+write `tui` item; every other session is
22
+ // read-only. With the container down there is no live session to drive, so all
23
+ // sessions are read-only. The `logs` row is appended last (container stdout,
24
+ // available offline) so it sits below the divider in the picker.
25
+ export async function listViewerItems(opts: ListViewerItemsOptions): Promise<ViewerList> {
26
+ const sessions = await listSessions(opts)
27
+ const allowWritable = opts.allowWritable !== false
28
+ const writableSessionId = opts.containerRunning && allowWritable ? pickWritableSession(sessions) : null
29
+
30
+ const items: ViewerItem[] = sessions.map((summary) =>
31
+ summary.sessionId === writableSessionId
32
+ ? { kind: 'tui', summary, writable: true }
33
+ : { kind: 'session', summary, writable: false },
34
+ )
35
+
36
+ if (opts.includeLogs !== false) items.push({ kind: 'logs' })
37
+
38
+ return { items, writableSessionId }
39
+ }
40
+
41
+ function pickWritableSession(sessions: SessionSummary[]): string | null {
42
+ const tuiSession = sessions.find((s) => s.origin?.kind === 'tui')
43
+ return tuiSession?.sessionId ?? null
44
+ }
@@ -0,0 +1,17 @@
1
+ import type { SessionSummary } from './session-list'
2
+
3
+ // At most one item is `writable` (the live TUI session); every other session is
4
+ // read-only. `logs` carries no session — it is container stdout, available even
5
+ // when the agent server is down.
6
+ export type ViewerItem =
7
+ | { kind: 'session'; summary: SessionSummary; writable: false }
8
+ | { kind: 'tui'; summary: SessionSummary; writable: true }
9
+ | { kind: 'logs' }
10
+
11
+ export function isWritable(item: ViewerItem): item is Extract<ViewerItem, { kind: 'tui' }> {
12
+ return item.kind === 'tui'
13
+ }
14
+
15
+ export function itemKey(item: ViewerItem): string {
16
+ return item.kind === 'logs' ? 'logs' : item.summary.sessionId
17
+ }
@@ -17,7 +17,7 @@ export function originLabel(origin: MinimalSessionOrigin): string {
17
17
  case 'cron':
18
18
  return `Cron ${origin.jobId} (${origin.jobKind})`
19
19
  case 'subagent':
20
- return `Subagent ${origin.subagent} ← ${shortSessionId(origin.parentSessionId)}`
20
+ return `Subagent ${origin.subagent}`
21
21
  case 'channel':
22
22
  return channelLabel(origin)
23
23
  case 'system':
@@ -0,0 +1,79 @@
1
+ import { logs } from '@/container'
2
+
3
+ export type StreamLogsOptions = {
4
+ cwd: string
5
+ color: boolean
6
+ stdout: (line: string) => void
7
+ stderr: (line: string) => void
8
+ signal?: AbortSignal
9
+ liveHint?: string
10
+ }
11
+
12
+ export type StreamLogsResult = { escToPicker: boolean }
13
+
14
+ // Interactive container-logs viewer for the session-viewer list. Unlike the raw
15
+ // `logs` pump (host-stage, used for `typeclaw logs | grep`), this one runs under
16
+ // the loop's tail scope: aborting the signal (esc/q/ctrl-c) kills `docker logs`
17
+ // and returns control to the picker. Works with the agent server down — it only
18
+ // needs the container to exist.
19
+ export async function streamLogs(opts: StreamLogsOptions): Promise<StreamLogsResult> {
20
+ const aborted = (): boolean => opts.signal?.aborted === true
21
+ if (aborted()) return { escToPicker: true }
22
+
23
+ opts.stdout(divider(opts.color, '─── container logs ───'))
24
+ if (opts.liveHint !== undefined && opts.liveHint !== '') {
25
+ opts.stdout(divider(opts.color, opts.liveHint))
26
+ }
27
+
28
+ const out = lineSink(opts.stdout)
29
+ const err = lineSink(opts.stderr)
30
+
31
+ const result = await logs({
32
+ cwd: opts.cwd,
33
+ follow: true,
34
+ out,
35
+ err,
36
+ useColor: opts.color,
37
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
38
+ })
39
+
40
+ if (!result.ok) {
41
+ opts.stderr(result.reason)
42
+ return { escToPicker: aborted() }
43
+ }
44
+ return { escToPicker: aborted() }
45
+ }
46
+
47
+ function divider(color: boolean, text: string): string {
48
+ if (color) return `\u001b[2m${text}\u001b[0m`
49
+ return text
50
+ }
51
+
52
+ // `logs` writes pre-formatted chunks (already newline-terminated) to a
53
+ // WritableStream; the loop's sink wants newline-free lines. Buffer partial
54
+ // lines and forward complete ones so a chunk split mid-line never emits a
55
+ // truncated row.
56
+ function lineSink(emit: (line: string) => void): NodeJS.WritableStream {
57
+ let buffer = ''
58
+ const flushLines = (): void => {
59
+ let idx = buffer.indexOf('\n')
60
+ while (idx !== -1) {
61
+ emit(buffer.slice(0, idx))
62
+ buffer = buffer.slice(idx + 1)
63
+ idx = buffer.indexOf('\n')
64
+ }
65
+ }
66
+ return {
67
+ write(chunk: string | Uint8Array): boolean {
68
+ buffer += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)
69
+ flushLines()
70
+ return true
71
+ },
72
+ end(): void {
73
+ if (buffer.length > 0) {
74
+ emit(buffer)
75
+ buffer = ''
76
+ }
77
+ },
78
+ } as unknown as NodeJS.WritableStream
79
+ }
@@ -6,6 +6,80 @@ export type TailController = {
6
6
  dispose: () => void
7
7
  }
8
8
 
9
+ export type OpenItemContext = {
10
+ createTailScope: () => TailController
11
+ }
12
+
13
+ export type SelectItem<TItem> = (items: TItem[], opts: { initialKey?: string }) => Promise<TItem | null>
14
+
15
+ export type OpenItemResult = {
16
+ result: RunInspectResult
17
+ // True only when the viewer that just closed ended a writable (live TUI)
18
+ // session — i.e. a tui detach. Logs and read-only transcripts return
19
+ // escToPicker WITHOUT this, so they must not suppress the writable row.
20
+ endedWritableSession?: boolean
21
+ }
22
+
23
+ export type OpenItem<TItem> = (item: TItem, ctx: OpenItemContext) => Promise<OpenItemResult>
24
+
25
+ export type ListItemsContext = {
26
+ // False once the user has returned to the picker from any viewer: the prior
27
+ // viewer interaction ended, so there is no proof of a still-live writable
28
+ // session — a detached tui session must not be re-promoted as writable.
29
+ allowWritable: boolean
30
+ }
31
+
32
+ export type RunViewerLoopOptions<TItem> = {
33
+ listItems: (ctx: ListItemsContext) => Promise<TItem[]>
34
+ keyOf: (item: TItem) => string
35
+ preselectKey?: string
36
+ selectItem: SelectItem<TItem>
37
+ openItem: OpenItem<TItem>
38
+ createTailScope: () => TailController
39
+ onEmpty: () => RunInspectResult
40
+ }
41
+
42
+ // The session-viewer state machine: pick an item → open it → on back, re-open
43
+ // the picker; on exit, return. `openItem` owns the per-branch lifecycle and
44
+ // decides whether to request a tail scope (session/logs do; tui does not, since
45
+ // it owns its own raw-mode terminal). When used, the tail scope is created
46
+ // inside `openItem` AFTER the picker resolves and disposed before the picker
47
+ // re-opens, so clack always owns a clean cooked-mode stdin.
48
+ export async function runViewerLoop<TItem>(opts: RunViewerLoopOptions<TItem>): Promise<RunInspectResult> {
49
+ let preselectKey = opts.preselectKey
50
+ let lastPickedKey: string | undefined
51
+ // Writable is only safe on the very first list. Returning to the picker means
52
+ // a viewer was just opened and left — any writable session it might represent
53
+ // is gone (detach ends the live session), so subsequent refreshes are
54
+ // read-only.
55
+ let allowWritable = true
56
+
57
+ while (true) {
58
+ const items = await opts.listItems({ allowWritable })
59
+ if (items.length === 0) return opts.onEmpty()
60
+
61
+ let chosen: TItem | null
62
+ if (preselectKey !== undefined) {
63
+ chosen = items.find((i) => opts.keyOf(i) === preselectKey) ?? null
64
+ preselectKey = undefined
65
+ if (chosen === null) return opts.onEmpty()
66
+ } else {
67
+ const hint = lastPickedKey
68
+ chosen = await opts.selectItem(items, hint !== undefined ? { initialKey: hint } : {})
69
+ if (chosen === null) return { ok: false, exitCode: 130, reason: 'cancelled' }
70
+ lastPickedKey = opts.keyOf(chosen)
71
+ }
72
+
73
+ const opened = await opts.openItem(chosen, { createTailScope: opts.createTailScope })
74
+ const result = opened.result
75
+ if (!result.ok) return result
76
+ if (result.escToPicker !== true) return result
77
+ // Only a writable (tui) detach ends the live session; leaving logs or a
78
+ // read-only transcript leaves it untouched, so the writable row stays.
79
+ if (opened.endedWritableSession === true) allowWritable = false
80
+ }
81
+ }
82
+
9
83
  export type RunInspectLoopOptions = Omit<RunInspectOptions, 'signal'> & {
10
84
  // Builds a fresh interaction scope for ONE live-tail attempt: a new
11
85
  // AbortController plus a temporary raw-mode listener. The loop creates it
@@ -30,12 +104,9 @@ export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunIn
30
104
  if (sessionArg !== undefined) resolveOpts.sessionIdOrPrefix = sessionArg
31
105
  else delete (resolveOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
32
106
 
33
- // Picker phase: cooked-mode stdin, no tail scope alive.
34
107
  const resolved = await resolveInspectTarget(resolveOpts)
35
108
  if (!resolved.ok) return resolved
36
109
 
37
- // Streaming phase: scope owns raw-mode stdin start-to-dispose, never
38
- // spanning the picker above or the next iteration's picker below.
39
110
  const scope = opts.createTailScope()
40
111
  let result: RunInspectResult
41
112
  try {