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.
- package/package.json +1 -1
- package/src/agent/index.ts +43 -5
- package/src/agent/live-subagents.ts +5 -0
- package/src/agent/loop-guard.ts +112 -26
- package/src/agent/plugin-tools.ts +167 -50
- package/src/agent/session-origin.ts +3 -3
- package/src/agent/subagent-drain.ts +150 -0
- package/src/agent/subagents.ts +41 -3
- package/src/agent/system-prompt.ts +29 -4
- package/src/agent/tools/channel-send.ts +1 -1
- package/src/agent/tools/spawn-subagent.ts +34 -1
- package/src/agent/tools/subagent-output.ts +7 -3
- package/src/agent/tools/wikipedia.ts +1 -1
- package/src/bundled-plugins/bun-hygiene/README.md +12 -11
- package/src/bundled-plugins/bun-hygiene/policy.ts +8 -3
- package/src/bundled-plugins/explorer/explorer.ts +2 -0
- package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +94 -0
- package/src/bundled-plugins/github-cli-auth/effective-approval.ts +98 -0
- package/src/bundled-plugins/github-cli-auth/gh-review-inline-detect.ts +130 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +27 -2
- package/src/bundled-plugins/github-cli-auth/review-recorder.ts +12 -4
- package/src/bundled-plugins/memory/memory-logger.ts +3 -3
- package/src/bundled-plugins/operator/operator.ts +2 -0
- package/src/bundled-plugins/planner/index.ts +11 -0
- package/src/bundled-plugins/planner/planner.ts +283 -0
- package/src/bundled-plugins/planner/skills/general.ts +65 -0
- package/src/bundled-plugins/planner/skills/project.ts +69 -0
- package/src/bundled-plugins/researcher/index.ts +11 -0
- package/src/bundled-plugins/researcher/researcher.ts +233 -0
- package/src/bundled-plugins/researcher/skills/general.ts +105 -0
- package/src/bundled-plugins/researcher/write-report.ts +107 -0
- package/src/bundled-plugins/reviewer/reviewer.ts +28 -9
- package/src/bundled-plugins/reviewer/skills/data-review.ts +77 -0
- package/src/bundled-plugins/reviewer/skills/doc-review.ts +79 -0
- package/src/bundled-plugins/reviewer/skills/plan-review.ts +64 -0
- package/src/bundled-plugins/reviewer/skills/security-audit.ts +70 -0
- package/src/bundled-plugins/reviewer/skills/writing-review.ts +63 -0
- package/src/bundled-plugins/scout/scout.ts +2 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +8 -4
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +3 -2
- package/src/channels/adapters/discord-bot.ts +38 -11
- package/src/channels/adapters/github/inbound.ts +68 -4
- package/src/channels/adapters/kakaotalk-classify.ts +2 -2
- package/src/channels/adapters/kakaotalk.ts +2 -2
- package/src/channels/adapters/slack-bot-classify.ts +1 -1
- package/src/channels/adapters/slack-bot.ts +3 -0
- package/src/channels/adapters/telegram-bot.ts +3 -0
- package/src/channels/engagement.ts +12 -7
- package/src/channels/github-review-claim.ts +15 -3
- package/src/channels/router.ts +85 -9
- package/src/channels/schema.ts +1 -1
- package/src/channels/types.ts +6 -0
- package/src/cli/init.ts +13 -2
- package/src/cli/ui.ts +64 -0
- package/src/config/config.ts +21 -15
- package/src/container/start.ts +5 -1
- package/src/init/dockerfile.ts +19 -56
- package/src/init/hatching.ts +1 -1
- package/src/init/index.ts +5 -1
- package/src/migrations/index.ts +35 -0
- package/src/migrations/secrets-v1-to-v2.ts +344 -0
- package/src/run/bundled-plugins.ts +4 -0
- package/src/run/index.ts +13 -0
- package/src/sandbox/availability.ts +12 -0
- package/src/sandbox/build.ts +12 -0
- package/src/sandbox/index.ts +1 -1
- package/src/sandbox/policy.ts +8 -0
- package/src/server/index.ts +24 -5
- package/src/shared/host-locale.ts +27 -0
- package/src/shared/protocol.ts +1 -1
- package/src/shared/wordmark.ts +19 -0
- package/src/skills/typeclaw-config/SKILL.md +32 -32
- package/src/skills/typeclaw-kaomoji/SKILL.md +3 -3
- package/src/skills/typeclaw-tunnels/SKILL.md +3 -1
- package/src/tui/banner.ts +19 -0
- package/src/tui/format.ts +34 -0
- package/src/tui/index.ts +121 -22
- package/src/tui/theme.ts +26 -1
- package/src/tunnels/providers/cloudflare-named.ts +15 -4
- package/src/tunnels/providers/cloudflare-quick.ts +15 -4
- package/src/tunnels/providers/cloudflared-binary.ts +11 -0
- 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
|
|
245
|
-
if (
|
|
246
|
+
const loopGate = gateLoopGuard(opts.sessionId, opts.toolName, before.args)
|
|
247
|
+
if (loopGate.blockNow) {
|
|
246
248
|
fireLoopAbort(opts.getAbort)
|
|
247
|
-
return errorResult(
|
|
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
|
-
|
|
266
|
-
|
|
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
|
|
305
|
-
if (
|
|
309
|
+
const loopGate = gateLoopGuard(opts.sessionId, tool.name, mutableArgs)
|
|
310
|
+
if (loopGate.blockNow) {
|
|
306
311
|
fireLoopAbort(opts.getAbort)
|
|
307
|
-
throw new Error(
|
|
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
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
368
|
-
if (
|
|
369
|
+
const loopGate = gateLoopGuard(opts.sessionId, tool.name, mutableArgs)
|
|
370
|
+
if (loopGate.blockNow) {
|
|
369
371
|
fireLoopAbort(opts.getAbort)
|
|
370
|
-
throw new Error(
|
|
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
|
|
388
|
-
|
|
389
|
-
|
|
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
|
|
446
|
-
if (
|
|
444
|
+
const loopGate = gateLoopGuard(opts.sessionId, tool.name, mutableArgs)
|
|
445
|
+
if (loopGate.blockNow) {
|
|
447
446
|
fireLoopAbort(opts.getAbort)
|
|
448
|
-
throw new Error(
|
|
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
|
-
|
|
469
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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<
|
|
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:
|
|
634
|
-
// "
|
|
635
|
-
// check, so
|
|
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
|
+
}
|
package/src/agent/subagents.ts
CHANGED
|
@@ -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 } =
|
|
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
|
}
|