typeclaw 0.28.2 → 0.30.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 (82) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +43 -5
  3. package/src/agent/live-subagents.ts +5 -0
  4. package/src/agent/loop-guard.ts +112 -26
  5. package/src/agent/plugin-tools.ts +167 -50
  6. package/src/agent/session-origin.ts +3 -3
  7. package/src/agent/subagent-drain.ts +150 -0
  8. package/src/agent/subagents.ts +41 -3
  9. package/src/agent/system-prompt.ts +29 -4
  10. package/src/agent/tools/channel-send.ts +1 -1
  11. package/src/agent/tools/spawn-subagent.ts +34 -1
  12. package/src/agent/tools/subagent-output.ts +7 -3
  13. package/src/agent/tools/wikipedia.ts +1 -1
  14. package/src/bundled-plugins/bun-hygiene/README.md +12 -11
  15. package/src/bundled-plugins/bun-hygiene/policy.ts +8 -3
  16. package/src/bundled-plugins/explorer/explorer.ts +2 -0
  17. package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +94 -0
  18. package/src/bundled-plugins/github-cli-auth/effective-approval.ts +98 -0
  19. package/src/bundled-plugins/github-cli-auth/gh-review-inline-detect.ts +130 -0
  20. package/src/bundled-plugins/github-cli-auth/index.ts +27 -2
  21. package/src/bundled-plugins/github-cli-auth/review-recorder.ts +12 -4
  22. package/src/bundled-plugins/memory/memory-logger.ts +3 -3
  23. package/src/bundled-plugins/operator/operator.ts +2 -0
  24. package/src/bundled-plugins/planner/index.ts +11 -0
  25. package/src/bundled-plugins/planner/planner.ts +283 -0
  26. package/src/bundled-plugins/planner/skills/general.ts +65 -0
  27. package/src/bundled-plugins/planner/skills/project.ts +69 -0
  28. package/src/bundled-plugins/researcher/index.ts +11 -0
  29. package/src/bundled-plugins/researcher/researcher.ts +233 -0
  30. package/src/bundled-plugins/researcher/skills/general.ts +105 -0
  31. package/src/bundled-plugins/researcher/write-report.ts +107 -0
  32. package/src/bundled-plugins/reviewer/reviewer.ts +28 -9
  33. package/src/bundled-plugins/reviewer/skills/data-review.ts +77 -0
  34. package/src/bundled-plugins/reviewer/skills/doc-review.ts +79 -0
  35. package/src/bundled-plugins/reviewer/skills/plan-review.ts +64 -0
  36. package/src/bundled-plugins/reviewer/skills/security-audit.ts +70 -0
  37. package/src/bundled-plugins/reviewer/skills/writing-review.ts +63 -0
  38. package/src/bundled-plugins/scout/scout.ts +2 -0
  39. package/src/bundled-plugins/security/policies/prompt-injection.ts +8 -4
  40. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +3 -2
  41. package/src/channels/adapters/discord-bot.ts +38 -11
  42. package/src/channels/adapters/github/inbound.ts +68 -4
  43. package/src/channels/adapters/kakaotalk-classify.ts +2 -2
  44. package/src/channels/adapters/kakaotalk.ts +2 -2
  45. package/src/channels/adapters/slack-bot-classify.ts +1 -1
  46. package/src/channels/adapters/slack-bot.ts +3 -0
  47. package/src/channels/adapters/telegram-bot.ts +3 -0
  48. package/src/channels/engagement.ts +12 -7
  49. package/src/channels/github-review-claim.ts +15 -3
  50. package/src/channels/router.ts +85 -9
  51. package/src/channels/schema.ts +1 -1
  52. package/src/channels/types.ts +6 -0
  53. package/src/cli/init.ts +13 -2
  54. package/src/cli/ui.ts +64 -0
  55. package/src/config/config.ts +21 -15
  56. package/src/container/start.ts +5 -1
  57. package/src/init/dockerfile.ts +19 -56
  58. package/src/init/hatching.ts +1 -1
  59. package/src/init/index.ts +5 -1
  60. package/src/migrations/index.ts +35 -0
  61. package/src/migrations/secrets-v1-to-v2.ts +344 -0
  62. package/src/run/bundled-plugins.ts +4 -0
  63. package/src/run/index.ts +13 -0
  64. package/src/sandbox/availability.ts +12 -0
  65. package/src/sandbox/build.ts +12 -0
  66. package/src/sandbox/index.ts +1 -1
  67. package/src/sandbox/policy.ts +8 -0
  68. package/src/server/index.ts +24 -5
  69. package/src/shared/host-locale.ts +27 -0
  70. package/src/shared/protocol.ts +1 -1
  71. package/src/shared/wordmark.ts +19 -0
  72. package/src/skills/typeclaw-config/SKILL.md +32 -32
  73. package/src/skills/typeclaw-kaomoji/SKILL.md +3 -3
  74. package/src/skills/typeclaw-tunnels/SKILL.md +3 -1
  75. package/src/tui/banner.ts +19 -0
  76. package/src/tui/format.ts +34 -0
  77. package/src/tui/index.ts +121 -22
  78. package/src/tui/theme.ts +26 -1
  79. package/src/tunnels/providers/cloudflare-named.ts +15 -4
  80. package/src/tunnels/providers/cloudflare-quick.ts +15 -4
  81. package/src/tunnels/providers/cloudflared-binary.ts +11 -0
  82. package/typeclaw.schema.json +15 -7
