typeclaw 0.23.0 → 0.25.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 (90) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/agent/index.ts +133 -27
  4. package/src/agent/llm-replay-sanitizer.ts +120 -0
  5. package/src/agent/loop-guard.ts +34 -0
  6. package/src/agent/multimodal/look-at.ts +1 -1
  7. package/src/agent/plugin-tools.ts +122 -8
  8. package/src/agent/restart/index.ts +15 -3
  9. package/src/agent/restart-handoff/index.ts +110 -12
  10. package/src/agent/session-origin.ts +30 -0
  11. package/src/agent/subagent-completion-reminder.ts +26 -1
  12. package/src/agent/subagents.ts +75 -3
  13. package/src/agent/system-prompt.ts +5 -1
  14. package/src/agent/todo/continuation-policy.ts +242 -0
  15. package/src/agent/todo/continuation-state.ts +87 -0
  16. package/src/agent/todo/continuation-wiring.ts +113 -0
  17. package/src/agent/todo/continuation.ts +71 -0
  18. package/src/agent/todo/scope.ts +77 -0
  19. package/src/agent/todo/store.ts +98 -0
  20. package/src/agent/tool-not-found-nudge.ts +126 -0
  21. package/src/agent/tools/channel-reply.ts +51 -0
  22. package/src/agent/tools/curl-impersonate.ts +2 -2
  23. package/src/agent/tools/restart.ts +11 -4
  24. package/src/agent/tools/spawn-subagent.ts +19 -2
  25. package/src/agent/tools/subagent-access.ts +40 -5
  26. package/src/agent/tools/subagent-cancel.ts +3 -1
  27. package/src/agent/tools/subagent-output.ts +6 -2
  28. package/src/agent/tools/todo/index.ts +119 -0
  29. package/src/agent/tools/webfetch/fetch.ts +18 -18
  30. package/src/agent/tools/webfetch/index.ts +1 -1
  31. package/src/agent/tools/webfetch/tool.ts +13 -13
  32. package/src/agent/tools/webfetch/types.ts +1 -1
  33. package/src/agent/tools/websearch.ts +6 -6
  34. package/src/bundled-plugins/backup/index.ts +40 -37
  35. package/src/bundled-plugins/backup/runner.ts +23 -2
  36. package/src/bundled-plugins/github-cli-auth/gh-command.ts +15 -7
  37. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +38 -1
  38. package/src/bundled-plugins/memory/README.md +11 -11
  39. package/src/bundled-plugins/memory/dreaming.ts +5 -0
  40. package/src/bundled-plugins/memory/search-tool.ts +98 -1
  41. package/src/bundled-plugins/operator/operator.ts +5 -1
  42. package/src/bundled-plugins/reviewer/reviewer.ts +32 -9
  43. package/src/bundled-plugins/reviewer/skills/code-review.ts +1 -1
  44. package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
  45. package/src/bundled-plugins/scout/scout.ts +7 -7
  46. package/src/bundled-plugins/security/policies/private-surface-read.ts +2 -2
  47. package/src/bundled-plugins/security/policies/ssrf.ts +3 -3
  48. package/src/bundled-plugins/tool-result-cap/README.md +1 -1
  49. package/src/channels/adapters/discord-bot-reference.ts +78 -0
  50. package/src/channels/adapters/discord-bot.ts +25 -3
  51. package/src/channels/adapters/github/inbound.ts +172 -10
  52. package/src/channels/adapters/github/index.ts +10 -0
  53. package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
  54. package/src/channels/adapters/github/webhook-register.ts +32 -27
  55. package/src/channels/adapters/kakaotalk-classify.ts +67 -6
  56. package/src/channels/adapters/slack-bot-classify.ts +9 -1
  57. package/src/channels/adapters/slack-bot-reference.ts +129 -0
  58. package/src/channels/adapters/slack-bot.ts +67 -8
  59. package/src/channels/manager.ts +8 -2
  60. package/src/channels/router.ts +506 -45
  61. package/src/channels/schema.ts +21 -4
  62. package/src/channels/subagent-completion-bridge.ts +18 -18
  63. package/src/channels/types.ts +69 -1
  64. package/src/cli/inspect-controller.ts +132 -33
  65. package/src/cli/inspect.ts +2 -1
  66. package/src/commands/index.ts +9 -0
  67. package/src/container/start.ts +7 -1
  68. package/src/git/mutex.ts +22 -0
  69. package/src/git/reconcile-ignored.ts +214 -0
  70. package/src/hostd/daemon.ts +26 -1
  71. package/src/hostd/portbroker-manager.ts +7 -0
  72. package/src/init/dockerfile.ts +1 -1
  73. package/src/init/gitignore.ts +28 -16
  74. package/src/inspect/index.ts +53 -4
  75. package/src/inspect/loop.ts +16 -12
  76. package/src/plugin/define.ts +2 -2
  77. package/src/plugin/index.ts +2 -2
  78. package/src/portbroker/hostd-client.ts +36 -13
  79. package/src/run/index.ts +74 -5
  80. package/src/sandbox/build.ts +20 -0
  81. package/src/sandbox/index.ts +10 -0
  82. package/src/sandbox/policy.ts +22 -0
  83. package/src/sandbox/session-tmp.ts +43 -0
  84. package/src/sandbox/writable-zones.ts +178 -0
  85. package/src/server/command-runner.ts +1 -1
  86. package/src/server/index.ts +126 -4
  87. package/src/skills/typeclaw-channel-github/SKILL.md +71 -17
  88. package/src/skills/typeclaw-memory/SKILL.md +3 -1
  89. package/src/tui/format.ts +11 -11
  90. package/typeclaw.schema.json +10 -0
