typeclaw 0.23.0 → 0.24.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 (46) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +91 -22
  3. package/src/agent/plugin-tools.ts +38 -2
  4. package/src/agent/restart/index.ts +15 -3
  5. package/src/agent/restart-handoff/index.ts +110 -12
  6. package/src/agent/subagent-completion-reminder.ts +3 -1
  7. package/src/agent/subagents.ts +44 -1
  8. package/src/agent/system-prompt.ts +4 -0
  9. package/src/agent/todo/continuation-policy.ts +242 -0
  10. package/src/agent/todo/continuation-state.ts +87 -0
  11. package/src/agent/todo/continuation-wiring.ts +113 -0
  12. package/src/agent/todo/continuation.ts +71 -0
  13. package/src/agent/todo/scope.ts +77 -0
  14. package/src/agent/todo/store.ts +98 -0
  15. package/src/agent/tool-not-found-nudge.ts +119 -0
  16. package/src/agent/tools/channel-reply.ts +51 -0
  17. package/src/agent/tools/restart.ts +11 -4
  18. package/src/agent/tools/todo/index.ts +119 -0
  19. package/src/bundled-plugins/backup/runner.ts +1 -1
  20. package/src/bundled-plugins/reviewer/reviewer.ts +14 -0
  21. package/src/channels/adapters/discord-bot-reference.ts +78 -0
  22. package/src/channels/adapters/discord-bot.ts +25 -3
  23. package/src/channels/adapters/github/inbound.ts +161 -10
  24. package/src/channels/adapters/github/index.ts +10 -0
  25. package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
  26. package/src/channels/adapters/kakaotalk-classify.ts +67 -6
  27. package/src/channels/adapters/slack-bot-classify.ts +9 -1
  28. package/src/channels/adapters/slack-bot-reference.ts +129 -0
  29. package/src/channels/adapters/slack-bot.ts +67 -8
  30. package/src/channels/manager.ts +8 -2
  31. package/src/channels/router.ts +445 -22
  32. package/src/channels/schema.ts +20 -4
  33. package/src/channels/types.ts +68 -0
  34. package/src/cli/inspect-controller.ts +7 -0
  35. package/src/cli/inspect.ts +2 -1
  36. package/src/commands/index.ts +9 -0
  37. package/src/init/gitignore.ts +5 -2
  38. package/src/inspect/index.ts +22 -0
  39. package/src/run/index.ts +60 -5
  40. package/src/sandbox/build.ts +10 -0
  41. package/src/sandbox/index.ts +2 -0
  42. package/src/sandbox/policy.ts +10 -0
  43. package/src/sandbox/writable-zones.ts +78 -0
  44. package/src/server/index.ts +118 -4
  45. package/src/skills/typeclaw-channel-github/SKILL.md +34 -7
  46. package/typeclaw.schema.json +10 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'
4
4
 
