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
@@ -32,6 +32,7 @@ import {
32
32
  StickyLedger,
33
33
  type EngagementDecision,
34
34
  } from './engagement'
35
+ import { resetReviewTurn } from './github-review-turn-ledger'
35
36
  import {
36
37
  MEMBERSHIP_COLD_FETCH_TIMEOUT_MS,
37
38
  type MembershipCount,
@@ -1844,6 +1845,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1844
1845
  live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
1845
1846
  live.skipLockedSendTurn = null
1846
1847
  live.policyDeniedToolSendsThisTurn.clear()
1848
+ resetReviewTurn(live.sessionId)
1847
1849
  const isRealUserTurn = batch.length > 0
1848
1850
  await fireSessionTurnStart(live, text)
1849
1851
  try {
@@ -2854,7 +2856,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2854
2856
  return
2855
2857
  }
2856
2858
 
2857
- const { text: assistantText, source } = candidate
2859
+ const { text: candidateText, source } = candidate
2860
+ let assistantText = candidateText
2858
2861
 
2859
2862
  if (endsWithNoReplySignal(assistantText)) {
2860
2863
  const leakedReasoning = !isNoReplySignal(assistantText)
@@ -2874,10 +2877,49 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2874
2877
  return
2875
2878
  }
2876
2879
 
2877
- if (isLikelyPlainTextChannelToolCall(assistantText)) {
2878
- logger.warn(`[channels] ${live.keyId}: suppressed plain_text_channel_tool_call text_len=${assistantText.length}`)
2880
+ // Plain-text tool-call leak: the model serialized a channel tool call as
2881
+ // ordinary prose instead of producing a real tool call (a Kimi-on-Fireworks
2882
+ // failure mode — see `isLikelyPlainTextChannelToolCall`). We can't post the
2883
+ // raw `channel_reply({...})` serialization to the channel, but for
2884
+ // reply/send the model's *intent* is unambiguous: deliver the `text` arg.
2885
+ // Extract it and recover the actual message. `skip_response` is the
2886
+ // opposite — a genuine decline — so it stays suppressed.
2887
+ const plainTextToolCallKind = getPlainTextChannelToolCallKind(assistantText)
2888
+ if (plainTextToolCallKind === 'skip') {
2889
+ logger.warn(
2890
+ `[channels] ${live.keyId}: suppressed plain_text_channel_skip_response text_len=${assistantText.length}`,
2891
+ )
2879
2892
  return
2880
2893
  }
2894
+ if (plainTextToolCallKind !== null) {
2895
+ const extracted = extractPlainTextChannelToolCallText(assistantText)
2896
+ // Unextractable (no `text` arg, empty value, or fully-truncated): fall
2897
+ // back to the historical safe behavior — drop it rather than leak plumbing.
2898
+ if (extracted === null) {
2899
+ logger.warn(
2900
+ `[channels] ${live.keyId}: suppressed unextractable_plain_text_channel_tool_call text_len=${assistantText.length}`,
2901
+ )
2902
+ return
2903
+ }
2904
+ // The extracted value is still untrusted model output: if it is itself a
2905
+ // no-reply signal, an empty-response sentinel, or another (nested) leaked
2906
+ // tool call, suppress it through the same guards rather than re-leaking.
2907
+ if (
2908
+ endsWithNoReplySignal(extracted) ||
2909
+ isUpstreamEmptyResponseSentinel(extracted) ||
2910
+ isLikelyKimiChannelToolLeak(extracted) ||
2911
+ isLikelyPlainTextChannelToolCall(extracted)
2912
+ ) {
2913
+ logger.warn(
2914
+ `[channels] ${live.keyId}: suppressed plain_text_channel_tool_call (unsafe extracted text) text_len=${extracted.length}`,
2915
+ )
2916
+ return
2917
+ }
2918
+ logger.warn(
2919
+ `[channels] ${live.keyId}: recovered plain_text_channel_tool_call kind=${plainTextToolCallKind} text_len=${extracted.length}`,
2920
+ )
2921
+ assistantText = extracted
2922
+ }
2881
2923
 
2882
2924
  // `source` distinguishes the three recovery shapes for log triage:
2883
2925
  // - 'leaf': the assistant message IS the leaf with stopReason 'stop'
@@ -4233,8 +4275,12 @@ export function isLikelyKimiChannelToolLeak(text: string): boolean {
4233
4275
  //
4234
4276
  // Structural-only detection (NOT a substring search): the trimmed text must
4235
4277
  // *start* with `channel_reply(`, `channel_send(`, or `skip_response(`, and
4236
- // that opening paren must enclose at least one `"` (the serialized argument).
4237
- // This deliberately matches the leak shape while letting prose that merely
4278
+ // that opening paren must enclose at least one quote — `"` or `'` (the
4279
+ // serialized argument). The single-quote arm matters because the extractor
4280
+ // recovers single-quoted values too; if the classifier only matched `"`, a
4281
+ // single-quoted leak like `channel_reply({text: 'hi'})` would bypass the
4282
+ // extractor and post raw plumbing. This deliberately matches the leak shape
4283
+ // while letting prose that merely
4238
4284
  // *mentions* a tool name (e.g. "I would normally call channel_reply here
4239
4285
  // but...") reach the user — that false-positive class is already locked in by
4240
4286
  // the `still recovers prose that mentions channel_reply` test.
@@ -4242,12 +4288,150 @@ export function isLikelyKimiChannelToolLeak(text: string): boolean {
4242
4288
  // The trailing close paren is NOT required: the model sometimes truncates
4243
4289
  // mid-serialization, and a half-leaked `channel_reply({"text":"..."` is
4244
4290
  // just as user-hostile as the full shape.
4245
- const PLAIN_TEXT_CHANNEL_TOOL_CALL_RE = /^(?:channel_(?:reply|send)|skip_response)\s*\(\s*[^)]*"/
4291
+ const PLAIN_TEXT_CHANNEL_TOOL_CALL_RE = /^(channel_reply|channel_send|skip_response)\s*\(\s*[^)]*["']/
4292
+
4293
+ export type PlainTextChannelToolCallKind = 'reply' | 'send' | 'skip'
4294
+
4295
+ export function getPlainTextChannelToolCallKind(text: string): PlainTextChannelToolCallKind | null {
4296
+ const match = PLAIN_TEXT_CHANNEL_TOOL_CALL_RE.exec(text.trim())
4297
+ if (match === null) return null
4298
+ switch (match[1]) {
4299
+ case 'channel_reply':
4300
+ return 'reply'
4301
+ case 'channel_send':
4302
+ return 'send'
4303
+ case 'skip_response':
4304
+ return 'skip'
4305
+ default:
4306
+ return null
4307
+ }
4308
+ }
4246
4309
 
4247
4310
  export function isLikelyPlainTextChannelToolCall(text: string): boolean {
4248
- return PLAIN_TEXT_CHANNEL_TOOL_CALL_RE.test(text.trim())
4311
+ return getPlainTextChannelToolCallKind(text) !== null
4312
+ }
4313
+
4314
+ // Tolerant single-purpose scanner that pulls the `text` argument out of a
4315
+ // plain-text-serialized `channel_reply(...)` / `channel_send(...)` leak. A
4316
+ // single regex covering every shape (double/single/unquoted keys, escaped
4317
+ // quotes, mid-serialization truncation) is fragile, so this walks the string
4318
+ // once and extracts only the first string-valued `text` property. `channel_send`
4319
+ // also carries `adapter`/`chat`/`thread`, which are intentionally ignored —
4320
+ // recovery always routes back through the current channel, never a
4321
+ // model-supplied destination. Returns null when no recoverable, non-empty
4322
+ // `text` value is present so the caller can fall back to suppression.
4323
+ export function extractPlainTextChannelToolCallText(text: string): string | null {
4324
+ const trimmed = text.trim()
4325
+ if (!/^(?:channel_reply|channel_send)\s*\(/.test(trimmed)) return null
4326
+
4327
+ // Walk the serialization once, honoring a `text` key only at the top level of
4328
+ // the argument object (braceDepth 1, outside any array). Two failure classes
4329
+ // motivate the bookkeeping: a `text:` inside an earlier quoted value, e.g.
4330
+ // `channel_send({ reason: "see text: here", text: "real" })`, and a `text:`
4331
+ // inside a *nested* object, e.g. `channel_reply({ meta: { text: "x" }, text:
4332
+ // "real" })`. Skipping string literals defeats the first; tracking
4333
+ // brace/bracket depth and matching keys only at top level defeats the second.
4334
+ // Either way the scanner lands on the real reply instead of leaking the wrong
4335
+ // value or dropping the message.
4336
+ let braceDepth = 0
4337
+ let bracketDepth = 0
4338
+ for (let i = 0; i < trimmed.length; i++) {
4339
+ const ch = trimmed[i]!
4340
+
4341
+ if (ch === '"' || ch === "'") {
4342
+ i = skipStringLiteral(trimmed, i, ch)
4343
+ continue
4344
+ }
4345
+
4346
+ if (ch === '{') {
4347
+ braceDepth++
4348
+ if (braceDepth === 1 && bracketDepth === 0) {
4349
+ const value = readTextKeyValueAt(trimmed, i + 1)
4350
+ if (value !== undefined) return value
4351
+ }
4352
+ continue
4353
+ }
4354
+ if (ch === '}') {
4355
+ if (braceDepth > 0) braceDepth--
4356
+ continue
4357
+ }
4358
+ if (ch === '[') {
4359
+ bracketDepth++
4360
+ continue
4361
+ }
4362
+ if (ch === ']') {
4363
+ if (bracketDepth > 0) bracketDepth--
4364
+ continue
4365
+ }
4366
+
4367
+ if (ch === ',' && braceDepth === 1 && bracketDepth === 0) {
4368
+ const value = readTextKeyValueAt(trimmed, i + 1)
4369
+ if (value !== undefined) return value
4370
+ }
4371
+ }
4372
+
4373
+ return null
4374
+ }
4375
+
4376
+ // Returns the recovered value (string or null) when a `text` key starts at
4377
+ // `from`, or undefined when no `text` key is present there so the scanner keeps
4378
+ // walking. The null/undefined split lets a malformed `text` value short-circuit
4379
+ // to suppression while a non-`text` delimiter is simply skipped.
4380
+ function readTextKeyValueAt(s: string, from: number): string | null | undefined {
4381
+ const afterKey = matchTextKey(s, from)
4382
+ if (afterKey === null) return undefined
4383
+
4384
+ const quote = s[afterKey]
4385
+ if (quote !== '"' && quote !== "'") return null
4386
+ return readStringValue(s, afterKey + 1, quote)
4387
+ }
4388
+
4389
+ // Returns the closing-quote index, or the last index when the literal is
4390
+ // truncated, so the caller's `i++` resumes past the consumed string.
4391
+ function skipStringLiteral(s: string, openIdx: number, quote: string): number {
4392
+ let escaped = false
4393
+ for (let i = openIdx + 1; i < s.length; i++) {
4394
+ const ch = s[i]!
4395
+ if (escaped) {
4396
+ escaped = false
4397
+ continue
4398
+ }
4399
+ if (ch === '\\') {
4400
+ escaped = true
4401
+ continue
4402
+ }
4403
+ if (ch === quote) return i
4404
+ }
4405
+ return s.length
4406
+ }
4407
+
4408
+ function matchTextKey(s: string, from: number): number | null {
4409
+ const m = /^\s*(?:"text"|'text'|text)\s*:\s*/.exec(s.slice(from))
4410
+ return m === null ? null : from + m[0].length
4249
4411
  }
4250
4412
 
4413
+ function readStringValue(s: string, from: number, quote: string): string | null {
4414
+ let value = ''
4415
+ let escaped = false
4416
+ for (let i = from; i < s.length; i++) {
4417
+ const ch = s[i]!
4418
+ if (escaped) {
4419
+ value += ESCAPE_REPLACEMENTS[ch] ?? ch
4420
+ escaped = false
4421
+ continue
4422
+ }
4423
+ if (ch === '\\') {
4424
+ escaped = true
4425
+ continue
4426
+ }
4427
+ if (ch === quote) break
4428
+ value += ch
4429
+ }
4430
+ return value.trim().length > 0 ? value : null
4431
+ }
4432
+
4433
+ const ESCAPE_REPLACEMENTS: Record<string, string> = { n: '\n', r: '\r', t: '\t' }
4434
+
4251
4435
  function describe(err: unknown): string {
4252
4436
  return err instanceof Error ? err.message : String(err)
4253
4437
  }
@@ -107,11 +107,9 @@ const quotedReplySchema = z
107
107
  .default({ enabled: true, queueDelayMs: DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS })
108
108
 
109
109
  // Deliberately non-strict: a stale on-disk file may still carry the
110
- // legacy `allow` field (`migrateLegacyConfigShape` lifts it into
111
- // `roles.member.match[]` on load, but a between-reload window can
112
- // briefly contain both). Zod silently drops unknown keys here, which is
113
- // exactly what we want — a hard `.strict()` reject would brick recovery
114
- // for any user mid-migration.
110
+ // legacy `allow` field. Zod silently drops unknown keys here, which is
111
+ // exactly what we want — the field is ignored, not translated, and a hard
112
+ // `.strict()` reject would brick recovery for any user with an old config.
115
113
  const adapterSchema = z.object({
116
114
  engagement: engagementSchema,
117
115
  history: historySchema,
@@ -128,6 +126,7 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
128
126
  'pull_request.ready_for_review',
129
127
  'pull_request.review_requested',
130
128
  'pull_request.review_request_removed',
129
+ 'pull_request.synchronize',
131
130
  'discussion.created',
132
131
  'pull_request_review.submitted',
133
132
  ] as const
@@ -158,6 +157,21 @@ const GITHUB_EVENT_ALLOWLIST_V2 = [
158
157
  'discussion.created',
159
158
  'pull_request_review.submitted',
160
159
  ] as const
160
+ // - v3: added ready_for_review, shipped 0.12.0+ (the default just before
161
+ // synchronize was added). Snapshotted here so configs seeded with the
162
+ // pre-synchronize default unfreeze and re-track the new default.
163
+ const GITHUB_EVENT_ALLOWLIST_V3 = [
164
+ 'issue_comment.created',
165
+ 'pull_request_review_comment.created',
166
+ 'discussion_comment.created',
167
+ 'issues.opened',
168
+ 'pull_request.opened',
169
+ 'pull_request.ready_for_review',
170
+ 'pull_request.review_requested',
171
+ 'pull_request.review_request_removed',
172
+ 'discussion.created',
173
+ 'pull_request_review.submitted',
174
+ ] as const
161
175
 
162
176
  // Every event-allowlist that `channel add` / `init` has ever seeded verbatim
163
177
  // into typeclaw.json, oldest first, current default last. The legacy-shape
@@ -169,6 +183,7 @@ const GITHUB_EVENT_ALLOWLIST_V2 = [
169
183
  export const SEEDED_GITHUB_EVENT_ALLOWLISTS: readonly (readonly string[])[] = [
170
184
  GITHUB_EVENT_ALLOWLIST_V1,
171
185
  GITHUB_EVENT_ALLOWLIST_V2,
186
+ GITHUB_EVENT_ALLOWLIST_V3,
172
187
  DEFAULT_GITHUB_EVENT_ALLOWLIST,
173
188
  ]
174
189
 
@@ -629,8 +629,9 @@ async function promptGithubCredentials(cwd: string): Promise<{
629
629
  message: 'GitHub authentication type',
630
630
  options: [
631
631
  { value: 'pat', label: 'Fine-grained personal access token' },
632
- { value: 'app', label: 'GitHub App installation token' },
632
+ { value: 'app', label: 'GitHub App installation token (recommended)' },
633
633
  ],
634
+ initialValue: 'app',
634
635
  })
635
636
  if (isCancel(authType)) {
636
637
  cancel('Aborted.')
package/src/cli/init.ts CHANGED
@@ -1182,8 +1182,9 @@ async function runGithubFlow(cwd: string): Promise<StepResult<CollectedInputs['c
1182
1182
  message: 'GitHub authentication type',
1183
1183
  options: [
1184
1184
  { value: 'pat', label: 'Fine-grained personal access token' },
1185
- { value: 'app', label: 'GitHub App installation token' },
1185
+ { value: 'app', label: 'GitHub App installation token (recommended)' },
1186
1186
  ],
1187
+ initialValue: 'app',
1187
1188
  })
1188
1189
  if (isCancel(authType)) return back()
1189
1190
  const auth = authType === 'pat' ? await promptGithubPatAuth() : await promptGithubAppAuth()
@@ -2,7 +2,19 @@ import { defineCommand } from 'citty'
2
2
 
3
3
  import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
4
4
  import { findAgentDir } from '@/init'
5
- import { runInspectLoop, streamLive, type LiveSourceFactory, type SessionSummary } from '@/inspect'
5
+ import {
6
+ listViewerItems,
7
+ openViewerItem,
8
+ parseDuration,
9
+ parseFilter,
10
+ resolveSession,
11
+ runInspectLoop,
12
+ runViewerLoop,
13
+ streamLive,
14
+ type LiveSourceFactory,
15
+ type SessionSummary,
16
+ type ViewerItem,
17
+ } from '@/inspect'
6
18
  import { originLabel, shortSessionId } from '@/inspect/label'
7
19
 
8
20
  import { createTailScope } from './inspect-controller'
@@ -13,12 +25,12 @@ const ESC_DEBOUNCE_MS = 50
13
25
  export const inspectCommand = defineCommand({
14
26
  meta: {
15
27
  name: 'inspect',
16
- description: 'observe a session: replay the transcript, then tail live activity (host stage)',
28
+ description: 'session viewer: pick a session, the live TUI, or container logs to observe (host stage)',
17
29
  },
18
30
  args: {
19
31
  session: {
20
32
  type: 'positional',
21
- description: 'session id or short prefix (omit to pick from a list)',
33
+ description: 'session id or short prefix (omit to pick from the list)',
22
34
  required: false,
23
35
  },
24
36
  filter: {
@@ -42,42 +54,175 @@ export const inspectCommand = defineCommand({
42
54
  const sessionArg = typeof args.session === 'string' ? args.session : undefined
43
55
  const filterArg = typeof args.filter === 'string' ? args.filter : undefined
44
56
  const sinceArg = typeof args.since === 'string' ? args.since : undefined
45
-
46
57
  const isJson = args.json === true
47
- const liveSource = isJson ? undefined : await buildLiveSource(cwd)
48
- const interactive = !isJson && Boolean(process.stdin.isTTY)
49
- const liveHint = interactive ? escHintLine(color) : undefined
50
-
51
- const result = await runInspectLoop({
52
- agentDir: cwd,
53
- ...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
54
- ...(filterArg !== undefined ? { filter: filterArg } : {}),
55
- ...(sinceArg !== undefined ? { since: sinceArg } : {}),
56
- json: isJson,
57
- color,
58
- selectSession: (sessions, selectOpts) => clackSelect(sessions, selectOpts?.initialSessionId),
59
- ...(liveSource !== undefined ? { liveSource } : {}),
60
- createTailScope: () => createTailScope({ debounceMs: ESC_DEBOUNCE_MS }),
61
- ...(interactive ? { interactive: true } : {}),
62
- ...(liveHint !== undefined ? { liveHint } : {}),
63
- stdout: (line) => process.stdout.write(`${line}\n`),
64
- stderr: (line) => process.stderr.write(`${line}\n`),
65
- })
66
58
 
67
- if (!result.ok) {
68
- process.stderr.write(`${errorLine(result.reason)}\n`)
69
- process.exit(result.exitCode)
59
+ // JSON mode stays the scriptable, session-only path: no list, no logs/tui
60
+ // rows, explicit session id required. Behavior is unchanged from before the
61
+ // viewer merge.
62
+ if (isJson) {
63
+ const result = await runInspectLoop({
64
+ agentDir: cwd,
65
+ ...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
66
+ ...(filterArg !== undefined ? { filter: filterArg } : {}),
67
+ ...(sinceArg !== undefined ? { since: sinceArg } : {}),
68
+ json: true,
69
+ color,
70
+ selectSession: (sessions, selectOpts) => clackSelectSession(sessions, selectOpts?.initialSessionId),
71
+ createTailScope: () => createTailScope({ debounceMs: ESC_DEBOUNCE_MS }),
72
+ stdout: (line) => process.stdout.write(`${line}\n`),
73
+ stderr: (line) => process.stderr.write(`${line}\n`),
74
+ })
75
+ finish(result)
76
+ return
70
77
  }
71
- process.exit(result.exitCode)
78
+
79
+ const exitCode = await runInspectViewer({
80
+ cwd,
81
+ ...(sessionArg !== undefined ? { sessionArg } : {}),
82
+ ...(filterArg !== undefined ? { filterArg } : {}),
83
+ ...(sinceArg !== undefined ? { sinceArg } : {}),
84
+ color,
85
+ })
86
+ process.exit(exitCode)
72
87
  },
73
88
  })
74
89
 
75
- async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefined> {
76
- const precheck = await requireContainerRunning({ cwd })
77
- if (!precheck.ok) {
78
- process.stderr.write(`${c.yellow('⚠')} ${precheck.reason}; tailing live events disabled\n`)
79
- return undefined
90
+ export type RunInspectViewerOptions = {
91
+ cwd: string
92
+ sessionArg?: string
93
+ filterArg?: string
94
+ sinceArg?: string
95
+ color?: boolean
96
+ // Set false by the `tui` detach handoff: the live session was just ended, so
97
+ // no row should be offered as writable (see listViewerItems).
98
+ allowWritable?: boolean
99
+ }
100
+
101
+ // The interactive session-viewer: list → open → back to list. Shared by the
102
+ // `inspect` command and `tui`'s esc-detach fallthrough. Returns an exit code
103
+ // instead of calling process.exit so callers can chain (e.g. tui drops here).
104
+ export async function runInspectViewer(opts: RunInspectViewerOptions): Promise<number> {
105
+ const { cwd } = opts
106
+ const color = opts.color ?? useColor()
107
+
108
+ const filterResult = parseFilter(opts.filterArg)
109
+ if (!filterResult.ok) {
110
+ process.stderr.write(`${errorLine(filterResult.reason)}\n`)
111
+ return 2
112
+ }
113
+ let sinceMs: number | undefined
114
+ if (opts.sinceArg !== undefined) {
115
+ const d = parseDuration(opts.sinceArg)
116
+ if (!d.ok) {
117
+ process.stderr.write(`${errorLine(d.reason)}\n`)
118
+ return 2
119
+ }
120
+ sinceMs = Date.now() - d.ms
121
+ }
122
+
123
+ const containerRunning = (await requireContainerRunning({ cwd })).ok
124
+ if (!containerRunning) {
125
+ process.stderr.write(`${c.yellow('⚠')} container not running; showing read-only history and logs only\n`)
126
+ }
127
+
128
+ const sessionsDir = `${cwd}/sessions`
129
+
130
+ // Resolve a session arg (id or short prefix) to a full session id BEFORE the
131
+ // loop: runViewerLoop matches preselectKey against exact itemKeys, so a bare
132
+ // prefix would otherwise miss every row and report "no sessions". 'logs' is a
133
+ // reserved key, not a session, so it bypasses resolution.
134
+ let preselectKey: string | undefined
135
+ if (opts.sessionArg !== undefined && opts.sessionArg !== 'logs') {
136
+ const resolved = await resolveSession(sessionsDir, opts.sessionArg, (l) => process.stderr.write(`${l}\n`))
137
+ if (!resolved.ok) {
138
+ const reason =
139
+ resolved.reason === 'ambiguous'
140
+ ? `Ambiguous session prefix "${opts.sessionArg}" matches ${resolved.matches.length} sessions. Use a longer prefix or run \`typeclaw inspect\` without args.`
141
+ : `No session matching "${opts.sessionArg}" in ${sessionsDir}/`
142
+ process.stderr.write(`${errorLine(reason)}\n`)
143
+ return resolved.reason === 'ambiguous' ? 2 : 1
144
+ }
145
+ preselectKey = resolved.summary.sessionId
146
+ } else if (opts.sessionArg === 'logs') {
147
+ preselectKey = 'logs'
148
+ }
149
+
150
+ const interactive = Boolean(process.stdin.isTTY)
151
+ const liveHint = interactive ? escHintLine(color) : undefined
152
+ const liveSource = containerRunning ? await buildLiveSource(cwd) : undefined
153
+
154
+ const stdout = (line: string): void => {
155
+ process.stdout.write(`${line}\n`)
156
+ }
157
+ const stderr = (line: string): void => {
158
+ process.stderr.write(`${line}\n`)
159
+ }
160
+
161
+ const open = openViewerItem({
162
+ cwd,
163
+ filter: filterResult.filter,
164
+ sinceMs,
165
+ json: false,
166
+ color,
167
+ interactive,
168
+ stdout,
169
+ stderr,
170
+ resolveTuiUrl: () => resolveTuiUrl(cwd),
171
+ ...(liveSource !== undefined ? { liveSource } : {}),
172
+ ...(liveHint !== undefined ? { liveHint } : {}),
173
+ })
174
+
175
+ const cliAllowWritable = opts.allowWritable !== false
176
+ const result = await runViewerLoop<ViewerItem>({
177
+ listItems: async ({ allowWritable: loopAllowWritable }) => {
178
+ const listOpts: Parameters<typeof listViewerItems>[0] = {
179
+ sessionsDir,
180
+ containerRunning,
181
+ // Compose the CLI-level permission (false on tui detach handoff) with
182
+ // the loop-level one (false after returning to the picker from a viewer).
183
+ allowWritable: cliAllowWritable && loopAllowWritable,
184
+ limit: 20,
185
+ onWarn: stderr,
186
+ }
187
+ if (sinceMs !== undefined) listOpts.sinceMs = sinceMs
188
+ return (await listViewerItems(listOpts)).items
189
+ },
190
+ keyOf: (item) => (item.kind === 'logs' ? 'logs' : item.summary.sessionId),
191
+ ...(preselectKey !== undefined ? { preselectKey } : {}),
192
+ selectItem: (items, selectOpts) => clackSelectItem(items, selectOpts.initialKey),
193
+ openItem: open,
194
+ createTailScope: () => createTailScope({ debounceMs: ESC_DEBOUNCE_MS }),
195
+ onEmpty: () => ({
196
+ ok: false,
197
+ exitCode: 1,
198
+ reason: `No sessions found in ${sessionsDir}/.\nStart a session with \`typeclaw tui\` or send a message from a configured channel.`,
199
+ }),
200
+ })
201
+
202
+ if (!result.ok && result.reason !== undefined) {
203
+ process.stderr.write(`${errorLine(result.reason)}\n`)
204
+ }
205
+ return result.exitCode
206
+ }
207
+
208
+ function finish(result: { ok: boolean; exitCode: number; reason?: string }): void {
209
+ if (!result.ok && result.reason !== undefined) {
210
+ process.stderr.write(`${errorLine(result.reason)}\n`)
80
211
  }
212
+ process.exit(result.exitCode)
213
+ }
214
+
215
+ async function resolveTuiUrl(cwd: string): Promise<string> {
216
+ const precheck = await requireContainerRunning({ cwd })
217
+ if (!precheck.ok) throw new Error(precheck.reason)
218
+ const port = await resolveHostPort({ cwd })
219
+ const token = await resolveTuiToken({ cwd })
220
+ const url = new URL(`ws://127.0.0.1:${port}`)
221
+ if (token !== null) url.searchParams.set('token', token)
222
+ return url.toString()
223
+ }
224
+
225
+ async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefined> {
81
226
  const port = await resolveHostPort({ cwd })
82
227
  const token = await resolveTuiToken({ cwd })
83
228
  const baseUrl = new URL(`ws://127.0.0.1:${port}/inspect`)
@@ -94,7 +239,7 @@ async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefin
94
239
  }
95
240
 
96
241
  function escHintLine(color: boolean): string {
97
- const text = '(esc to return to session list · q to quit)'
242
+ const text = '(esc to return to the list · q to quit)'
98
243
  return color ? `\u001b[2m${text}\u001b[0m` : text
99
244
  }
100
245
 
@@ -105,7 +250,29 @@ function useColor(): boolean {
105
250
  return Boolean(process.stdout.isTTY)
106
251
  }
107
252
 
108
- async function clackSelect(
253
+ async function clackSelectItem(items: ViewerItem[], initialKey: string | undefined): Promise<ViewerItem | null> {
254
+ const { select } = await import('@clack/prompts')
255
+ prepareStdinForClack()
256
+ const keyOf = (item: ViewerItem): string => (item.kind === 'logs' ? 'logs' : item.summary.sessionId)
257
+ const preferred =
258
+ initialKey !== undefined && items.some((i) => keyOf(i) === initialKey) ? initialKey : keyOf(items[0]!)
259
+ const picked = await select<string>({
260
+ message: `Pick what to view (showing ${items.length})`,
261
+ options: items.map((item) => ({
262
+ value: keyOf(item),
263
+ label: itemLabel(item),
264
+ ...itemHint(item),
265
+ })),
266
+ initialValue: preferred,
267
+ })
268
+ if (isCancel(picked)) {
269
+ cancel('Cancelled.')
270
+ return null
271
+ }
272
+ return items.find((i) => keyOf(i) === picked) ?? null
273
+ }
274
+
275
+ async function clackSelectSession(
109
276
  sessions: SessionSummary[],
110
277
  initialSessionId: string | undefined,
111
278
  ): Promise<SessionSummary | null> {
@@ -119,7 +286,7 @@ async function clackSelect(
119
286
  message: `Pick a session to inspect (showing ${sessions.length})`,
120
287
  options: sessions.map((s) => ({
121
288
  value: s.sessionId,
122
- label: formatRowLabel(s),
289
+ label: sessionRowLabel(s),
123
290
  ...(s.firstPrompt !== null ? { hint: truncate(s.firstPrompt, 60) } : { hint: '(no prompt)' }),
124
291
  })),
125
292
  initialValue: preferred,
@@ -131,7 +298,20 @@ async function clackSelect(
131
298
  return sessions.find((s) => s.sessionId === picked) ?? null
132
299
  }
133
300
 
134
- function formatRowLabel(s: SessionSummary): string {
301
+ function itemLabel(item: ViewerItem): string {
302
+ if (item.kind === 'logs') return `${c.dim('▤')} container logs`
303
+ if (item.kind === 'tui') return `${c.green('●')} ${c.bold('live TUI')} ${sessionRowLabel(item.summary)}`
304
+ return `${c.dim('○')} ${sessionRowLabel(item.summary)}`
305
+ }
306
+
307
+ function itemHint(item: ViewerItem): { hint: string } {
308
+ if (item.kind === 'logs') return { hint: 'read-only · works offline' }
309
+ if (item.kind === 'tui') return { hint: 'read+write · esc detaches and ends the live session' }
310
+ if (item.summary.firstPrompt !== null) return { hint: truncate(item.summary.firstPrompt, 60) }
311
+ return { hint: '(no prompt)' }
312
+ }
313
+
314
+ function sessionRowLabel(s: SessionSummary): string {
135
315
  const id = shortSessionId(s.sessionId)
136
316
  const label = s.origin === null ? '(unknown origin)' : originLabel(s.origin)
137
317
  const when = formatRelative(s.mtimeMs)
package/src/cli/logs.ts CHANGED
@@ -3,6 +3,7 @@ import { defineCommand } from 'citty'
3
3
  import { logs, parseTailValue } from '@/container'
4
4
  import { findAgentDir } from '@/init'
5
5
 
6
+ import { runInspectViewer } from './inspect'
6
7
  import { c, errorLine } from './ui'
7
8
 
8
9
  export const logsCommand = defineCommand({
@@ -22,6 +23,11 @@ export const logsCommand = defineCommand({
22
23
  alias: 'n',
23
24
  description: 'number of lines to show from the end of the logs (non-negative integer or "all")',
24
25
  },
26
+ list: {
27
+ type: 'boolean',
28
+ description: 'open the session viewer on the logs entry instead of dumping logs',
29
+ default: false,
30
+ },
25
31
  },
26
32
  async run({ args }) {
27
33
  const cwd = findAgentDir(process.cwd()) ?? process.cwd()
@@ -36,6 +42,15 @@ export const logsCommand = defineCommand({
36
42
  tail = parsed.value
37
43
  }
38
44
 
45
+ // The viewer is strictly opt-in via --list, so the default `typeclaw logs`
46
+ // (piped, redirected, -f, or a plain TTY dump) keeps the raw `docker logs`
47
+ // pump that `typeclaw logs | grep` and CI depend on. --list drops into the
48
+ // session viewer pre-opened on the logs entry, where esc returns to the list.
49
+ if (args.list) {
50
+ const exitCode = await runInspectViewer({ cwd, sessionArg: 'logs' })
51
+ process.exit(exitCode)
52
+ }
53
+
39
54
  if (args.follow) {
40
55
  console.log(c.cyan('Streaming container logs...'))
41
56
  } else {