@@ -1,4 +1,5 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { join } from 'node:path'
2
3
 
3
4
  import type { AgentTool } from '@mariozechner/pi-agent-core'
4
5
  import {
@@ -33,13 +34,22 @@ import type {
33
34
  ToolContext,
34
35
  ToolResult,
35
36
  } from '@/plugin'
36
- import { buildSandboxedCommand, ensureBwrapAvailable, resolveHiddenPaths } from '@/sandbox'
37
+ import {
38
+ buildSandboxedCommand,
39
+ ensureBwrapAvailable,
40
+ ensureSessionTmpDir,
41
+ mapVirtualTmpPath,
42
+ resolveHiddenPaths,
43
+ resolveProtectedZones,
44
+ resolveWritableZones,
45
+ subtractMasked,
46
+ } from '@/sandbox'
37
47
 
38
48
  import { createLoopGuard, type LoopGuard } from './loop-guard'
39
49
  import { checkImageReadRedirect } from './multimodal/read-redirect'
40
50
  import type { SessionOrigin } from './session-origin'
41
- import { webfetchTool } from './tools/webfetch'
42
- import { websearchTool } from './tools/websearch'
51
+ import { webFetchTool } from './tools/webfetch'
52
+ import { webSearchTool } from './tools/websearch'
43
53
 
44
54
  // Process-wide loop guard. State is keyed by sessionId so concurrent sessions
45
55
  // don't interfere; the guard's own LRU bound keeps it from growing without
@@ -106,7 +116,7 @@ const ACKNOWLEDGE_GUARDS_SCHEMA = Type.Optional(
106
116
  // name-filter path); the wrapped customTools just replace the implementation
107
117
  // underneath so subagent and channel sessions share the same hook coverage.
108
118
  type PiAgentToolName = 'read' | 'bash' | 'edit' | 'write' | 'grep' | 'find' | 'ls'
109
- type TypeclawToolName = 'websearch' | 'webfetch'
119
+ type TypeclawToolName = 'web_search' | 'web_fetch'
110
120
 
111
121
  const PI_AGENT_TOOL_MAP: Record<PiAgentToolName, AgentTool<any, any>> = {
112
122
  read: piReadTool,
@@ -119,8 +129,8 @@ const PI_AGENT_TOOL_MAP: Record<PiAgentToolName, AgentTool<any, any>> = {
119
129
  }
120
130
 
121
131
  const TYPECLAW_TOOL_DEFINITION_MAP: Record<TypeclawToolName, ToolDefinition<any, any, any>> = {
122
- websearch: websearchTool,
123
- webfetch: webfetchTool,
132
+ web_search: webSearchTool,
133
+ web_fetch: webFetchTool,
124
134
  }
125
135
 
126
136
  function isPiAgentToolName(name: string): name is PiAgentToolName {
@@ -163,6 +173,10 @@ export type WrapToolOptions = {
163
173
  // origin mutates per turn surface the current-turn `lastInboundAuthorId`
164
174
  // to `tool.before`. Sessions with a fixed origin can pass `() => origin`.
165
175
  getOrigin?: () => SessionOrigin | undefined
176
+ // Resolves the current turn's abort handle. Resolved lazily (not at wrap
177
+ // time) because tools are wrapped BEFORE `createAgentSession` returns the
178
+ // session whose `agent.abort` this points at. See `fireLoopAbort`.
179
+ getAbort?: () => (() => void) | undefined
166
180
  }
167
181
 
168
182
  export type WrapSystemToolOptions = {
@@ -170,6 +184,7 @@ export type WrapSystemToolOptions = {
170
184
  sessionId: string
171
185
  hooks: HookBus
172
186
  getOrigin?: () => SessionOrigin | undefined
187
+ getAbort?: () => (() => void) | undefined
173
188
  // When present, the bash builtin is rewritten through the per-tool bwrap
174
189
  // sandbox with role-derived path masks. Absent (or no masks for the role)
175
190
  // runs bash unchanged — preserving today's behavior for trusted+ and for
@@ -228,6 +243,7 @@ export function wrapPluginTool(tool: Tool<any>, opts: WrapToolOptions): ToolDefi
228
243
 
229
244
  const loopDecision = sharedLoopGuard.check(opts.sessionId, opts.toolName, before.args)
230
245
  if (loopDecision.kind === 'block') {
246
+ fireLoopAbort(opts.getAbort)
231
247
  return errorResult(loopDecision.message)
232
248
  }
233
249
 
@@ -287,6 +303,7 @@ export function wrapSystemTool<TParams extends TSchema, TDetails = unknown, TSta
287
303
  }
288
304
  const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
289
305
  if (loopDecision.kind === 'block') {
306
+ fireLoopAbort(opts.getAbort)
290
307
  throw new Error(loopDecision.message)
291
308
  }
292
309
  const guardResult = await runFinalWriteGuards({
@@ -349,6 +366,7 @@ export function wrapSystemAgentTool<TParams extends TSchema, TDetails = unknown>
349
366
  }
350
367
  const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
351
368
  if (loopDecision.kind === 'block') {
369
+ fireLoopAbort(opts.getAbort)
352
370
  throw new Error(loopDecision.message)
353
371
  }
354
372
  const guardResult = await runFinalWriteGuards({
@@ -426,6 +444,7 @@ export function wrapAgentToolAsCustomToolDefinition<TParams extends TSchema, TDe
426
444
  delete mutableArgs[TYPECLAW_INTERNAL_BASH_ENV]
427
445
  const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
428
446
  if (loopDecision.kind === 'block') {
447
+ fireLoopAbort(opts.getAbort)
429
448
  throw new Error(loopDecision.message)
430
449
  }
431
450
  const guardResult = await runFinalWriteGuards({
@@ -443,7 +462,11 @@ export function wrapAgentToolAsCustomToolDefinition<TParams extends TSchema, TDe
443
462
  stripGuardAcknowledgements(mutableArgs)
444
463
 
445
464
  if (tool.name === 'bash' && opts.permissions !== undefined) {
446
- await applyBashSandbox(mutableArgs, opts.permissions, liveOrigin, opts.agentDir, bashEnvOverlay)
465
+ await applyBashSandbox(mutableArgs, opts.permissions, liveOrigin, opts.agentDir, opts.sessionId, bashEnvOverlay)
466
+ }
467
+
468
+ if (TMP_REDIRECT_TOOLS.has(tool.name) && opts.permissions !== undefined) {
469
+ await applyTmpPathRedirect(mutableArgs, opts.permissions, liveOrigin, opts.agentDir, opts.sessionId)
447
470
  }
448
471
 
449
472
  const result = await bashEnvStore.run(bashEnvOverlay, () =>
@@ -490,6 +513,7 @@ async function applyBashSandbox(
490
513
  permissions: PermissionService,
491
514
  origin: SessionOrigin | undefined,
492
515
  agentDir: string,
516
+ sessionId: string,
493
517
  envOverlay: BashEnvOverlay | undefined,
494
518
  ): Promise<void> {
495
519
  const command = mutableArgs.command
@@ -499,12 +523,46 @@ async function applyBashSandbox(
499
523
  if (dirs.length === 0 && files.length === 0) return
500
524
 
501
525
  await ensureBwrapAvailable()
526
+ // Per-session /tmp: bind this session's scratch dir over the default
527
+ // --tmpfs /tmp so writes survive across the role's sandboxed bash calls AND
528
+ // match what the write/edit wrapper redirected a /tmp path to. The bind is
529
+ // emitted via policy.mounts (after the hardcoded --tmpfs /tmp), so last-op-
530
+ // wins makes it the live /tmp. Unsandboxed roles (empty masks, returned
531
+ // above) keep sharing the real container /tmp between write and bash.
532
+ const sessionTmp = await ensureSessionTmpDir(sessionId)
533
+ // Write-confined jail for low-trust roles: bind the whole project read-only,
534
+ // hide private/secret paths, then re-expose only the free-write scratch zones
535
+ // (workspace + root allowlist + .git) RW. The WORKING TREE outside those zones
536
+ // (node_modules/, agentDir root, non-allowlisted tracked files) stays EROFS, so
537
+ // bash cannot sidestep the non-workspace-write guard — and `git checkout` of a
538
+ // protected worktree path fails at the kernel. .git is RW so members can
539
+ // commit; .git/hooks + .git/config (and any writable core.hooksPath target)
540
+ // are re-protected RO (protected, rendered after writable, ensured to exist so
541
+ // an absent path can't be created+executed) so a hook-plant / core.hooksPath
542
+ // never becomes code execution in the unsandboxed runtime git ops. Trusted/owner never reach here
543
+ // (their masks are empty) and keep full unsandboxed access. subtractMasked
544
+ // drops any writable zone masked for this role so an RW bind never re-exposes a
545
+ // hidden path (e.g. a guest's masked workspace/).
546
+ const writable = subtractMasked(await resolveWritableZones(agentDir), { dirs, files })
547
+ // subtractMasked again on the protected set: a protected RO bind renders after
548
+ // the masks (last-op-wins), so an unfiltered protected path nested under a
549
+ // masked dir (e.g. a guest's workspace/ when core.hooksPath=workspace/hooks)
550
+ // would re-expose the hidden real dir. A masked path is already non-writable
551
+ // for this role, so it needs no protection anyway.
552
+ const protectedZones = writable.dirs.includes(join(agentDir, '.git'))
553
+ ? subtractMasked(await resolveProtectedZones(agentDir), { dirs, files })
554
+ : { dirs: [], files: [] }
502
555
  // bwrap does --clearenv, so the overlay must be re-introduced via env.set or
503
556
  // it would never reach the sandboxed process (the non-sandboxed spawnHook
504
557
  // path does not run when the command is rewritten to a bwrap invocation).
505
558
  const { commandString } = buildSandboxedCommand(command, {
506
- mounts: [{ type: 'bind', source: agentDir, dest: agentDir }],
559
+ mounts: [
560
+ { type: 'ro-bind', source: agentDir, dest: agentDir },
561
+ { type: 'bind', source: sessionTmp, dest: '/tmp' },
562
+ ],
507
563
  masks: { dirs, files },
564
+ writable,
565
+ protected: protectedZones,
508
566
  network: 'inherit',
509
567
  cwd: agentDir,
510
568
  ...(envOverlay !== undefined ? { env: { set: envOverlay } } : {}),
@@ -512,11 +570,55 @@ async function applyBashSandbox(
512
570
  mutableArgs.command = commandString
513
571
  }
514
572
 
573
+ // The builtin file tools that take a single filesystem `path` arg. For a
574
+ // sandboxed role they all run UNSANDBOXED in the main process (only bash is
575
+ // bwrap-wrapped), so each must apply the same /tmp -> session-dir mapping that
576
+ // applyBashSandbox binds for bash — otherwise a `read` of /tmp/foo hits the
577
+ // real container /tmp while sandboxed bash wrote the session backing dir.
578
+ const TMP_REDIRECT_TOOLS = new Set(['read', 'write', 'edit', 'grep', 'find', 'ls'])
579
+
580
+ // Sandboxed roles read /tmp through bwrap's per-session bind (applyBashSandbox),
581
+ // but the path-based file tools run unsandboxed against the real container /tmp.
582
+ // Without this redirect a guest/member that touches /tmp/foo through bash (bound
583
+ // to the session dir) and through a file tool (real /tmp) would see two
584
+ // different files. Rewriting the file tool's on-disk path to the same session
585
+ // backing dir makes every layer resolve /tmp/foo to one file. Unsandboxed roles
586
+ // (empty masks) are left untouched: their bash already shares the real /tmp.
587
+ async function applyTmpPathRedirect(
588
+ mutableArgs: Record<string, unknown>,
589
+ permissions: PermissionService,
590
+ origin: SessionOrigin | undefined,
591
+ agentDir: string,
592
+ sessionId: string,
593
+ ): Promise<void> {
594
+ const rawPath = mutableArgs.path
595
+ if (typeof rawPath !== 'string') return
596
+
597
+ const { dirs, files } = resolveHiddenPaths(permissions, origin, agentDir)
598
+ if (dirs.length === 0 && files.length === 0) return
599
+
600
+ const backing = mapVirtualTmpPath(agentDir, sessionId, rawPath)
601
+ if (backing === undefined) return
602
+
603
+ await ensureSessionTmpDir(sessionId)
604
+ mutableArgs.path = backing
605
+ }
606
+
515
607
  function appendLoopWarning(result: ToolResult, message: string): ToolResult {
516
608
  const content: ContentPart[] = [...(result.content as ContentPart[]), { type: 'text', text: message }]
517
609
  return { content, details: result.details }
518
610
  }
519
611
 
612
+ // Clears one tool's loop-guard residue for a session on the process-wide shared
613
+ // guard. The completion-reminder bridges (channel router + TUI server) call this
614
+ // for `subagent_output` when a backgrounded subagent finishes, so the next fetch
615
+ // the reminder asks for isn't blocked by the window the agent's premature polling
616
+ // poisoned. Exposed as a narrow function rather than the guard itself so callers
617
+ // can't reach `check`/`forget` and widen the blast radius.
618
+ export function forgetSharedLoopGuardTool(sessionId: string, tool: string): void {
619
+ sharedLoopGuard.forgetTool(sessionId, tool)
620
+ }
621
+
520
622
  // Test-only seam: swaps the shared loop guard for a fresh instance so tests
521
623
  // that reuse sessionIds across cases don't see cross-test streak counts.
522
624
  // Production code never calls this; the guard's LRU bound handles
@@ -525,6 +627,18 @@ export function __resetSharedLoopGuardForTests(): void {
525
627
  sharedLoopGuard = createLoopGuard()
526
628
  }
527
629
 
630
+ // A loop-guard `block` verdict returned/thrown from a tool's execute() is
631
+ // caught by pi-agent-core and surfaced to the model as an `isError` result,
632
+ // which the model simply retries — the loop never ends. Aborting the run's
633
+ // AbortSignal is the only thing that actually stops the in-flight turn (the
634
+ // next assistant stream sees the aborted signal and ends with stopReason
635
+ // 'aborted'). We use the signal-only `agent.abort`, never `session.abort`,
636
+ // which would deadlock awaiting the very run this tool call belongs to. See
637
+ // the matching pattern in src/channels/router.ts (policy-denied send cap).
638
+ function fireLoopAbort(getAbort: (() => (() => void) | undefined) | undefined): void {
639
+ getAbort?.()?.()
640
+ }
641
+
528
642
  function errorResult(message: string) {
529
643
  return {
530
644
  content: [{ type: 'text' as const, text: message }],
@@ -1,6 +1,6 @@
1
1
  import { basename } from 'node:path'
2
2
 
3
- import { writeRestartHandoff } from '@/agent/restart-handoff'
3
+ import { type RestartHandoffOrigin, writeRestartHandoff } from '@/agent/restart-handoff'
4
4
  import { send, sendHttp } from '@/hostd/client'
5
5
  import { containerSocketPath } from '@/hostd/paths'
6
6
  import type { Stream } from '@/stream'
@@ -30,6 +30,11 @@ export type RequestContainerRestartOptions = {
30
30
  agentDir?: string
31
31
  originatingSessionId?: string
32
32
  originatingSessionFile?: string
33
+ // Origin metadata persisted into the handoff so the next boot routes the
34
+ // resume to the right subsystem (tui → websocket open; channel → router
35
+ // startup). Required alongside agentDir + originatingSessionFile for the
36
+ // handoff to be written; omitting it skips the handoff entirely.
37
+ handoffOrigin?: RestartHandoffOrigin
33
38
  restartedAt?: string
34
39
  }
35
40
 
@@ -48,6 +53,7 @@ export async function requestContainerRestart({
48
53
  agentDir,
49
54
  originatingSessionId,
50
55
  originatingSessionFile,
56
+ handoffOrigin,
51
57
  restartedAt,
52
58
  }: RequestContainerRestartOptions): Promise<RequestContainerRestartResult> {
53
59
  const request = { kind: 'restart' as const, containerName, build: build === true }
@@ -84,13 +90,19 @@ export async function requestContainerRestart({
84
90
  // only; a missing one just cold-starts the rebooted container without the
85
91
  // "I'm back" greeting. writeRestartHandoff swallows its own errors today, but
86
92
  // guard here too so this contract survives the writer being changed later.
87
- if (agentDir !== undefined && originatingSessionId !== undefined && originatingSessionFile !== undefined) {
93
+ if (
94
+ agentDir !== undefined &&
95
+ originatingSessionId !== undefined &&
96
+ originatingSessionFile !== undefined &&
97
+ handoffOrigin !== undefined
98
+ ) {
88
99
  try {
89
100
  await writeRestartHandoff(agentDir, {
90
- schemaVersion: 1,
101
+ schemaVersion: 2,
91
102
  restartedAt: restartTimestamp,
92
103
  originatingSessionId,
93
104
  originatingSessionFile: basename(originatingSessionFile),
105
+ origin: handoffOrigin,
94
106
  })
95
107
  } catch {
96
108
  // intentional swallow — see the post-ACK rationale above
@@ -1,17 +1,40 @@
1
1
  import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
2
2
  import { dirname } from 'node:path'
3
3
 
4
+ import type { AdapterId } from '@/channels/schema'
5
+
4
6
  import { restartHandoffPath } from './paths'
5
7
 
6
8
  export { restartHandoffPath } from './paths'
7
9
 
8
10
  export const RESTART_HANDOFF_TTL_MS = 60_000
9
11
 
12
+ // The channel coordinates needed to reopen and wake the originating session
13
+ // on the channel side after a restart. Mirrors ChannelKey (src/channels/types)
14
+ // but is duplicated here so the handoff module does not depend on the channel
15
+ // subsystem's full type surface — only the four routing coordinates travel in
16
+ // the handoff file.
17
+ export type RestartHandoffChannelKey = {
18
+ adapter: AdapterId
19
+ workspace: string
20
+ chat: string
21
+ thread: string | null
22
+ }
23
+
24
+ // Discriminates which subsystem owns resuming the originating session on boot.
25
+ // A TUI handoff is claimed by the websocket `open` handler (it needs a
26
+ // reconnecting client); a channel handoff is claimed by channel startup (the
27
+ // router reopens the session and wakes it without any client). Splitting the
28
+ // claim by kind is what stops the first TUI reconnect from deleting a
29
+ // channel-origin handoff before channel boot can see it.
30
+ export type RestartHandoffOrigin = { kind: 'tui' } | { kind: 'channel'; key: RestartHandoffChannelKey }
31
+
10
32
  export type RestartHandoff = {
11
- schemaVersion: 1
33
+ schemaVersion: 2
12
34
  restartedAt: string
13
35
  originatingSessionId: string
14
36
  originatingSessionFile: string
37
+ origin: RestartHandoffOrigin
15
38
  }
16
39
 
17
40
  // Atomic write via `.tmp` + rename so a crash mid-write never leaves the
@@ -38,13 +61,21 @@ export async function writeRestartHandoff(agentDir: string, handoff: RestartHand
38
61
  // Otherwise a stale file would linger until the NEXT restart wrote a fresh
39
62
  // one, and the boot consumer would re-read the stale entry every time.
40
63
  //
64
+ // `accept` lets a caller claim only the handoffs it owns: the TUI path passes
65
+ // a tui-only predicate, channel boot passes a channel-only predicate. When the
66
+ // predicate REJECTS an otherwise-valid handoff, the file is restored so the
67
+ // rightful owner can still claim it (best-effort; a restore failure degrades
68
+ // to the same cold-start as a missing handoff). When `accept` is omitted, any
69
+ // valid handoff is consumed (preserves the original single-consumer behavior
70
+ // for callers that do not need kind-aware claiming).
71
+ //
41
72
  // Returns the parsed handoff iff the file existed, was valid JSON of the
42
- // expected shape, and was within `ttlMs` of `now`. Otherwise returns null.
43
- // `now` and `ttlMs` are injectable so tests can drive the recency gate
44
- // without sleeping.
73
+ // expected shape, was within `ttlMs` of `now`, and (if `accept` is given)
74
+ // passed the predicate. Otherwise returns null. `now` and `ttlMs` are
75
+ // injectable so tests can drive the recency gate without sleeping.
45
76
  export async function consumeRestartHandoff(
46
77
  agentDir: string,
47
- options: { now?: number; ttlMs?: number } = {},
78
+ options: { now?: number; ttlMs?: number; accept?: (handoff: RestartHandoff) => boolean } = {},
48
79
  ): Promise<RestartHandoff | null> {
49
80
  const path = restartHandoffPath(agentDir)
50
81
  const now = options.now ?? Date.now()
@@ -57,14 +88,35 @@ export async function consumeRestartHandoff(
57
88
  return null
58
89
  }
59
90
 
60
- await rm(path, { force: true }).catch(() => undefined)
61
-
62
91
  const handoff = parseHandoff(raw)
63
- if (handoff === null) return null
92
+
93
+ // Peek before delete: a handoff we will NOT claim (malformed, expired, or
94
+ // rejected by `accept`) is left untouched on disk so the rightful consumer
95
+ // can still find it. The previous delete-then-restore opened a window where
96
+ // a concurrent rightful consumer saw no file; never deleting an unclaimed
97
+ // handoff closes that window entirely.
98
+ if (handoff === null) {
99
+ await rm(path, { force: true }).catch(() => undefined)
100
+ return null
101
+ }
64
102
 
65
103
  const restartedAtMs = Date.parse(handoff.restartedAt)
66
- if (Number.isNaN(restartedAtMs)) return null
67
- if (now - restartedAtMs > ttlMs) return null
104
+ if (Number.isNaN(restartedAtMs) || now - restartedAtMs > ttlMs) {
105
+ await rm(path, { force: true }).catch(() => undefined)
106
+ return null
107
+ }
108
+
109
+ if (options.accept !== undefined && !options.accept(handoff)) return null
110
+
111
+ // Claim by deleting. A non-forced unlink distinguishes "we removed it" from
112
+ // "it was already gone": if another consumer of the same kind claimed it
113
+ // first, the unlink throws ENOENT and we return null, so the handoff is
114
+ // honored exactly once even under concurrent same-kind consumers.
115
+ try {
116
+ await rm(path)
117
+ } catch {
118
+ return null
119
+ }
68
120
 
69
121
  return handoff
70
122
  }
@@ -78,14 +130,60 @@ function parseHandoff(raw: string): RestartHandoff | null {
78
130
  }
79
131
  if (parsed === null || typeof parsed !== 'object') return null
80
132
  const obj = parsed as Record<string, unknown>
81
- if (obj.schemaVersion !== 1) return null
133
+
82
134
  if (typeof obj.restartedAt !== 'string') return null
83
135
  if (typeof obj.originatingSessionId !== 'string' || obj.originatingSessionId === '') return null
84
136
  if (typeof obj.originatingSessionFile !== 'string' || obj.originatingSessionFile === '') return null
137
+
138
+ // v1 handoffs predate the origin discriminator and were only ever written by
139
+ // TUI sessions (channel/cron origins wrote no handoff). Read them forward as
140
+ // a tui origin so an in-flight restart that straddles an upgrade still
141
+ // produces the "I'm back" turn.
142
+ if (obj.schemaVersion === 1) {
143
+ return {
144
+ schemaVersion: 2,
145
+ restartedAt: obj.restartedAt,
146
+ originatingSessionId: obj.originatingSessionId,
147
+ originatingSessionFile: obj.originatingSessionFile,
148
+ origin: { kind: 'tui' },
149
+ }
150
+ }
151
+
152
+ if (obj.schemaVersion !== 2) return null
153
+ const origin = parseOrigin(obj.origin)
154
+ if (origin === null) return null
85
155
  return {
86
- schemaVersion: 1,
156
+ schemaVersion: 2,
87
157
  restartedAt: obj.restartedAt,
88
158
  originatingSessionId: obj.originatingSessionId,
89
159
  originatingSessionFile: obj.originatingSessionFile,
160
+ origin,
161
+ }
162
+ }
163
+
164
+ function parseOrigin(raw: unknown): RestartHandoffOrigin | null {
165
+ if (raw === null || typeof raw !== 'object') return null
166
+ const obj = raw as Record<string, unknown>
167
+ if (obj.kind === 'tui') return { kind: 'tui' }
168
+ if (obj.kind === 'channel') {
169
+ const key = parseChannelKey(obj.key)
170
+ if (key === null) return null
171
+ return { kind: 'channel', key }
172
+ }
173
+ return null
174
+ }
175
+
176
+ function parseChannelKey(raw: unknown): RestartHandoffChannelKey | null {
177
+ if (raw === null || typeof raw !== 'object') return null
178
+ const obj = raw as Record<string, unknown>
179
+ if (typeof obj.adapter !== 'string' || obj.adapter === '') return null
180
+ if (typeof obj.workspace !== 'string') return null
181
+ if (typeof obj.chat !== 'string') return null
182
+ if (obj.thread !== null && typeof obj.thread !== 'string') return null
183
+ return {
184
+ adapter: obj.adapter as AdapterId,
185
+ workspace: obj.workspace,
186
+ chat: obj.chat,
187
+ thread: obj.thread,
90
188
  }
91
189
  }
@@ -69,6 +69,36 @@ export type SessionOrigin =
69
69
  triggeredBy?: SessionOrigin
70
70
  }
71
71
 
72
+ // Hard ceiling on the subagent delegation chain. Bounds chain LENGTH, not
73
+ // fan-out breadth: the deepest reachable chain is main (depth 0) →
74
+ // operator/reviewer (depth 1) → nested worker (depth 2). `spawn_subagent`
75
+ // refuses to spawn from a session already at this depth.
76
+ export const MAX_SUBAGENT_DEPTH = 2
77
+
78
+ // Counts subagent links from the root by walking the `spawnedByOrigin`
79
+ // ancestry. A non-subagent (or undefined) origin is depth 0; each nested
80
+ // subagent origin adds one. Fails CLOSED on ambiguous ancestry: if a subagent
81
+ // origin has no `spawnedByOrigin` (the serialized path in
82
+ // parseSpawnedByOriginJson drops it), the true depth is unknowable, so we
83
+ // return MAX_SUBAGENT_DEPTH rather than assume it sits at the root — a
84
+ // truncated grandchild must not read as a child and earn an extra spawn. A
85
+ // cyclic chain is bounded by the same cap.
86
+ export function subagentDepth(origin: SessionOrigin | undefined): number {
87
+ let depth = 0
88
+ let current: SessionOrigin | undefined = origin
89
+ while (current !== undefined && current.kind === 'subagent') {
90
+ depth += 1
91
+ if (current.spawnedByOrigin === undefined) {
92
+ return MAX_SUBAGENT_DEPTH
93
+ }
94
+ if (depth >= MAX_SUBAGENT_DEPTH) {
95
+ return depth
96
+ }
97
+ current = current.spawnedByOrigin
98
+ }
99
+ return depth
100
+ }
101
+
72
102
  export const PARTICIPANTS_TOP_K = 10
73
103
  export const PARTICIPANTS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
74
104
 
@@ -43,7 +43,9 @@ export function renderSubagentCompletionReminder(args: CompletionReminderArgs):
43
43
  return (
44
44
  `<system-reminder>\n` +
45
45
  `Subagent \`${args.subagent}\` (${args.taskId}) FAILED after ${durationStr}: ${err}. ` +
46
- `Use subagent_output to inspect.${channelTail}\n` +
46
+ `Use subagent_output to inspect. If this work was tracked in your todo list, ` +
47
+ `keep the item pending (or add a recovery item) via todo_write so it is not ` +
48
+ `dropped.${channelTail}\n` +
47
49
  `</system-reminder>`
48
50
  )
49
51
  }
@@ -57,6 +59,13 @@ export function formatReminderDuration(ms: number): string {
57
59
  return `${min}m${sec}s`
58
60
  }
59
61
 
62
+ export type SubagentCompletedChannelKey = {
63
+ adapter: string
64
+ workspace: string
65
+ chat: string
66
+ thread: string | null
67
+ }
68
+
60
69
  export type SubagentCompletedPayload = {
61
70
  taskId: string
62
71
  subagent: string
@@ -64,6 +73,11 @@ export type SubagentCompletedPayload = {
64
73
  ok: boolean
65
74
  durationMs: number
66
75
  error?: string
76
+ // Present when the parent was a channel session. Lets the router fall back
77
+ // to the live successor session for the same channel key when the parent
78
+ // rolled over (SESSION_FRESHNESS_TTL_MS) or was idle-evicted while the
79
+ // subagent ran — otherwise the completion is silently dropped.
80
+ channelKey?: SubagentCompletedChannelKey
67
81
  }
68
82
 
69
83
  // Type guard for the `subagent.completed` broadcast payload. Subscribers
@@ -80,9 +94,11 @@ export function parseSubagentCompletedPayload(payload: unknown): SubagentComplet
80
94
  ok?: unknown
81
95
  durationMs?: unknown
82
96
  error?: unknown
97
+ channelKey?: unknown
83
98
  }
84
99
  if (p.kind !== 'subagent.completed') return null
85
100
  if (typeof p.parentSessionId !== 'string') return null
101
+ const channelKey = parseChannelKey(p.channelKey)
86
102
  return {
87
103
  taskId: typeof p.taskId === 'string' ? p.taskId : '<unknown>',
88
104
  subagent: typeof p.subagent === 'string' ? p.subagent : 'subagent',
@@ -90,5 +106,14 @@ export function parseSubagentCompletedPayload(payload: unknown): SubagentComplet
90
106
  ok: p.ok === true,
91
107
  durationMs: typeof p.durationMs === 'number' ? p.durationMs : 0,
92
108
  ...(typeof p.error === 'string' ? { error: p.error } : {}),
109
+ ...(channelKey !== null ? { channelKey } : {}),
93
110
  }
94
111
  }
112
+
113
+ function parseChannelKey(value: unknown): SubagentCompletedChannelKey | null {
114
+ if (value === null || typeof value !== 'object') return null
115
+ const k = value as { adapter?: unknown; workspace?: unknown; chat?: unknown; thread?: unknown }
116
+ if (typeof k.adapter !== 'string' || typeof k.workspace !== 'string' || typeof k.chat !== 'string') return null
117
+ if (k.thread !== null && typeof k.thread !== 'string') return null
118
+ return { adapter: k.adapter, workspace: k.workspace, chat: k.chat, thread: k.thread }
119
+ }