5
5
  import {
6
6
  createAgentSession,
7
+ createCodingTools,
7
8
  DefaultResourceLoader,
8
9
  defineTool as definePiTool,
9
10
  SessionManager,
@@ -45,11 +46,13 @@ import {
45
46
  zodToToolParameters,
46
47
  } from './plugin-tools'
47
48
  import { createReloadTool } from './reload-tool'
49
+ import type { RestartHandoffOrigin } from './restart-handoff'
48
50
  import { loadSelf } from './self'
49
51
  import { SESSION_META_CUSTOM_TYPE, sessionMetaPayload } from './session-meta'
50
52
  import { renderSessionOrigin, type SessionOrigin, type SessionRoleContext } from './session-origin'
51
53
  import type { CreateSessionForSubagent, SubagentRegistry } from './subagents'
52
54
  import { DEFAULT_SYSTEM_PROMPT, renderRuntimeBlock, SLIM_SYSTEM_PROMPT } from './system-prompt'
55
+ import { attachToolNotFoundNudge } from './tool-not-found-nudge'
53
56
  import {
54
57
  createBudgetState,
55
58
  type ToolResultBudget,
@@ -68,6 +71,7 @@ import { createSpawnSubagentTool } from './tools/spawn-subagent'
68
71
  import { createStreamSnapshotTool } from './tools/stream-snapshot'
69
72
  import { createSubagentCancelTool } from './tools/subagent-cancel'
70
73
  import { createSubagentOutputTool } from './tools/subagent-output'
74
+ import { createTodoTools } from './tools/todo'
71
75
  import { webfetchTool } from './tools/webfetch'
72
76
  import { websearchTool } from './tools/websearch'
73
77
 
@@ -79,6 +83,13 @@ export { renderTurnRoleAnchor, renderTurnTimeAnchor } from './system-prompt'
79
83
 
80
84
  type AgentSessionTools = NonNullable<Parameters<typeof createAgentSession>[0]>['tools']
81
85
 
86
+ // pi's default active built-in tools when a session declares no `tools:` filter
87
+ // (pi `createAgentSession` falls back to `defaultActiveToolNames`, which is the
88
+ // name set of `codingTools`). Derived from pi's own `createCodingTools()` rather
89
+ // than hardcoded so the list can't silently drift if pi adds/removes/renames a
90
+ // default builtin; `default-pi-builtins match pi's coding tool set` pins it.
91
+ const DEFAULT_PI_BUILTIN_TOOL_NAMES = createCodingTools(process.cwd()).map((t) => t.name)
92
+
82
93
  export type PluginSessionWiring = {
83
94
  registry: PluginRegistry
84
95
  hooks: HookBus
@@ -248,6 +259,13 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
248
259
  const getOrigin: () => SessionOrigin | undefined =
249
260
  options.originRef !== undefined ? () => options.originRef!.current : () => options.origin
250
261
 
262
+ // Holds the session's signal-only abort once `createAgentSession` resolves.
263
+ // Tools are wrapped BEFORE the session exists, so the loop guard reaches the
264
+ // abort through this lazily-resolved getter. See `fireLoopAbort` in
265
+ // plugin-tools.ts for why aborting (not throwing) is what stops the loop.
266
+ const abortHolder: { abort?: () => void } = {}
267
+ const getAbort: () => (() => void) | undefined = () => abortHolder.abort
268
+
251
269
  // Subagent built-in tool refs are dual-routed (see BUILTIN_TOOL_DEFINITION
252
270
  // dual-map in plugin-tools.ts): pi-side coding tools go to `tools:` so they
253
271
  // become the strict base set, typeclaw-side web tools go to `customTools:`.
@@ -259,8 +277,8 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
259
277
  ? resolveBuiltinToolRefs(options.pluginSubagent.toolRefs)
260
278
  : { agentTools: [], toolDefinitions: [] }
261
279
  const pluginCustomTools = options.pluginSubagent
262
- ? wrapSubagentCustomTools(options.pluginSubagent, options.plugins, getOrigin)
263
- : wrapRegistryTools(options.plugins, getOrigin)
280
+ ? wrapSubagentCustomTools(options.pluginSubagent, options.plugins, getOrigin, getAbort)
281
+ : wrapRegistryTools(options.plugins, getOrigin, getAbort)
264
282
 
265
283
  // Per-run budget state for the tool-result byte ceiling. Allocated once per
266
284
  // session creation and threaded into every wrapped tool so they share the
@@ -276,7 +294,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
276
294
 
277
295
  const effectiveTools =
278
296
  options.tools ?? (options.pluginSubagent ? (resolvedSubagentBuiltins.agentTools as AgentSessionTools) : undefined)
279
- const hookWrappedTools = wrapSystemAgentTools(effectiveTools, options.plugins, getOrigin)
297
+ const hookWrappedTools = wrapSystemAgentTools(effectiveTools, options.plugins, getOrigin, getAbort)
280
298
  const tools =
281
299
  sessionBudget && sessionBudgetState && hookWrappedTools
282
300
  ? (hookWrappedTools.map((t) =>
@@ -348,6 +366,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
348
366
  permissions: options.permissions,
349
367
  reloadRoles: options.reloadRoles,
350
368
  }),
369
+ ...buildTodoTools(options.plugins?.agentDir, getOrigin),
351
370
  ]
352
371
  // Hook coverage for pi's builtin coding tools (read/bash/edit/write/grep/
353
372
  // find/ls) — pi 0.67.3 ignores `tools:` for implementation, so the only
@@ -361,10 +380,11 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
361
380
  sessionId: options.plugins.sessionId,
362
381
  hooks: options.plugins.hooks,
363
382
  getOrigin,
383
+ getAbort,
364
384
  ...(options.permissions ? { permissions: options.permissions } : {}),
365
385
  })