@@ -40,14 +40,16 @@ import {
40
40
  ensureSessionTmpDir,
41
41
  mapVirtualTmpPath,
42
42
  resolveHiddenPaths,
43
+ resolveProcSelfExe,
43
44
  resolveProtectedZones,
44
45
  resolveWritableZones,
45
46
  subtractMasked,
46
47
  } from '@/sandbox'
47
48
 
48
- import { createLoopGuard, type LoopGuard } from './loop-guard'
49
+ import { createLoopGuard, type LoopGuard, type LoopGuardDecision } from './loop-guard'
49
50
  import { checkImageReadRedirect } from './multimodal/read-redirect'
50
51
  import type { SessionOrigin } from './session-origin'
52
+ import { SUBAGENT_OUTPUT_TOOL_NAME, type SubagentOutputToolDetails } from './tools/subagent-output'
51
53
  import { webFetchTool } from './tools/webfetch'
52
54
  import { webSearchTool } from './tools/websearch'
53
55
 
@@ -241,10 +243,10 @@ export function wrapPluginTool(tool: Tool<any>, opts: WrapToolOptions): ToolDefi
241
243
  return errorResult(`blocked: ${blockResult.reason}`)
242
244
  }
243
245
 
244
- const loopDecision = sharedLoopGuard.check(opts.sessionId, opts.toolName, before.args)
245
- if (loopDecision.kind === 'block') {
246
+ const loopGate = gateLoopGuard(opts.sessionId, opts.toolName, before.args)
247
+ if (loopGate.blockNow) {
246
248
  fireLoopAbort(opts.getAbort)
247
- return errorResult(loopDecision.message)
249
+ return errorResult(loopGate.message)
248
250
  }
249
251
 
250
252
  const toolCtx: ToolContext = {
@@ -262,9 +264,12 @@ export function wrapPluginTool(tool: Tool<any>, opts: WrapToolOptions): ToolDefi
262
264
  return errorResult(message)
263
265
  }
264
266
 
265
- if (loopDecision.kind === 'warn') {
266
- result = appendLoopWarning(result, loopDecision.message)
267
+ const resolved = loopGate.resolve(result)
268
+ if ('deferredBlock' in resolved) {
269
+ fireLoopAbort(opts.getAbort)
270
+ return errorResult(resolved.deferredBlock)
267
271
  }
272
+ result = resolved.result
268
273
 
269
274
  await opts.hooks.runToolAfter({
270
275
  tool: opts.toolName,
@@ -301,10 +306,10 @@ export function wrapSystemTool<TParams extends TSchema, TDetails = unknown, TSta
301
306
  if (blockResult !== undefined) {
302
307
  throw new Error(`blocked: ${blockResult.reason}`)
303
308
  }
304
- const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
305
- if (loopDecision.kind === 'block') {
309
+ const loopGate = gateLoopGuard(opts.sessionId, tool.name, mutableArgs)
310
+ if (loopGate.blockNow) {
306
311
  fireLoopAbort(opts.getAbort)
307
- throw new Error(loopDecision.message)
312
+ throw new Error(loopGate.message)
308
313
  }
309
314
  const guardResult = await runFinalWriteGuards({
310
315
  tool: tool.name,
@@ -321,15 +326,12 @@ export function wrapSystemTool<TParams extends TSchema, TDetails = unknown, TSta
321
326
  stripGuardAcknowledgements(mutableArgs)
322
327
 
323
328
  const result = await tool.execute(toolCallId, mutableArgs as Static<TParams>, signal, onUpdate, ctx)
324
- const hookResult: ToolResult = {
325
- content: result.content as ContentPart[],
326
- details: result.details,
327
- }
328
- if (loopDecision.kind === 'warn') {
329
- const warned = appendLoopWarning(hookResult, loopDecision.message)
330
- hookResult.content = warned.content
331
- hookResult.details = warned.details
329
+ const resolved = loopGate.resolve({ content: result.content as ContentPart[], details: result.details })
330
+ if ('deferredBlock' in resolved) {
331
+ fireLoopAbort(opts.getAbort)
332
+ throw new Error(resolved.deferredBlock)
332
333
  }
334
+ const hookResult = resolved.result
333
335
  await opts.hooks.runToolAfter({
334
336
  tool: tool.name,
335
337
  sessionId: opts.sessionId,
@@ -337,7 +339,7 @@ export function wrapSystemTool<TParams extends TSchema, TDetails = unknown, TSta
337
339
  result: hookResult,
338
340
  })
339
341
  return {
340
- content: hookResult.content,
342
+ content: hookResult.content as ContentPart[],
341
343
  details: hookResult.details as TDetails,
342
344
  }
343
345
  },
@@ -364,10 +366,10 @@ export function wrapSystemAgentTool<TParams extends TSchema, TDetails = unknown>
364
366
  if (blockResult !== undefined) {
365
367
  throw new Error(`blocked: ${blockResult.reason}`)
366
368
  }
367
- const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
368
- if (loopDecision.kind === 'block') {
369
+ const loopGate = gateLoopGuard(opts.sessionId, tool.name, mutableArgs)
370
+ if (loopGate.blockNow) {
369
371
  fireLoopAbort(opts.getAbort)
370
- throw new Error(loopDecision.message)
372
+ throw new Error(loopGate.message)
371
373
  }
372
374
  const guardResult = await runFinalWriteGuards({
373
375
  tool: tool.name,
@@ -384,15 +386,12 @@ export function wrapSystemAgentTool<TParams extends TSchema, TDetails = unknown>
384
386
  stripGuardAcknowledgements(mutableArgs)
385
387
 
386
388
  const result = await tool.execute(toolCallId, mutableArgs as Static<TParams>, signal, onUpdate)
387
- const hookResult: ToolResult = {
388
- content: result.content as ContentPart[],
389
- details: result.details,
390
- }
391
- if (loopDecision.kind === 'warn') {
392
- const warned = appendLoopWarning(hookResult, loopDecision.message)
393
- hookResult.content = warned.content
394
- hookResult.details = warned.details
389
+ const resolved = loopGate.resolve({ content: result.content as ContentPart[], details: result.details })
390
+ if ('deferredBlock' in resolved) {
391
+ fireLoopAbort(opts.getAbort)
392
+ throw new Error(resolved.deferredBlock)
395
393
  }
394
+ const hookResult = resolved.result
396
395
  await opts.hooks.runToolAfter({
397
396
  tool: tool.name,
398
397
  sessionId: opts.sessionId,
@@ -400,7 +399,7 @@ export function wrapSystemAgentTool<TParams extends TSchema, TDetails = unknown>
400
399
  result: hookResult,
401
400
  })
402
401
  return {
403
- content: hookResult.content,
402
+ content: hookResult.content as ContentPart[],
404
403
  details: hookResult.details as TDetails,
405
404
  }
406
405
  },
@@ -442,10 +441,10 @@ export function wrapAgentToolAsCustomToolDefinition<TParams extends TSchema, TDe
442
441
  // loop-detection state, or pi's execute.
443
442
  const bashEnvOverlay = readBashEnvOverlay(mutableArgs)
444
443
  delete mutableArgs[TYPECLAW_INTERNAL_BASH_ENV]
445
- const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
446
- if (loopDecision.kind === 'block') {
444
+ const loopGate = gateLoopGuard(opts.sessionId, tool.name, mutableArgs)
445
+ if (loopGate.blockNow) {
447
446
  fireLoopAbort(opts.getAbort)
448
- throw new Error(loopDecision.message)
447
+ throw new Error(loopGate.message)
449
448
  }
450
449
  const guardResult = await runFinalWriteGuards({
451
450
  tool: tool.name,
@@ -465,22 +464,30 @@ export function wrapAgentToolAsCustomToolDefinition<TParams extends TSchema, TDe
465
464
  await applyBashSandbox(mutableArgs, opts.permissions, liveOrigin, opts.agentDir, opts.sessionId, bashEnvOverlay)
466
465
  }
467
466
 
468
- if (TMP_REDIRECT_TOOLS.has(tool.name) && opts.permissions !== undefined) {
469
- await applyTmpPathRedirect(mutableArgs, opts.permissions, liveOrigin, opts.agentDir, opts.sessionId)
470
- }
467
+ const tmpRedirect =
468
+ TMP_REDIRECT_TOOLS.has(tool.name) && opts.permissions !== undefined
469
+ ? await applyTmpPathRedirect(mutableArgs, opts.permissions, liveOrigin, opts.agentDir, opts.sessionId)
470
+ : undefined
471
471
 
472
- const result = await bashEnvStore.run(bashEnvOverlay, () =>
473
- tool.execute(toolCallId, mutableArgs as Static<TParams>, signal, onUpdate),
474
- )
475
- const hookResult: ToolResult = {
476
- content: result.content as ContentPart[],
477
- details: result.details,
472
+ let rawResult: ToolResult
473
+ try {
474
+ rawResult = await bashEnvStore.run(bashEnvOverlay, () =>
475
+ tool.execute(toolCallId, mutableArgs as Static<TParams>, signal, onUpdate),
476
+ )
477
+ } catch (error) {
478
+ // A throwing tool (pi's bash rejects on non-zero exit) must still run
479
+ // tool.after so cleanup hooks fire — e.g. the github approve guard's
480
+ // release, whose absence stranded a PR as "already approved" (PR #672).
481
+ await runToolAfterSafely(opts, tool.name, toolCallId, toErrorResult(error))
482
+ throw error
478
483
  }
479
- if (loopDecision.kind === 'warn') {
480
- const warned = appendLoopWarning(hookResult, loopDecision.message)
481
- hookResult.content = warned.content
482
- hookResult.details = warned.details
484
+ const result = tmpRedirect !== undefined ? restoreTmpPathInResult(rawResult, tmpRedirect) : rawResult
485
+ const resolved = loopGate.resolve({ content: result.content as ContentPart[], details: result.details })
486
+ if ('deferredBlock' in resolved) {
487
+ fireLoopAbort(opts.getAbort)
488
+ throw new Error(resolved.deferredBlock)
483
489
  }
490
+ const hookResult = resolved.result
484
491
  await opts.hooks.runToolAfter({
485
492
  tool: tool.name,
486
493
  sessionId: opts.sessionId,
@@ -495,6 +502,26 @@ export function wrapAgentToolAsCustomToolDefinition<TParams extends TSchema, TDe
495
502
  })
496
503
  }
497
504
 
505
+ function toErrorResult(error: unknown): ToolResult {
506
+ const message = error instanceof Error ? error.message : String(error)
507
+ return { content: [{ type: 'text', text: message }], details: { error: message } }
508
+ }
509
+
510
+ // The original tool error must always propagate, so a failure inside the
511
+ // after-hook itself is swallowed rather than masking the real cause.
512
+ async function runToolAfterSafely(
513
+ opts: WrapSystemToolOptions,
514
+ tool: string,
515
+ callId: string,
516
+ result: ToolResult,
517
+ ): Promise<void> {
518
+ try {
519
+ await opts.hooks.runToolAfter({ tool, sessionId: opts.sessionId, callId, result })
520
+ } catch {
521
+ // intentionally ignored: never mask the originating tool error
522
+ }
523
+ }
524
+
498
525
  export function defaultBuiltinPiAgentTools(): AgentTool<any, any>[] {
499
526
  return [piReadTool, piBashTool, piEditTool, piWriteTool, piGrepTool, piFindTool, piLsTool]
500
527
  }
@@ -565,6 +592,7 @@ async function applyBashSandbox(
565
592
  protected: protectedZones,
566
593
  network: 'inherit',
567
594
  cwd: agentDir,
595
+ procSelfExe: resolveProcSelfExe(),
568
596
  ...(envOverlay !== undefined ? { env: { set: envOverlay } } : {}),
569
597
  })
570
598
  mutableArgs.command = commandString
@@ -584,24 +612,47 @@ const TMP_REDIRECT_TOOLS = new Set(['read', 'write', 'edit', 'grep', 'find', 'ls
584
612
  // different files. Rewriting the file tool's on-disk path to the same session
585
613
  // backing dir makes every layer resolve /tmp/foo to one file. Unsandboxed roles
586
614
  // (empty masks) are left untouched: their bash already shares the real /tmp.
615
+ type TmpRedirect = { original: string; backing: string }
616
+
587
617
  async function applyTmpPathRedirect(
588
618
  mutableArgs: Record<string, unknown>,
589
619
  permissions: PermissionService,
590
620
  origin: SessionOrigin | undefined,
591
621
  agentDir: string,
592
622
  sessionId: string,
593
- ): Promise<void> {
623
+ ): Promise<TmpRedirect | undefined> {
594
624
  const rawPath = mutableArgs.path
595
- if (typeof rawPath !== 'string') return
625
+ if (typeof rawPath !== 'string') return undefined
596
626
 
597
627
  const { dirs, files } = resolveHiddenPaths(permissions, origin, agentDir)
598
- if (dirs.length === 0 && files.length === 0) return
628
+ if (dirs.length === 0 && files.length === 0) return undefined
599
629
 
600
630
  const backing = mapVirtualTmpPath(agentDir, sessionId, rawPath)
601
- if (backing === undefined) return
631
+ if (backing === undefined || backing === rawPath) return undefined
602
632
 
603
633
  await ensureSessionTmpDir(sessionId)
604
634
  mutableArgs.path = backing
635
+ return { original: rawPath, backing }
636
+ }
637
+
638
+ // The redirect swaps the model-facing /tmp path for its session backing dir
639
+ // before execution; the file tool then echoes that backing path in its receipt
640
+ // text and details. Reverse it on the way out so the model only ever sees the
641
+ // path it asked for — a leaked backing path is unreachable inside the bwrap
642
+ // bash sandbox, so reusing it in `gh api --input` fails (the PR #672 strand).
643
+ function restoreTmpPathInResult(result: ToolResult, redirect: TmpRedirect): ToolResult {
644
+ const content = (result.content as ContentPart[]).map((part) =>
645
+ part.type === 'text' ? { ...part, text: part.text.split(redirect.backing).join(redirect.original) } : part,
646
+ )
647
+ const details =
648
+ isRecord(result.details) && result.details.path === redirect.backing
649
+ ? { ...result.details, path: redirect.original }
650
+ : result.details
651
+ return { content, details }
652
+ }
653
+
654
+ function isRecord(value: unknown): value is Record<string, unknown> {
655
+ return typeof value === 'object' && value !== null
605
656
  }
606
657
 
607
658
  function appendLoopWarning(result: ToolResult, message: string): ToolResult {
@@ -609,6 +660,72 @@ function appendLoopWarning(result: ToolResult, message: string): ToolResult {
609
660
  return { content, details: result.details }
610
661
  }
611
662
 
663
+ // `subagent_output` is a read-only poll whose loop/no-loop classification only
664
+ // becomes knowable AFTER execution: a result of `status: 'running'` is a
665
+ // still-pending wait (legitimate), while a repeated terminal result is a real
666
+ // loop. The loop guard's `check` is result-blind and pre-execution, so for this
667
+ // one tool we DEFER enforcing a block until the status is known — otherwise the
668
+ // exact poll that would reveal 'running' gets blocked before it can run (the
669
+ // boundary-call hazard for round-robin fan-out polling). Every other tool
670
+ // enforces its block immediately, as before.
671
+ // A block is deferred only for a `subagent_output` poll the guard still marks
672
+ // `deferable` — i.e. whose signature has not yet proven terminal. Once a poll of
673
+ // that signature returns completed/failed, `deferable` is false and the block is
674
+ // enforced pre-execute, so a finished task is not re-polled forever.
675
+ function shouldDeferLoopBlock(toolName: string, decision: LoopGuardDecision): boolean {
676
+ return toolName === SUBAGENT_OUTPUT_TOOL_NAME && decision.kind === 'block' && decision.deferable
677
+ }
678
+
679
+ function subagentPollStatus(toolName: string, result: ToolResult): 'running' | 'terminal' | undefined {
680
+ if (toolName !== SUBAGENT_OUTPUT_TOOL_NAME) return undefined
681
+ const details = result.details as SubagentOutputToolDetails | undefined
682
+ if (details?.ok !== true) return undefined
683
+ return details.status === 'running' ? 'running' : 'terminal'
684
+ }
685
+
686
+ type LoopGuardGate = {
687
+ // True when the guard wants to block AND the block is enforced now (every tool
688
+ // except a deferable `subagent_output` poll). The caller aborts + errors.
689
+ blockNow: boolean
690
+ message: string
691
+ // Resolves the guard against the tool's result. Returns the result to surface
692
+ // (possibly warn-annotated), or `{ deferredBlock: message }` when a deferred
693
+ // `subagent_output` block must now be enforced because the poll did not return
694
+ // a still-running status.
695
+ resolve: (result: ToolResult) => { result: ToolResult } | { deferredBlock: string }
696
+ }
697
+
698
+ // Single chokepoint for the loop-guard pre-check + post-execute resolution so
699
+ // all four tool wrappers share identical deferred-block / pending-retract
700
+ // semantics. `check` runs here (recording the observation); the returned
701
+ // `resolve` is called after execute with the tool's result, feeding the poll's
702
+ // running/terminal status back to the guard so future blocks stop deferring.
703
+ function gateLoopGuard(sessionId: string, toolName: string, args: unknown): LoopGuardGate {
704
+ const decision = sharedLoopGuard.check(sessionId, toolName, args)
705
+ const defer = shouldDeferLoopBlock(toolName, decision)
706
+ return {
707
+ blockNow: decision.kind === 'block' && !defer,
708
+ message: decision.kind === 'ok' ? '' : decision.message,
709
+ resolve(result) {
710
+ const pollStatus = subagentPollStatus(toolName, result)
711
+ if (pollStatus !== undefined) {
712
+ sharedLoopGuard.noteResult(decision.receipt, pollStatus)
713
+ }
714
+ if (pollStatus === 'running') {
715
+ sharedLoopGuard.retract(decision.receipt)
716
+ return { result }
717
+ }
718
+ if (defer && decision.kind === 'block') {
719
+ return { deferredBlock: decision.message }
720
+ }
721
+ if (decision.kind === 'warn') {
722
+ return { result: appendLoopWarning(result, decision.message) }
723
+ }
724
+ return { result }
725
+ },
726
+ }
727
+ }
728
+
612
729
  // Clears one tool's loop-guard residue for a session on the process-wide shared
613
730
  // guard. The completion-reminder bridges (channel router + TUI server) call this
614
731
  // for `subagent_output` when a backgrounded subagent finishes, so the next fetch
@@ -630,9 +630,9 @@ function renderParticipants(
630
630
  // mention syntax) and Telegram (uses `@username`, where `authorId` is a
631
631
  // numeric id and NOT the username). See issue #188.
632
632
  //
633
- // Symptom in the wild before PR #183 + this fix: 돌쇠 addressing Winky as
634
- // "Winky님" (plain text) on Discord, which never trips Winky's `isBotMention`
635
- // check, so Winky observes silently and the conversation stalls. The
633
+ // Symptom in the wild before PR #183 + this fix: Kiki addressing Momo as
634
+ // "Momo님" (plain text) on Discord, which never trips Momo's `isBotMention`
635
+ // check, so Momo observes silently and the conversation stalls. The
636
636
  // angle-id branch here is exactly the fix for that case; the at-username
637
637
  // and alias branches keep the platform contract honest for KakaoTalk and
638
638
  // Telegram instead of self-contradicting the per-adapter mention guidance
@@ -0,0 +1,150 @@
1
+ import type { Stream, Unsubscribe } from '@/stream'
2
+
3
+ import type { LiveSubagentRegistry } from './live-subagents'
4
+ import { parseSubagentCompletedPayload, renderSubagentCompletionReminder } from './subagent-completion-reminder'
5
+
6
+ // Presence of this capability is the single signal that background spawning is
7
+ // permitted from a subagent (see the spawn_subagent guard); absence keeps the
8
+ // subagent a one-shot leaf. It carries everything the drain needs: the shared
9
+ // Stream to listen on, the subagent's own sessionId to filter completions by,
10
+ // and the registry that is the source of truth for child state.
11
+ export type SubagentBackgroundDrain = {
12
+ stream: Stream
13
+ sessionId: string
14
+ liveRegistry: LiveSubagentRegistry
15
+ }
16
+
17
+ export type DrainPrompt = (text: string) => Promise<void>
18
+
19
+ export type RunSubagentDrainOptions = {
20
+ drain: SubagentBackgroundDrain
21
+ prompt: DrainPrompt
22
+ // Cooperative cancellation: when this returns true the loop stops re-prompting
23
+ // and returns, letting the caller's timeout/abort path dispose the session.
24
+ cancelled?: () => boolean
25
+ }
26
+
27
+ // Re-prompts a subagent with its children's completion reminders until a fixed
28
+ // point, called after the subagent's initial prompt resolves. The registry is
29
+ // the source of truth; stream broadcasts are only wakeups, so a duplicated or
30
+ // missed broadcast cannot corrupt termination (every iteration re-derives state
31
+ // from the registry). Each child's reminder is delivered at most once (tracked
32
+ // by taskId). Terminates only when no children are running AND none are
33
+ // completed-but-undelivered; a child spawned during a reminder turn reappears as
34
+ // `running` in the next snapshot and keeps the loop alive, so no separate
35
+ // "spawned nothing" flag is needed. The watch MUST have been started before the
36
+ // initial prompt (see `beginSubagentDrainWatch`) to close the lost-wakeup race.
37
+ export async function runSubagentDrain(watch: SubagentDrainWatch, options: RunSubagentDrainOptions): Promise<void> {
38
+ const { drain, prompt, cancelled } = options
39
+ const delivered = new Set<string>()
40
+ try {
41
+ while (cancelled === undefined || !cancelled()) {
42
+ const pending = collectPendingReminders(drain, delivered)
43
+ if (pending.length === 0) {
44
+ if (!hasRunningChildren(drain)) return
45
+ // Children still running but none newly completed: wait for the next
46
+ // wakeup, then re-derive from the registry.
47
+ const woke = await watch.waitForWakeup()
48
+ if (!woke) return
49
+ continue
50
+ }
51
+ for (const reminder of pending) {
52
+ if (cancelled !== undefined && cancelled()) return
53
+ delivered.add(reminder.taskId)
54
+ await prompt(reminder.text)
55
+ }
56
+ }
57
+ } finally {
58
+ watch.stop()
59
+ }
60
+ }
61
+
62
+ type PendingReminder = { taskId: string; text: string }
63
+
64
+ function collectPendingReminders(drain: SubagentBackgroundDrain, delivered: Set<string>): PendingReminder[] {
65
+ const children = drain.liveRegistry.list({ parentSessionId: drain.sessionId })
66
+ const pending: PendingReminder[] = []
67
+ for (const child of children) {
68
+ // Synchronous spawns return their result inline via the tool call; only
69
+ // background spawns deliver out-of-band and need a drain reminder.
70
+ if (child.background !== true) continue
71
+ if (child.status === 'running') continue
72
+ if (delivered.has(child.taskId)) continue
73
+ const completion = child.completion
74
+ const text = renderSubagentCompletionReminder({
75
+ subagent: child.subagentName,
76
+ taskId: child.taskId,
77
+ ok: child.status === 'completed',
78
+ durationMs: completion?.durationMs ?? 0,
79
+ ...(completion?.error !== undefined ? { error: completion.error } : {}),
80
+ })
81
+ pending.push({ taskId: child.taskId, text })
82
+ }
83
+ return pending
84
+ }
85
+
86
+ function hasRunningChildren(drain: SubagentBackgroundDrain): boolean {
87
+ // Only background children gate termination. A sync child still marked running
88
+ // in the registry settles via its inline tool call, never via a broadcast
89
+ // wakeup, so waiting on it would hang the drain forever.
90
+ return drain.liveRegistry
91
+ .list({ parentSessionId: drain.sessionId })
92
+ .some((c) => c.background === true && c.status === 'running')
93
+ }
94
+
95
+ export type SubagentDrainWatch = {
96
+ // Resolves true on a child-completion wakeup, false once stopped. A wakeup
97
+ // that arrives before anyone waits is latched (pendingWake), so a completion
98
+ // during the subagent's prompt is not lost.
99
+ waitForWakeup: () => Promise<boolean>
100
+ stop: () => void
101
+ }
102
+
103
+ export function beginSubagentDrainWatch(drain: SubagentBackgroundDrain): SubagentDrainWatch {
104
+ let stopped = false
105
+ let pendingWake = false
106
+ let resolveWaiter: ((woke: boolean) => void) | null = null
107
+
108
+ const wake = (): void => {
109
+ if (resolveWaiter !== null) {
110
+ const r = resolveWaiter
111
+ resolveWaiter = null
112
+ r(true)
113
+ return
114
+ }
115
+ pendingWake = true
116
+ }
117
+
118
+ const unsubscribe: Unsubscribe = drain.stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
119
+ const parsed = parseSubagentCompletedPayload(msg.payload)
120
+ if (parsed === null) return
121
+ if (parsed.parentSessionId !== drain.sessionId) return
122
+ wake()
123
+ })
124
+
125
+ return {
126
+ waitForWakeup: () =>
127
+ new Promise<boolean>((resolve) => {
128
+ if (stopped) {
129
+ resolve(false)
130
+ return
131
+ }
132
+ if (pendingWake) {
133
+ pendingWake = false
134
+ resolve(true)
135
+ return
136
+ }
137
+ resolveWaiter = resolve
138
+ }),
139
+ stop: () => {
140
+ if (stopped) return
141
+ stopped = true
142
+ unsubscribe()
143
+ if (resolveWaiter !== null) {
144
+ const r = resolveWaiter
145
+ resolveWaiter = null
146
+ r(false)
147
+ }
148
+ },
149
+ }
150
+ }
@@ -7,6 +7,12 @@ import type { Stream, Unsubscribe } from '@/stream'
7
7
  import { type AgentSession, createSession } from './index'
8
8
  import { subscribeProviderErrors } from './provider-error'
9
9
  import type { SessionOrigin } from './session-origin'
10
+ import {
11
+ beginSubagentDrainWatch,
12
+ runSubagentDrain,
13
+ type SubagentBackgroundDrain,
14
+ type SubagentDrainWatch,
15
+ } from './subagent-drain'
10
16
  import { renderTurnTimeAnchor } from './system-prompt'
11
17
  import type { ToolResultBudget } from './tool-result-budget'
12
18
 
@@ -48,6 +54,13 @@ export type SubagentShared<P = unknown> = {
48
54
  handler?: (ctx: SubagentContext<P>, runSession: RunSession) => Promise<void>
49
55
  toolResultBudget?: ToolResultBudget
50
56
  visibility?: 'public' | 'internal'
57
+ // One-line purpose blurb for the main agent's "## Subagent orchestration"
58
+ // roster, rendered from the registry by `renderPublicSubagentRoster` instead
59
+ // of hand-maintained in the prompt (the drift that once left `researcher` and
60
+ // `planner` unlisted). Required for `visibility: 'public'`; ignored otherwise.
61
+ // On `SubagentShared` so the plugin→internal shim carries it via rest-spread
62
+ // (see `pluginSubagentShim`), like `visibility`.
63
+ rosterDescription?: string
51
64
  requiresSpecificPermission?: boolean
52
65
  // Opt-in: when true, this subagent's session is wired with the orchestration
53
66
  // tools (spawn_subagent/subagent_output/subagent_cancel) so it can delegate
@@ -55,6 +68,12 @@ export type SubagentShared<P = unknown> = {
55
68
  // registry scoping. Default (unset/false) keeps the subagent a leaf — the
56
69
  // historical contract for explorer/scout/memory-logger/etc.
57
70
  canSpawnSubagents?: boolean
71
+ // Opt-in: allow this subagent to spawn background children AND drain their
72
+ // completions back into its own session (requires canSpawnSubagents). Default
73
+ // (unset/false) keeps background spawns denied from this subagent — it must
74
+ // use synchronous spawns. Only meaningful when the runtime wires the drain
75
+ // capability (createSessionForSubagent provides stream+sessionId+liveRegistry).
76
+ canBackgroundSpawnSubagents?: boolean
58
77
  // Wall-clock ceiling on a single spawn, enforced at the orchestration
59
78
  // layer (both `dispatchSpawnSubagent` and the stream-driven
60
79
  // `SubagentConsumer`). When exceeded, the orchestrator's `await` settles
@@ -109,6 +128,7 @@ export type CreateSessionForSubagentResult = {
109
128
  agentDir?: string
110
129
  origin?: SessionOrigin
111
130
  getTranscriptPath?: () => string | undefined
131
+ backgroundDrain?: SubagentBackgroundDrain
112
132
  }
113
133
  export type CreateSessionForSubagentOptions = {
114
134
  name?: string
@@ -145,6 +165,7 @@ type NormalizedSubagentSession = {
145
165
  agentDir: string | undefined
146
166
  origin: SessionOrigin | undefined
147
167
  getTranscriptPath: (() => string | undefined) | undefined
168
+ backgroundDrain: SubagentBackgroundDrain | undefined
148
169
  }
149
170
 
150
171
  function normalizeSubagentSession(result: AgentSession | CreateSessionForSubagentResult): NormalizedSubagentSession {
@@ -157,6 +178,7 @@ function normalizeSubagentSession(result: AgentSession | CreateSessionForSubagen
157
178
  agentDir: result.agentDir,
158
179
  origin: result.origin,
159
180
  getTranscriptPath: result.getTranscriptPath,
181
+ backgroundDrain: result.backgroundDrain,
160
182
  }
161
183
  }
162
184
  return {
@@ -167,6 +189,7 @@ function normalizeSubagentSession(result: AgentSession | CreateSessionForSubagen
167
189
  agentDir: undefined,
168
190
  origin: undefined,
169
191
  getTranscriptPath: undefined,
192
+ backgroundDrain: undefined,
170
193
  }
171
194
  }
172
195
 
@@ -207,14 +230,16 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
207
230
  }
208
231
 
209
232
  const runSession: RunSession = async (override) => {
210
- const { session, dispose, hooks, sessionId, agentDir, origin, getTranscriptPath } = normalizeSubagentSession(
211
- await createSessionForSubagent(subagent, sessionOptions),
212
- )
233
+ const { session, dispose, hooks, sessionId, agentDir, origin, getTranscriptPath, backgroundDrain } =
234
+ normalizeSubagentSession(await createSessionForSubagent(subagent, sessionOptions))
235
+ let aborted = false
236
+ let drainWatch: SubagentDrainWatch | undefined
213
237
  if (options.onSessionCreated !== undefined) {
214
238
  options.onSessionCreated({
215
239
  session,
216
240
  sessionId,
217
241
  abort: async () => {
242
+ aborted = true
218
243
  await session.abort()
219
244
  },
220
245
  })
@@ -232,6 +257,9 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
232
257
  if (hooks && turnEvent !== undefined) {
233
258
  await hooks.runSessionTurnStart({ ...turnEvent, userPrompt: userPromptForTurn })
234
259
  }
260
+ if (backgroundDrain !== undefined) {
261
+ drainWatch = beginSubagentDrainWatch(backgroundDrain)
262
+ }
235
263
  try {
236
264
  await session.prompt(`${renderTurnTimeAnchor()}\n\n${userPromptForTurn}`)
237
265
  } finally {
@@ -239,6 +267,15 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
239
267
  await hooks.runSessionTurnEnd(turnEvent)
240
268
  }
241
269
  }
270
+ if (drainWatch !== undefined && backgroundDrain !== undefined) {
271
+ await runSubagentDrain(drainWatch, {
272
+ drain: backgroundDrain,
273
+ prompt: async (text) => {
274
+ await session.prompt(`${renderTurnTimeAnchor()}\n\n${text}`)
275
+ },
276
+ cancelled: () => aborted,
277
+ })
278
+ }
242
279
  if (hooks && sessionId !== undefined) {
243
280
  await hooks.runSessionIdle({
244
281
  sessionId,
@@ -252,6 +289,7 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
252
289
  if (hooks && sessionId !== undefined) {
253
290
  await hooks.runSessionEnd({ sessionId, ...(origin !== undefined ? { origin } : {}) })
254
291
  }
292
+ drainWatch?.stop()
255
293
  session.dispose()
256
294
  await dispose()
257
295
  }