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