366
386
  : []
367
- const wrappedCustomSystemTools = wrapSystemTools(customSystemTools, options.plugins, getOrigin)
387
+ const wrappedCustomSystemTools = wrapSystemTools(customSystemTools, options.plugins, getOrigin, getAbort)
368
388
  const customToolsPreBudget = [...wrappedCustomSystemTools, ...pluginCustomTools, ...builtinPiToolOverrides]
369
389
  const customTools =
370
390
  sessionBudget && sessionBudgetState
@@ -385,25 +405,41 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
385
405
  ...(thinkingLevel ? { thinkingLevel } : {}),
386
406
  })
387
407
 
408
+ abortHolder.abort = () => {
409
+ if (session.agent.signal?.aborted !== true) session.agent.abort()
410
+ }
411
+
412
+ // The names the session actually exposes to the model: pi's active base set
413
+ // (the caller's `tools:` filter, or pi's default builtins when unset) union
414
+ // the typeclaw/plugin custom tools. Deliberately EXCLUDES
415
+ // `builtinPiToolOverrides` — those replace builtin implementations by name,
416
+ // they are not additional callable names. This is the single source of truth
417
+ // for both the active-set re-narrowing below and the tool-not-found nudge
418
+ // vocabulary, so the two never drift (a divergence would make the nudge miss
419
+ // real tools or suggest tools the session deliberately did not expose).
420
+ const intendedActiveToolNames = [
421
+ ...new Set([
422
+ ...(tools !== undefined ? tools.map((t) => t.name) : DEFAULT_PI_BUILTIN_TOOL_NAMES),
423
+ ...[...wrappedCustomSystemTools, ...pluginCustomTools].map((t) => t.name),
424
+ ]),
425
+ ]
426
+
388
427
  // Re-narrow the active tool set after `createAgentSession`. pi 0.67.3's
389
428
  // `_refreshToolRegistry` runs with `includeAllExtensionTools: true` and
390
429
  // pushes every customTool name into the active set, which would widen
391
430
  // a subagent's declared `[edit]` to all 7 builtin overrides plus every
392
- // typeclaw custom tool. The intended active set is the names the caller
393
- // would have gotten WITHOUT the builtin overrides: pi's `initialActiveToolNames`
394
- // (derived from `tools:`) union the names from typeclaw/plugin customTools.
395
- // `builtinPiToolOverrides` are implementation overrides, never additions.
431
+ // typeclaw custom tool.
396
432
  if (builtinPiToolOverrides.length > 0) {
397
- const baseActiveNames = tools !== undefined ? tools.map((t) => t.name) : ['read', 'bash', 'edit', 'write']
398
- const customToolActiveNames = [...wrappedCustomSystemTools, ...pluginCustomTools].map((t) => t.name)
399
- const intendedActive = [...new Set([...baseActiveNames, ...customToolActiveNames])]
400
- session.setActiveToolsByName(intendedActive)
433
+ session.setActiveToolsByName(intendedActiveToolNames)
401
434
  }
402
435
 
403
436
  const unsubRestart = subscribeRestartNotice(options.stream, sessionManager)
404
437
 
438
+ const unsubToolNudge = attachToolNotFoundNudge(session, intendedActiveToolNames)
439
+
405
440
  const dispose = async () => {
406
441
  unsubRestart?.()
442
+ unsubToolNudge()
407
443
  if (materializedSkills) await materializedSkills.dispose()
408
444
  }
409
445
  return { session, dispose }
@@ -411,22 +447,39 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
411
447
 
412
448
  // Decides whether the restart tool should write the cross-restart handoff
413
449
  // file (`<agentDir>/.typeclaw/restart-pending.json`) and supplies the agentDir
414
- // + session file path it needs to do so. Returns an empty object — meaning
415
- // "no handoff" — for any session whose origin is not TUI, so a channel-
416
- // originated or cron-originated `restart` call cannot accidentally produce an
417
- // "I'm back" greeting in the next container's first TUI session. See
418
- // issue #291's scoping concerns. Also returns empty when the session is not
419
- // persisted to disk (in-memory sessions have no file the next container could
420
- // reopen).
450
+ // + session file path + origin metadata it needs to do so. Returns an empty
451
+ // object — meaning "no handoff" — for cron/subagent/system origins (no
452
+ // attended session the next boot could resume) and for in-memory sessions
453
+ // (no file to reopen).
454
+ //
455
+ // TUI and channel origins both resume: a TUI restart reattaches to the
456
+ // reconnecting client (websocket open handler), a channel restart reopens the
457
+ // originating chat session on the channel router's boot path. The `origin`
458
+ // discriminator in the handoff is what routes the next boot to the correct
459
+ // subsystem.
421
460
  export function buildRestartHandoffWiring(
422
461
  options: { origin?: SessionOrigin; plugins?: { agentDir: string } },
423
462
  sessionManager: SessionManager,
424
- ): { agentDir?: string; originatingSessionFile?: string } {
425
- if (options.origin?.kind !== 'tui') return {}
463
+ ): { agentDir?: string; originatingSessionFile?: string; handoffOrigin?: RestartHandoffOrigin } {
464
+ const origin = options.origin
465
+ if (origin === undefined) return {}
466
+ const handoffOrigin = restartHandoffOriginFor(origin)
467
+ if (handoffOrigin === null) return {}
426
468
  const agentDir = options.plugins?.agentDir
427
469
  const sessionFile = sessionManager.getSessionFile()
428
470
  if (agentDir === undefined || sessionFile === undefined) return {}
429
- return { agentDir, originatingSessionFile: sessionFile }
471
+ return { agentDir, originatingSessionFile: sessionFile, handoffOrigin }
472
+ }
473
+
474
+ function restartHandoffOriginFor(origin: SessionOrigin): RestartHandoffOrigin | null {
475
+ if (origin.kind === 'tui') return { kind: 'tui' }
476
+ if (origin.kind === 'channel') {
477
+ return {
478
+ kind: 'channel',
479
+ key: { adapter: origin.adapter, workspace: origin.workspace, chat: origin.chat, thread: origin.thread },
480
+ }
481
+ }
482
+ return null
430
483
  }
431
484
 
432
485
  // Subscribes the given session to the in-process broadcast that the `restart`
@@ -662,9 +715,18 @@ export function buildRoleGrantTools(opts: {
662
715
  ]
663
716
  }
664
717
 
718
+ export function buildTodoTools(
719
+ agentDir: string | undefined,
720
+ getOrigin: () => SessionOrigin | undefined,
721
+ ): ToolDefinition[] {
722
+ if (agentDir === undefined) return []
723
+ return createTodoTools({ agentDir, getOrigin })
724
+ }
725
+
665
726
  function wrapRegistryTools(
666
727
  plugins: PluginSessionWiring | undefined,
667
728
  getOrigin: () => SessionOrigin | undefined,
729
+ getAbort: () => (() => void) | undefined,
668
730
  ): ToolDefinition[] {
669
731
  if (!plugins) return []
670
732
  return plugins.registry.tools.map((t: PluginRegisteredTool) =>
@@ -676,6 +738,7 @@ function wrapRegistryTools(
676
738
  logger: t.logger,
677
739
  hooks: plugins.hooks,
678
740
  getOrigin,
741
+ getAbort,
679
742
  }),
680
743
  )
681
744
  }
@@ -684,6 +747,7 @@ function wrapSystemAgentTools(
684
747
  tools: AgentSessionTools | undefined,
685
748
  plugins: PluginSessionWiring | undefined,
686
749
  getOrigin: () => SessionOrigin | undefined,
750
+ getAbort: () => (() => void) | undefined,
687
751
  ): AgentSessionTools | undefined {
688
752
  if (!tools || !hasToolHooks(plugins)) return tools
689
753
  return tools.map((tool) =>
@@ -692,6 +756,7 @@ function wrapSystemAgentTools(
692
756
  sessionId: plugins.sessionId,
693
757
  hooks: plugins.hooks,
694
758
  getOrigin,
759
+ getAbort,
695
760
  }),
696
761
  )
697
762
  }
@@ -700,6 +765,7 @@ function wrapSystemTools(
700
765
  tools: ToolDefinition[],
701
766
  plugins: PluginSessionWiring | undefined,
702
767
  getOrigin: () => SessionOrigin | undefined,
768
+ getAbort: () => (() => void) | undefined,
703
769
  ): ToolDefinition[] {
704
770
  if (!hasToolHooks(plugins)) return tools
705
771
  return tools.map((tool) =>
@@ -708,6 +774,7 @@ function wrapSystemTools(
708
774
  sessionId: plugins.sessionId,
709
775
  hooks: plugins.hooks,
710
776
  getOrigin,
777
+ getAbort,
711
778
  }),
712
779
  )
713
780
  }
@@ -721,6 +788,7 @@ function wrapSubagentCustomTools(
721
788
  selection: PluginSubagentSelection,
722
789
  plugins: PluginSessionWiring | undefined,
723
790
  getOrigin: () => SessionOrigin | undefined,
791
+ getAbort: () => (() => void) | undefined,
724
792
  ): ToolDefinition[] {
725
793
  if (!selection.customTools || !plugins) return []
726
794
  const logger = makePluginLogger(selection.pluginName)
@@ -733,6 +801,7 @@ function wrapSubagentCustomTools(
733
801
  logger,
734
802
  hooks: plugins.hooks,
735
803
  getOrigin,
804
+ getAbort,
736
805
  }),
737
806
  )
738
807
  }
@@ -33,7 +33,13 @@ import type {
33
33
  ToolContext,
34
34
  ToolResult,
35
35
  } from '@/plugin'
36
- import { buildSandboxedCommand, ensureBwrapAvailable, resolveHiddenPaths } from '@/sandbox'
36
+ import {
37
+ buildSandboxedCommand,
38
+ ensureBwrapAvailable,
39
+ resolveHiddenPaths,
40
+ resolveWritableZones,
41
+ subtractMasked,
42
+ } from '@/sandbox'
37
43
 
38
44
  import { createLoopGuard, type LoopGuard } from './loop-guard'
39
45
  import { checkImageReadRedirect } from './multimodal/read-redirect'
@@ -163,6 +169,10 @@ export type WrapToolOptions = {
163
169
  // origin mutates per turn surface the current-turn `lastInboundAuthorId`
164
170
  // to `tool.before`. Sessions with a fixed origin can pass `() => origin`.
165
171
  getOrigin?: () => SessionOrigin | undefined
172
+ // Resolves the current turn's abort handle. Resolved lazily (not at wrap
173
+ // time) because tools are wrapped BEFORE `createAgentSession` returns the
174
+ // session whose `agent.abort` this points at. See `fireLoopAbort`.
175
+ getAbort?: () => (() => void) | undefined
166
176
  }
167
177
 
168
178
  export type WrapSystemToolOptions = {
@@ -170,6 +180,7 @@ export type WrapSystemToolOptions = {
170
180
  sessionId: string
171
181
  hooks: HookBus
172
182
  getOrigin?: () => SessionOrigin | undefined
183
+ getAbort?: () => (() => void) | undefined
173
184
  // When present, the bash builtin is rewritten through the per-tool bwrap
174
185
  // sandbox with role-derived path masks. Absent (or no masks for the role)
175
186
  // runs bash unchanged — preserving today's behavior for trusted+ and for
@@ -228,6 +239,7 @@ export function wrapPluginTool(tool: Tool<any>, opts: WrapToolOptions): ToolDefi
228
239
 
229
240
  const loopDecision = sharedLoopGuard.check(opts.sessionId, opts.toolName, before.args)
230
241
  if (loopDecision.kind === 'block') {
242
+ fireLoopAbort(opts.getAbort)
231
243
  return errorResult(loopDecision.message)
232
244
  }
233
245
 
@@ -287,6 +299,7 @@ export function wrapSystemTool<TParams extends TSchema, TDetails = unknown, TSta
287
299
  }
288
300
  const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
289
301
  if (loopDecision.kind === 'block') {
302
+ fireLoopAbort(opts.getAbort)
290
303
  throw new Error(loopDecision.message)
291
304
  }
292
305
  const guardResult = await runFinalWriteGuards({
@@ -349,6 +362,7 @@ export function wrapSystemAgentTool<TParams extends TSchema, TDetails = unknown>
349
362
  }
350
363
  const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
351
364
  if (loopDecision.kind === 'block') {
365
+ fireLoopAbort(opts.getAbort)
352
366
  throw new Error(loopDecision.message)
353
367
  }
354
368
  const guardResult = await runFinalWriteGuards({
@@ -426,6 +440,7 @@ export function wrapAgentToolAsCustomToolDefinition<TParams extends TSchema, TDe
426
440
  delete mutableArgs[TYPECLAW_INTERNAL_BASH_ENV]
427
441
  const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
428
442
  if (loopDecision.kind === 'block') {
443
+ fireLoopAbort(opts.getAbort)
429
444
  throw new Error(loopDecision.message)
430
445
  }
431
446
  const guardResult = await runFinalWriteGuards({
@@ -499,12 +514,21 @@ async function applyBashSandbox(
499
514
  if (dirs.length === 0 && files.length === 0) return
500
515
 
501
516
  await ensureBwrapAvailable()
517
+ // Write-confined jail for low-trust roles: bind the whole project read-only,
518
+ // hide private/secret paths, then re-expose only the free-write scratch zones
519
+ // RW. Anything else under agentDir (.git/, node_modules/, agentDir root) is
520
+ // EROFS, so bash cannot sidestep the non-workspace-write guard. Trusted/owner
521
+ // never reach here (their masks are empty) and keep full unsandboxed access.
522
+ // subtractMasked drops any writable zone masked for this role so an RW bind
523
+ // never re-exposes a hidden path (e.g. a guest's masked workspace/).
524
+ const writable = subtractMasked(await resolveWritableZones(agentDir), { dirs, files })
502
525
  // bwrap does --clearenv, so the overlay must be re-introduced via env.set or
503
526
  // it would never reach the sandboxed process (the non-sandboxed spawnHook
504
527
  // path does not run when the command is rewritten to a bwrap invocation).
505
528
  const { commandString } = buildSandboxedCommand(command, {
506
- mounts: [{ type: 'bind', source: agentDir, dest: agentDir }],
529
+ mounts: [{ type: 'ro-bind', source: agentDir, dest: agentDir }],
507
530
  masks: { dirs, files },
531
+ writable,
508
532
  network: 'inherit',
509
533
  cwd: agentDir,
510
534
  ...(envOverlay !== undefined ? { env: { set: envOverlay } } : {}),
@@ -525,6 +549,18 @@ export function __resetSharedLoopGuardForTests(): void {
525
549
  sharedLoopGuard = createLoopGuard()
526
550
  }
527
551
 
552
+ // A loop-guard `block` verdict returned/thrown from a tool's execute() is
553
+ // caught by pi-agent-core and surfaced to the model as an `isError` result,
554
+ // which the model simply retries — the loop never ends. Aborting the run's
555
+ // AbortSignal is the only thing that actually stops the in-flight turn (the
556
+ // next assistant stream sees the aborted signal and ends with stopReason
557
+ // 'aborted'). We use the signal-only `agent.abort`, never `session.abort`,
558
+ // which would deadlock awaiting the very run this tool call belongs to. See
559
+ // the matching pattern in src/channels/router.ts (policy-denied send cap).
560
+ function fireLoopAbort(getAbort: (() => (() => void) | undefined) | undefined): void {
561
+ getAbort?.()?.()
562
+ }
563
+
528
564
  function errorResult(message: string) {
529
565
  return {
530
566
  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
  }
@@ -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
  }