@esso0428/pi-subagents 0.17.5 → 0.17.7
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/CHANGELOG.md +14 -0
- package/CONTRIBUTING.md +4 -0
- package/README.md +1 -1
- package/dist/abortable.d.ts +13 -0
- package/dist/abortable.d.ts.map +1 -0
- package/dist/abortable.js +43 -0
- package/dist/abortable.js.map +1 -0
- package/dist/agent-color.d.ts +36 -0
- package/dist/agent-color.d.ts.map +1 -0
- package/dist/agent-color.js +124 -0
- package/dist/agent-color.js.map +1 -0
- package/dist/agent-file-toggle.d.ts +126 -0
- package/dist/agent-file-toggle.d.ts.map +1 -0
- package/dist/agent-file-toggle.js +259 -0
- package/dist/agent-file-toggle.js.map +1 -0
- package/dist/agent-history.d.ts +4 -0
- package/dist/agent-history.d.ts.map +1 -1
- package/dist/agent-history.js +47 -1
- package/dist/agent-history.js.map +1 -1
- package/dist/agent-manager.d.ts +370 -56
- package/dist/agent-manager.d.ts.map +1 -1
- package/dist/agent-manager.js +1123 -409
- package/dist/agent-manager.js.map +1 -1
- package/dist/agent-runner.d.ts +100 -10
- package/dist/agent-runner.d.ts.map +1 -1
- package/dist/agent-runner.js +166 -21
- package/dist/agent-runner.js.map +1 -1
- package/dist/agent-types.d.ts +57 -5
- package/dist/agent-types.d.ts.map +1 -1
- package/dist/agent-types.js +164 -32
- package/dist/agent-types.js.map +1 -1
- package/dist/child-context.d.ts +3 -0
- package/dist/child-context.d.ts.map +1 -0
- package/dist/child-context.js +13 -0
- package/dist/child-context.js.map +1 -0
- package/dist/cross-extension-rpc.d.ts +23 -3
- package/dist/cross-extension-rpc.d.ts.map +1 -1
- package/dist/cross-extension-rpc.js +79 -17
- package/dist/cross-extension-rpc.js.map +1 -1
- package/dist/custom-agents.d.ts +38 -1
- package/dist/custom-agents.d.ts.map +1 -1
- package/dist/custom-agents.js +164 -12
- package/dist/custom-agents.js.map +1 -1
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1908 -495
- package/dist/index.js.map +1 -1
- package/dist/invocation-config.d.ts +87 -2
- package/dist/invocation-config.d.ts.map +1 -1
- package/dist/invocation-config.js +71 -3
- package/dist/invocation-config.js.map +1 -1
- package/dist/mention-clone.d.ts +88 -0
- package/dist/mention-clone.d.ts.map +1 -0
- package/dist/mention-clone.js +154 -0
- package/dist/mention-clone.js.map +1 -0
- package/dist/mention.d.ts +82 -0
- package/dist/mention.d.ts.map +1 -0
- package/dist/mention.js +132 -0
- package/dist/mention.js.map +1 -0
- package/dist/model-resolver.d.ts +17 -0
- package/dist/model-resolver.d.ts.map +1 -1
- package/dist/model-resolver.js +15 -0
- package/dist/model-resolver.js.map +1 -1
- package/dist/model-scope.d.ts +50 -0
- package/dist/model-scope.d.ts.map +1 -0
- package/dist/model-scope.js +49 -0
- package/dist/model-scope.js.map +1 -0
- package/dist/nested-tools.d.ts +57 -0
- package/dist/nested-tools.d.ts.map +1 -0
- package/dist/nested-tools.js +301 -0
- package/dist/nested-tools.js.map +1 -0
- package/dist/output-file.d.ts +22 -3
- package/dist/output-file.d.ts.map +1 -1
- package/dist/output-file.js +58 -7
- package/dist/output-file.js.map +1 -1
- package/dist/prompts.d.ts +23 -0
- package/dist/prompts.d.ts.map +1 -1
- package/dist/prompts.js +20 -2
- package/dist/prompts.js.map +1 -1
- package/dist/schedule.d.ts.map +1 -1
- package/dist/schedule.js +36 -15
- package/dist/schedule.js.map +1 -1
- package/dist/settings.d.ts +228 -2
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js +94 -0
- package/dist/settings.js.map +1 -1
- package/dist/status-note.d.ts +49 -1
- package/dist/status-note.d.ts.map +1 -1
- package/dist/status-note.js +62 -1
- package/dist/status-note.js.map +1 -1
- package/dist/structured-output.d.ts +62 -0
- package/dist/structured-output.d.ts.map +1 -0
- package/dist/structured-output.js +113 -0
- package/dist/structured-output.js.map +1 -0
- package/dist/types.d.ts +176 -10
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/agent-mention.d.ts +83 -0
- package/dist/ui/agent-mention.d.ts.map +1 -0
- package/dist/ui/agent-mention.js +188 -0
- package/dist/ui/agent-mention.js.map +1 -0
- package/dist/ui/agent-widget.d.ts +96 -75
- package/dist/ui/agent-widget.d.ts.map +1 -1
- package/dist/ui/agent-widget.js +397 -420
- package/dist/ui/agent-widget.js.map +1 -1
- package/dist/ui/conversation-blocks.d.ts.map +1 -1
- package/dist/ui/conversation-blocks.js +6 -0
- package/dist/ui/conversation-blocks.js.map +1 -1
- package/dist/ui/conversation-timeline.d.ts +10 -2
- package/dist/ui/conversation-timeline.d.ts.map +1 -1
- package/dist/ui/conversation-timeline.js +130 -23
- package/dist/ui/conversation-timeline.js.map +1 -1
- package/dist/ui/conversation-viewer.d.ts +20 -5
- package/dist/ui/conversation-viewer.d.ts.map +1 -1
- package/dist/ui/conversation-viewer.js +274 -73
- package/dist/ui/conversation-viewer.js.map +1 -1
- package/dist/ui/fleet-list.d.ts +198 -0
- package/dist/ui/fleet-list.d.ts.map +1 -0
- package/dist/ui/fleet-list.js +487 -0
- package/dist/ui/fleet-list.js.map +1 -0
- package/dist/ui/schedule-menu.d.ts.map +1 -1
- package/dist/ui/schedule-menu.js +6 -7
- package/dist/ui/schedule-menu.js.map +1 -1
- package/dist/ui/select-item.d.ts +28 -0
- package/dist/ui/select-item.d.ts.map +1 -0
- package/dist/ui/select-item.js +35 -0
- package/dist/ui/select-item.js.map +1 -0
- package/dist/ui/workflow-card.d.ts +176 -0
- package/dist/ui/workflow-card.d.ts.map +1 -0
- package/dist/ui/workflow-card.js +333 -0
- package/dist/ui/workflow-card.js.map +1 -0
- package/dist/ui/workflow-dialog.d.ts +306 -0
- package/dist/ui/workflow-dialog.d.ts.map +1 -0
- package/dist/ui/workflow-dialog.js +844 -0
- package/dist/ui/workflow-dialog.js.map +1 -0
- package/dist/ui/workflow-menu.d.ts +61 -0
- package/dist/ui/workflow-menu.d.ts.map +1 -0
- package/dist/ui/workflow-menu.js +148 -0
- package/dist/ui/workflow-menu.js.map +1 -0
- package/dist/usage.d.ts +86 -1
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +72 -1
- package/dist/usage.js.map +1 -1
- package/dist/workflow/collisions.d.ts +96 -0
- package/dist/workflow/collisions.d.ts.map +1 -0
- package/dist/workflow/collisions.js +89 -0
- package/dist/workflow/collisions.js.map +1 -0
- package/dist/workflow/entry.d.ts +33 -0
- package/dist/workflow/entry.d.ts.map +1 -0
- package/dist/workflow/entry.js +30 -0
- package/dist/workflow/entry.js.map +1 -0
- package/dist/workflow/host.d.ts +63 -0
- package/dist/workflow/host.d.ts.map +1 -0
- package/dist/workflow/host.js +363 -0
- package/dist/workflow/host.js.map +1 -0
- package/dist/workflow/journal.d.ts +98 -0
- package/dist/workflow/journal.d.ts.map +1 -0
- package/dist/workflow/journal.js +121 -0
- package/dist/workflow/journal.js.map +1 -0
- package/dist/workflow/json-schema.d.ts +52 -0
- package/dist/workflow/json-schema.d.ts.map +1 -0
- package/dist/workflow/json-schema.js +112 -0
- package/dist/workflow/json-schema.js.map +1 -0
- package/dist/workflow/meta.d.ts +68 -0
- package/dist/workflow/meta.d.ts.map +1 -0
- package/dist/workflow/meta.js +318 -0
- package/dist/workflow/meta.js.map +1 -0
- package/dist/workflow/progress.d.ts +225 -0
- package/dist/workflow/progress.d.ts.map +1 -0
- package/dist/workflow/progress.js +362 -0
- package/dist/workflow/progress.js.map +1 -0
- package/dist/workflow/runtime.d.ts +335 -0
- package/dist/workflow/runtime.d.ts.map +1 -0
- package/dist/workflow/runtime.js +831 -0
- package/dist/workflow/runtime.js.map +1 -0
- package/dist/workflow/saved.d.ts +91 -0
- package/dist/workflow/saved.d.ts.map +1 -0
- package/dist/workflow/saved.js +204 -0
- package/dist/workflow/saved.js.map +1 -0
- package/dist/workflow/task.d.ts +137 -0
- package/dist/workflow/task.d.ts.map +1 -0
- package/dist/workflow/task.js +208 -0
- package/dist/workflow/task.js.map +1 -0
- package/dist/workflow/tool-description.d.ts +39 -0
- package/dist/workflow/tool-description.d.ts.map +1 -0
- package/dist/workflow/tool-description.js +200 -0
- package/dist/workflow/tool-description.js.map +1 -0
- package/dist/workflow/worker-source.d.ts +48 -0
- package/dist/workflow/worker-source.d.ts.map +1 -0
- package/dist/workflow/worker-source.js +779 -0
- package/dist/workflow/worker-source.js.map +1 -0
- package/dist/worktree.d.ts +10 -3
- package/dist/worktree.d.ts.map +1 -1
- package/dist/worktree.js +58 -54
- package/dist/worktree.js.map +1 -1
- package/dist/xml.d.ts +11 -0
- package/dist/xml.d.ts.map +1 -0
- package/dist/xml.js +13 -0
- package/dist/xml.js.map +1 -0
- package/docs/rpc.md +183 -0
- package/docs/superpowers/plans/2026-09-30-conversation-viewer-scrollbar.md +216 -0
- package/docs/superpowers/plans/2026-09-30-upstream-event-workflow-partial-history.md +195 -0
- package/docs/superpowers/specs/2026-09-30-conversation-viewer-scrollbar-design.md +82 -0
- package/docs/superpowers/specs/2026-09-30-upstream-event-workflow-partial-history-design.md +49 -0
- package/docs/workflows.md +437 -0
- package/examples/agent-tool-description.md +7 -7
- package/examples/workflows/compose.js +51 -0
- package/examples/workflows/fan-out-audit.js +47 -0
- package/examples/workflows/gated-fix.js +60 -0
- package/examples/workflows/lib/count-child.js +27 -0
- package/examples/workflows/review-panel.js +63 -0
- package/examples/workflows/structured-findings.js +78 -0
- package/package.json +1 -1
- package/src/abortable.ts +43 -0
- package/src/agent-color.ts +161 -0
- package/src/agent-file-toggle.ts +269 -0
- package/src/agent-history.ts +54 -2
- package/src/agent-manager.ts +1263 -402
- package/src/agent-runner.ts +251 -27
- package/src/agent-types.ts +188 -32
- package/src/child-context.ts +15 -0
- package/src/cross-extension-rpc.ts +96 -20
- package/src/custom-agents.ts +170 -13
- package/src/index.ts +2024 -537
- package/src/invocation-config.ts +118 -3
- package/src/mention-clone.ts +196 -0
- package/src/mention.ts +141 -0
- package/src/model-resolver.ts +18 -0
- package/src/model-scope.ts +70 -0
- package/src/nested-tools.ts +424 -0
- package/src/output-file.ts +61 -6
- package/src/prompts.ts +45 -2
- package/src/schedule.ts +35 -14
- package/src/settings.ts +312 -2
- package/src/status-note.ts +66 -1
- package/src/structured-output.ts +130 -0
- package/src/types.ts +177 -10
- package/src/ui/agent-mention.ts +216 -0
- package/src/ui/agent-widget.ts +389 -441
- package/src/ui/conversation-blocks.ts +6 -0
- package/src/ui/conversation-timeline.ts +139 -25
- package/src/ui/conversation-viewer.ts +284 -69
- package/src/ui/fleet-list.ts +558 -0
- package/src/ui/schedule-menu.ts +9 -8
- package/src/ui/select-item.ts +45 -0
- package/src/ui/workflow-card.ts +470 -0
- package/src/ui/workflow-dialog.ts +1115 -0
- package/src/ui/workflow-menu.ts +193 -0
- package/src/usage.ts +109 -2
- package/src/workflow/collisions.ts +123 -0
- package/src/workflow/entry.ts +47 -0
- package/src/workflow/host.ts +403 -0
- package/src/workflow/journal.ts +164 -0
- package/src/workflow/json-schema.ts +128 -0
- package/src/workflow/meta.ts +325 -0
- package/src/workflow/progress.ts +550 -0
- package/src/workflow/runtime.ts +1219 -0
- package/src/workflow/saved.ts +217 -0
- package/src/workflow/task.ts +302 -0
- package/src/workflow/tool-description.ts +200 -0
- package/src/workflow/worker-source.ts +781 -0
- package/src/worktree.ts +69 -55
- package/src/xml.ts +13 -0
- package/vitest.config.ts +0 -18
package/dist/agent-manager.js
CHANGED
|
@@ -1,108 +1,57 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* agent-manager.ts — Tracks agents, background execution, resume support.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* There are two independent concurrency pools, never one:
|
|
5
|
+
*
|
|
6
|
+
* - Background (`maxConcurrent`, default 10) bounds detached agents.
|
|
7
|
+
* - Foreground (`maxConcurrentForeground`, default 0 = unlimited) bounds
|
|
8
|
+
* agents a caller is blocking on inline — `spawnAndWait`.
|
|
9
|
+
*
|
|
10
|
+
* Independent by design: a foreground agent blocks the parent anyway, so
|
|
11
|
+
* charging it to the background pool would let a saturated pool starve the main
|
|
12
|
+
* session of work it could have done itself. Excess agents in either pool are
|
|
13
|
+
* queued and auto-started as slots free up. Nested children take no slot in
|
|
14
|
+
* either — see `occupiesPoolSlot` / `occupiesForegroundSlot`.
|
|
7
15
|
*/
|
|
8
16
|
import { randomUUID } from "node:crypto";
|
|
9
17
|
import { statSync } from "node:fs";
|
|
10
18
|
import { isAbsolute } from "node:path";
|
|
11
|
-
import { readAgentHistory } from "./agent-history.js";
|
|
12
|
-
import { readAgentRecoveryCheckpoints,
|
|
19
|
+
import { agentHistoryLocator, createAgentHistoryPath, readAgentHistory, streamAgentHistory, writeAgentHistoryInitialEntry, } from "./agent-history.js";
|
|
20
|
+
import { readAgentRecoveryCheckpoints, writeAgentRecoveryCheckpoint, } from "./agent-recovery.js";
|
|
13
21
|
import { resumeAgent, runAgent } from "./agent-runner.js";
|
|
22
|
+
import { assignHandle, handleBase } from "./mention.js";
|
|
23
|
+
import { describeModel } from "./model-resolver.js";
|
|
24
|
+
import { writeInitialEntry } from "./output-file.js";
|
|
14
25
|
import { addUsage } from "./usage.js";
|
|
15
|
-
import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (!value || typeof value !== "object")
|
|
45
|
-
return false;
|
|
46
|
-
const invocation = value;
|
|
47
|
-
for (const key of ["modelName", "effectiveModelName"]) {
|
|
48
|
-
if (invocation[key] !== undefined && !isSafePersistedString(invocation[key], 512))
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
for (const key of ["thinking", "effectiveThinking"]) {
|
|
52
|
-
if (invocation[key] !== undefined && (typeof invocation[key] !== "string" || !THINKING_LEVELS.has(invocation[key])))
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
if (invocation.maxTurns !== undefined && (!Number.isInteger(invocation.maxTurns) || invocation.maxTurns < 0))
|
|
56
|
-
return false;
|
|
57
|
-
for (const key of ["isolated", "inheritContext", "runInBackground"]) {
|
|
58
|
-
if (invocation[key] !== undefined && typeof invocation[key] !== "boolean")
|
|
59
|
-
return false;
|
|
60
|
-
}
|
|
61
|
-
if (invocation.isolation !== undefined && invocation.isolation !== "worktree")
|
|
62
|
-
return false;
|
|
63
|
-
return true;
|
|
64
|
-
}
|
|
65
|
-
function cloneInvocation(value) {
|
|
66
|
-
return value ? {
|
|
67
|
-
modelName: value.modelName,
|
|
68
|
-
effectiveModelName: value.effectiveModelName,
|
|
69
|
-
thinking: value.thinking,
|
|
70
|
-
effectiveThinking: value.effectiveThinking,
|
|
71
|
-
maxTurns: value.maxTurns,
|
|
72
|
-
isolated: value.isolated,
|
|
73
|
-
inheritContext: value.inheritContext,
|
|
74
|
-
runInBackground: value.runInBackground,
|
|
75
|
-
isolation: value.isolation,
|
|
76
|
-
} : undefined;
|
|
77
|
-
}
|
|
78
|
-
/** Validate one persisted terminal record without constructing runtime handles. */
|
|
79
|
-
export function isRestorableAgentRecord(value) {
|
|
80
|
-
if (!value || typeof value !== "object")
|
|
81
|
-
return false;
|
|
82
|
-
const record = value;
|
|
83
|
-
if (!isSafePersistedString(record.id, 256)
|
|
84
|
-
|| !isSafePersistedString(record.type, 256)
|
|
85
|
-
|| !isSafePersistedString(record.description, 4096)
|
|
86
|
-
|| typeof record.status !== "string"
|
|
87
|
-
|| !RESTORABLE_STATUSES.has(record.status)
|
|
88
|
-
|| !isFiniteTimestamp(record.startedAt)
|
|
89
|
-
|| !isFiniteTimestamp(record.completedAt)
|
|
90
|
-
|| record.completedAt < record.startedAt)
|
|
91
|
-
return false;
|
|
92
|
-
if (record.result !== undefined && !isSafePersistedString(record.result, 2_000_000))
|
|
93
|
-
return false;
|
|
94
|
-
if (record.error !== undefined && !isSafePersistedString(record.error, 64_000))
|
|
95
|
-
return false;
|
|
96
|
-
if (record.toolUses !== undefined && (!Number.isInteger(record.toolUses) || record.toolUses < 0))
|
|
97
|
-
return false;
|
|
98
|
-
if (record.lifetimeUsage !== undefined && !isValidUsage(record.lifetimeUsage))
|
|
99
|
-
return false;
|
|
100
|
-
if (record.transcriptPath !== undefined && !isSafeTranscriptLocator(record.transcriptPath))
|
|
101
|
-
return false;
|
|
102
|
-
if (record.invocation !== undefined && !isSafeInvocation(record.invocation))
|
|
103
|
-
return false;
|
|
104
|
-
return true;
|
|
105
|
-
}
|
|
26
|
+
import { cleanupWorktree, createWorktree, isWorktreeIsolationEnabled, pruneWorktrees, } from "./worktree.js";
|
|
27
|
+
/**
|
|
28
|
+
* Default max concurrent background agents.
|
|
29
|
+
*
|
|
30
|
+
* Raised from 4 when top-level spawns started defaulting to background
|
|
31
|
+
* (`backgroundByDefault`): foreground agents bypass this pool entirely, so
|
|
32
|
+
* while foreground was the default a fan-out of six ran six. With background
|
|
33
|
+
* as the default every top-level agent takes a slot, and a limit of 4 would
|
|
34
|
+
* have silently queued the tail of exactly the parallel fan-outs the `Agent`
|
|
35
|
+
* tool description tells the model to send.
|
|
36
|
+
*/
|
|
37
|
+
const DEFAULT_MAX_CONCURRENT = 10;
|
|
38
|
+
/**
|
|
39
|
+
* Default max concurrent foreground (blocking) agents — `0` = unlimited, the
|
|
40
|
+
* extension's existing convention for "no ceiling" (`defaultMaxTurns`).
|
|
41
|
+
*
|
|
42
|
+
* Off by default because nothing here ever bounded foreground work, and pi
|
|
43
|
+
* dispatches a message's tool calls through `Promise.all`, so an unqualified
|
|
44
|
+
* fan-out of blocking `Agent` calls has always run all at once. Users who want
|
|
45
|
+
* it bounded — chiefly local models, where parallel agents thrash the prompt
|
|
46
|
+
* cache (#253) — opt in; everyone else keeps today's behaviour exactly.
|
|
47
|
+
*/
|
|
48
|
+
const DEFAULT_MAX_CONCURRENT_FOREGROUND = 0;
|
|
49
|
+
/**
|
|
50
|
+
* How many evicted agents stay addressable by name. Only a bound on memory —
|
|
51
|
+
* a session that spawns hundreds of agents shouldn't retain every one — and
|
|
52
|
+
* far above the handful anyone keeps in their head.
|
|
53
|
+
*/
|
|
54
|
+
const MAX_TOMBSTONES = 100;
|
|
106
55
|
/**
|
|
107
56
|
* Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
|
|
108
57
|
* (parent cwd). Anything else must be an absolute path to an existing
|
|
@@ -126,28 +75,145 @@ function assertValidSpawnCwd(cwd) {
|
|
|
126
75
|
throw new Error(`SpawnOptions.cwd is not a directory: "${cwd}"`);
|
|
127
76
|
}
|
|
128
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Whether a record occupies one of the `maxConcurrent` background slots.
|
|
80
|
+
* Nested children don't: their parent already holds a slot, so counting (and
|
|
81
|
+
* therefore queueing) them would deadlock a parent that waits on its own child.
|
|
82
|
+
*
|
|
83
|
+
* Note this bounds nothing horizontally — the depth cap limits how DEEP nesting
|
|
84
|
+
* goes, not how WIDE. A parent's only limit on concurrent children is that each
|
|
85
|
+
* spawn costs it a turn, which is unbounded when max turns is unlimited.
|
|
86
|
+
*/
|
|
87
|
+
function occupiesPoolSlot(record) {
|
|
88
|
+
return !!record.isBackground && isTopLevelAgent(record);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Whether a record is one of the session's own agents, rather than something
|
|
92
|
+
* another agent or a workflow owns.
|
|
93
|
+
*
|
|
94
|
+
* The single definition behind every user-facing surface — the fleet list, the
|
|
95
|
+
* widget, the `/agents` menus, `@handle` resolution, and the completion events
|
|
96
|
+
* and session entries. An owned child reports through its owner, so surfacing
|
|
97
|
+
* it separately would double-count the same work in the places a person reads.
|
|
98
|
+
*/
|
|
99
|
+
export function isTopLevelAgent(record) {
|
|
100
|
+
return record.parentAgentId === undefined && record.workflowId === undefined;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Whether a record occupies one of the `maxConcurrentForeground` slots.
|
|
104
|
+
*
|
|
105
|
+
* Keyed on `blocking` — a caller awaiting this record inline — rather than on
|
|
106
|
+
* `isBackground === false`, because `spawn()` is also the funnel for DETACHED
|
|
107
|
+
* starts (cross-extension RPC, `@handle` mentions, the registry) that may pass
|
|
108
|
+
* `isBackground: false` and are documented to run immediately regardless. Those
|
|
109
|
+
* block nobody, so bounding them buys nothing and would park a record with no
|
|
110
|
+
* one waiting to release it.
|
|
111
|
+
*
|
|
112
|
+
* Nested children are excluded for the same reason as `occupiesPoolSlot`, and
|
|
113
|
+
* more sharply: their parent is blocked *awaiting them*, so queueing a child
|
|
114
|
+
* behind its own parent is a guaranteed deadlock rather than a possible one.
|
|
115
|
+
* Enforced here rather than at the call site so no caller can reintroduce it.
|
|
116
|
+
*
|
|
117
|
+
* A workflow's children go out through `spawnAndWait` and so are `blocking`
|
|
118
|
+
* too, and are excluded on the same `isTopLevelAgent` test as the background
|
|
119
|
+
* pool: the run already caps how many of its agents run at once, and charging
|
|
120
|
+
* them here as well would let one fan-out queue behind a limit meant for the
|
|
121
|
+
* session's own work.
|
|
122
|
+
*
|
|
123
|
+
* Like the background pool this bounds width at the top level only — a parent's
|
|
124
|
+
* own fan-out is limited by nothing but its turn budget.
|
|
125
|
+
*/
|
|
126
|
+
function occupiesForegroundSlot(record) {
|
|
127
|
+
return !!record.blocking && isTopLevelAgent(record);
|
|
128
|
+
}
|
|
129
|
+
/** Best-effort ceiling on one child's shutdown handlers, so teardown can't strand a quit. */
|
|
130
|
+
const CHILD_SHUTDOWN_TIMEOUT_MS = 3_000;
|
|
131
|
+
/**
|
|
132
|
+
* Close the extension lifecycle `runAgent` opened with `bindExtensions`, then dispose.
|
|
133
|
+
*
|
|
134
|
+
* `AgentSession.dispose()` only calls `ExtensionRunner.invalidate()` — pi emits the event
|
|
135
|
+
* itself in `AgentSessionRuntime.dispose()` beforehand, and this is the one place that binds
|
|
136
|
+
* extensions onto a session without going through that path. Without the emit, everything an
|
|
137
|
+
* extension armed in `session_start` leaks once per spawn, and its next tick throws
|
|
138
|
+
* `assertActive()` from a bare timer callback — an uncaughtException that kills pi (#242).
|
|
139
|
+
*/
|
|
140
|
+
async function shutdownChildSession(session) {
|
|
141
|
+
try {
|
|
142
|
+
const runner = session?.extensionRunner;
|
|
143
|
+
// Optional all the way down: on a pi without the getter, or a stubbed session from a
|
|
144
|
+
// partial `onSessionCreated`, skip the emit — the same degrade as before this fix.
|
|
145
|
+
if (runner?.hasHandlers?.("session_shutdown")) {
|
|
146
|
+
// Raced, not awaited outright. `emit` runs every handler serially with no timeout of
|
|
147
|
+
// its own, and dispose() is reached from pi's own `session_shutdown` with the TUI
|
|
148
|
+
// already torn down — one hung handler would leave a dead terminal.
|
|
149
|
+
await Promise.race([
|
|
150
|
+
runner.emit({ type: "session_shutdown", reason: "quit" }),
|
|
151
|
+
new Promise(resolve => setTimeout(resolve, CHILD_SHUTDOWN_TIMEOUT_MS).unref()),
|
|
152
|
+
]);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch { /* a partial session must degrade, not take the teardown down with it */ }
|
|
156
|
+
// Always, even on timeout: disposal is what this function ultimately exists to do.
|
|
157
|
+
try {
|
|
158
|
+
session?.dispose?.();
|
|
159
|
+
}
|
|
160
|
+
catch { /* ignore */ }
|
|
161
|
+
}
|
|
129
162
|
export class AgentManager {
|
|
130
163
|
agents = new Map();
|
|
131
164
|
cleanupInterval;
|
|
132
165
|
onComplete;
|
|
133
166
|
onStart;
|
|
134
167
|
onCompact;
|
|
168
|
+
onUsage;
|
|
135
169
|
maxConcurrent;
|
|
170
|
+
maxConcurrentForeground = DEFAULT_MAX_CONCURRENT_FOREGROUND;
|
|
136
171
|
/** Base repos worktrees were created from — so dispose() can prune them all,
|
|
137
172
|
* not just the parent repo (caller-supplied cwd can target other repos). */
|
|
138
173
|
worktreeRepos = new Set();
|
|
139
174
|
/** Project cwd for each record's durable checkpoint. */
|
|
140
175
|
recoveryCwds = new Map();
|
|
141
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* Startup phases, keyed by agent id. `spawn()` still returns synchronously,
|
|
178
|
+
* but an agent using worktree isolation is not running yet when it does —
|
|
179
|
+
* copying the repo is an awaited git call. This is what `awaitStartup` hands
|
|
180
|
+
* callers that must fail their tool call on a startup failure, and what
|
|
181
|
+
* `waitForAll` waits on while a record is "running" with no `promise` yet.
|
|
182
|
+
* Entries are dropped once the run is underway, and kept (rejected) after a
|
|
183
|
+
* startup failure so a late `awaitStartup` still sees it.
|
|
184
|
+
*/
|
|
185
|
+
startups = new Map();
|
|
186
|
+
/**
|
|
187
|
+
* Evicted agents that can still be reached by name, keyed by handle. Outlives
|
|
188
|
+
* the 10-minute record cleanup — that timer exists to bound memory, not to
|
|
189
|
+
* expire a conversation the user might still want — and is cleared alongside
|
|
190
|
+
* completed records on session start/switch.
|
|
191
|
+
*/
|
|
192
|
+
tombstones = new Map();
|
|
193
|
+
/**
|
|
194
|
+
* Agents waiting to start, tagged with the pool they wait on. One queue for
|
|
195
|
+
* both pools: `drainQueue` picks the earliest entry whose own pool has room,
|
|
196
|
+
* so neither can head-of-line-block the other, and every removal path
|
|
197
|
+
* (`abort`, `abortAll`, `dispose`) stays a single filter.
|
|
198
|
+
*
|
|
199
|
+
* `release` wakes a caller blocked in `spawnAndWait`, and is fired once the
|
|
200
|
+
* entry's `start` has SETTLED rather than at drain time: startup is async
|
|
201
|
+
* now, so releasing earlier would wake the caller before `record.promise`
|
|
202
|
+
* exists and it would read a still-starting agent as one that never ran.
|
|
203
|
+
* Removing an entry from this array MUST release it — a queued record has no
|
|
204
|
+
* promise to await, and pi has no tool-execution timeout to bail the caller
|
|
205
|
+
* out.
|
|
206
|
+
*/
|
|
142
207
|
queue = [];
|
|
143
208
|
/** Number of currently running background agents. */
|
|
144
209
|
runningBackground = 0;
|
|
145
|
-
/**
|
|
146
|
-
|
|
147
|
-
constructor(onComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT, onStart, onCompact) {
|
|
210
|
+
/** Number of currently running foreground (blocking) agents. */
|
|
211
|
+
runningForeground = 0;
|
|
212
|
+
constructor(onComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT, onStart, onCompact, onUsage) {
|
|
148
213
|
this.onComplete = onComplete;
|
|
149
214
|
this.onStart = onStart;
|
|
150
215
|
this.onCompact = onCompact;
|
|
216
|
+
this.onUsage = onUsage;
|
|
151
217
|
this.maxConcurrent = maxConcurrent;
|
|
152
218
|
// Cleanup completed agents after 10 minutes (but keep sessions for resume)
|
|
153
219
|
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
|
|
@@ -162,51 +228,192 @@ export class AgentManager {
|
|
|
162
228
|
getMaxConcurrent() {
|
|
163
229
|
return this.maxConcurrent;
|
|
164
230
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
this.
|
|
231
|
+
/** Update the max concurrent foreground (blocking) agents limit. 0 = unlimited. */
|
|
232
|
+
setMaxConcurrentForeground(n) {
|
|
233
|
+
// Floor 0, not 1: unlimited is a meaningful value here and the default.
|
|
234
|
+
this.maxConcurrentForeground = Math.max(0, n);
|
|
235
|
+
// Start queued agents if the new limit allows — including everything, when
|
|
236
|
+
// the limit is cleared back to unlimited mid-run.
|
|
237
|
+
this.drainQueue();
|
|
238
|
+
}
|
|
239
|
+
getMaxConcurrentForeground() {
|
|
240
|
+
return this.maxConcurrentForeground;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Which pool a spawn is charged to, or undefined for one that is charged to
|
|
244
|
+
* neither (nested children, detached non-background spawns).
|
|
245
|
+
*
|
|
246
|
+
* Nothing here queues when the limit is unset — `poolHasRoom` reports an
|
|
247
|
+
* unlimited pool as always having room, so that alone is what keeps the
|
|
248
|
+
* default path identical. The `> 0` guard is belt and braces on top: it also
|
|
249
|
+
* keeps the counter from churning and the settle path from calling a drain
|
|
250
|
+
* that would find nothing to do. Both are unobservable, which is why no test
|
|
251
|
+
* pins them; the observable half — that the default start stays synchronous —
|
|
252
|
+
* is pinned in `test/foreground-concurrency.test.ts`.
|
|
253
|
+
*/
|
|
254
|
+
poolFor(record) {
|
|
255
|
+
if (occupiesPoolSlot(record))
|
|
256
|
+
return "background";
|
|
257
|
+
if (this.maxConcurrentForeground > 0 && occupiesForegroundSlot(record))
|
|
258
|
+
return "foreground";
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
poolHasRoom(pool) {
|
|
262
|
+
return pool === "background"
|
|
263
|
+
? this.runningBackground < this.maxConcurrent
|
|
264
|
+
: this.maxConcurrentForeground === 0 || this.runningForeground < this.maxConcurrentForeground;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Spawn an agent and return its ID immediately (for background use).
|
|
268
|
+
* If the concurrency limit is reached, the agent is queued.
|
|
269
|
+
*
|
|
270
|
+
* The id comes back synchronously, but with `isolation: "worktree"` the agent
|
|
271
|
+
* is not running yet when it does — the repo copy is an awaited git call.
|
|
272
|
+
* Callers that must fail a tool call on a startup failure await
|
|
273
|
+
* `awaitStartup(id)`; everyone else sees it on the record (status "error").
|
|
274
|
+
*/
|
|
275
|
+
spawn(pi, ctx, type, prompt, options) {
|
|
276
|
+
// Validate before the queue branch — a queued spawn should fail at the
|
|
277
|
+
// call, not minutes later at drain. Throw (not warn): programmatic callers
|
|
278
|
+
// can fix and retry; the RPC layer converts throws into error envelopes.
|
|
279
|
+
assertValidSpawnCwd(options.cwd);
|
|
280
|
+
const id = randomUUID().slice(0, 17);
|
|
281
|
+
const abortController = new AbortController();
|
|
282
|
+
const record = {
|
|
283
|
+
id,
|
|
284
|
+
type,
|
|
285
|
+
// Owned children — nested, or a workflow's — are filtered out of every
|
|
286
|
+
// top-level surface, so no handle: nothing can address them and they must
|
|
287
|
+
// not consume a name a top-level sibling could otherwise take.
|
|
288
|
+
handle: !isTopLevelAgent(options)
|
|
289
|
+
? undefined
|
|
290
|
+
// A reclaimed handle is used as-is: it belongs to the conversation this
|
|
291
|
+
// spawn is reopening, and re-deriving it would lose the numbering.
|
|
292
|
+
: options.reclaim?.handle ?? assignHandle(handleBase(type), this.takenHandles()),
|
|
293
|
+
description: options.description,
|
|
294
|
+
// Reclaimed here, or filled in below from `name` — in which case it must
|
|
295
|
+
// see the handle this record just took, since both come out of the same
|
|
296
|
+
// namespace.
|
|
297
|
+
alias: isTopLevelAgent(options) ? options.reclaim?.alias : undefined,
|
|
298
|
+
// Overwritten below when the spawn is actually queued; a foreground spawn
|
|
299
|
+
// that queues flips to "queued" there rather than being guessed at here,
|
|
300
|
+
// since the pool decision needs the finished record.
|
|
301
|
+
status: options.isBackground ? "queued" : "running",
|
|
302
|
+
toolUses: 0,
|
|
303
|
+
startedAt: Date.now(),
|
|
304
|
+
abortController,
|
|
305
|
+
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0, cost: 0 },
|
|
306
|
+
compactionCount: 0,
|
|
307
|
+
// Raw tri-state (not coerced to a boolean): true = background, false =
|
|
308
|
+
// foreground (has an inline tool-result surface), undefined = caller never
|
|
309
|
+
// declared it (e.g. a cross-extension RPC spawn). The widget's background-
|
|
310
|
+
// only filter excludes only explicit `false`, so undefined agents — which
|
|
311
|
+
// have no inline surface — stay visible instead of vanishing.
|
|
312
|
+
isBackground: options.isBackground,
|
|
313
|
+
// Whether anyone is awaiting this agent is a property of the agent, not
|
|
314
|
+
// of the call that made it — and both settle paths need it long after
|
|
315
|
+
// `options` has stopped being the interesting object.
|
|
316
|
+
blocking: options.blocking,
|
|
317
|
+
invocation: options.invocation,
|
|
318
|
+
depth: options.depth ?? 1,
|
|
319
|
+
parentAgentId: options.parentAgentId,
|
|
320
|
+
workflowId: options.workflowId,
|
|
321
|
+
maxSubagentDepth: options.maxSubagentDepth,
|
|
322
|
+
rootSessionId: options.rootSessionId,
|
|
323
|
+
};
|
|
324
|
+
this.agents.set(id, record);
|
|
325
|
+
this.recoveryCwds.set(id, ctx.cwd);
|
|
326
|
+
// Durable history is manager-owned so every spawn path (Agent, scheduler,
|
|
327
|
+
// RPC, mention, and Workflow) has the same recoverable seam. Attach before
|
|
328
|
+
// any caller callback can start wiring output or observe the id.
|
|
329
|
+
this.attachDurableTranscript(record, id, prompt, ctx.cwd, options.outputTranscript !== false);
|
|
330
|
+
// After the insert, so `takenHandles()` already counts this record's own
|
|
331
|
+
// handle — a spawn named after its own type gets `explore-2`, not a
|
|
332
|
+
// duplicate `explore` that would make resolution ambiguous.
|
|
333
|
+
if (record.handle !== undefined && record.alias === undefined && options.name !== undefined) {
|
|
334
|
+
record.alias = assignHandle(handleBase(options.name), this.takenHandles());
|
|
335
|
+
}
|
|
336
|
+
const args = { pi, ctx, type, prompt, options };
|
|
337
|
+
const pool = this.poolFor(record);
|
|
338
|
+
if (pool !== undefined && !options.bypassQueue && !this.poolHasRoom(pool)) {
|
|
339
|
+
// Queue it — started when a running agent in the same pool completes.
|
|
340
|
+
// Idempotent for background (already "queued"); the flip that matters is
|
|
341
|
+
// a blocking foreground spawn, optimistically marked "running" above.
|
|
342
|
+
record.status = "queued";
|
|
343
|
+
// A queued record never reaches startAgent's signal wiring, so arm the
|
|
344
|
+
// parent abort here or Esc could not release the position.
|
|
345
|
+
if (!this.armQueuedAbort(id, options.signal))
|
|
346
|
+
return id;
|
|
347
|
+
let release;
|
|
348
|
+
record.startGate = new Promise(resolve => { release = resolve; });
|
|
349
|
+
this.queue.push({
|
|
350
|
+
id,
|
|
351
|
+
pool,
|
|
352
|
+
start: () => this.launch(id, record, args, pool),
|
|
353
|
+
release: () => release(),
|
|
354
|
+
});
|
|
355
|
+
options.onQueued?.(id, this.queue.filter(e => e.pool === pool).length - 1);
|
|
356
|
+
return id;
|
|
357
|
+
}
|
|
358
|
+
this.launch(id, record, args, undefined);
|
|
359
|
+
return id;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Attach the project-local transcript once for a newly-created record.
|
|
363
|
+
* Repeated calls are harmless: deterministic paths and an existing file keep
|
|
364
|
+
* the initial user entry intact, which is important for resume and retries.
|
|
365
|
+
*/
|
|
366
|
+
attachDurableTranscript(record, id, prompt, cwd, outputTranscript) {
|
|
367
|
+
try {
|
|
368
|
+
const historyFile = createAgentHistoryPath(cwd, id);
|
|
369
|
+
writeAgentHistoryInitialEntry(historyFile, id, prompt, cwd);
|
|
370
|
+
if (outputTranscript)
|
|
371
|
+
writeInitialEntry(historyFile, id, prompt, cwd);
|
|
372
|
+
record.historyFile = historyFile;
|
|
373
|
+
record.transcriptPath = agentHistoryLocator(cwd, historyFile);
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
// A read-only project must not prevent the agent from running. The
|
|
377
|
+
// checkpoint still records the spawn metadata and the warning makes the
|
|
378
|
+
// loss of durable history visible to the host.
|
|
379
|
+
console.warn(`[pi-subagents] failed to attach durable transcript for ${id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
380
|
+
}
|
|
381
|
+
this.checkpoint(record);
|
|
169
382
|
}
|
|
170
383
|
checkpointStatus(record) {
|
|
171
384
|
return record.status;
|
|
172
385
|
}
|
|
173
386
|
makeCheckpoint(record) {
|
|
174
|
-
|
|
387
|
+
return {
|
|
175
388
|
version: 1,
|
|
176
389
|
id: record.id,
|
|
177
390
|
type: record.type,
|
|
178
391
|
description: record.description,
|
|
179
392
|
status: this.checkpointStatus(record),
|
|
180
393
|
startedAt: record.startedAt,
|
|
181
|
-
toolUses: record.toolUses,
|
|
182
|
-
lifetimeUsage: { ...record.lifetimeUsage },
|
|
183
|
-
compactionCount: record.compactionCount,
|
|
184
394
|
...(record.completedAt !== undefined && { completedAt: record.completedAt }),
|
|
185
|
-
// A durable transcript is the source of truth for partial/full output.
|
|
186
|
-
// Avoid duplicating potentially sensitive or very large result text.
|
|
187
395
|
...(!record.transcriptPath && record.result !== undefined && { result: record.result }),
|
|
188
396
|
...(record.error !== undefined && { error: record.error }),
|
|
397
|
+
toolUses: record.toolUses,
|
|
398
|
+
lifetimeUsage: { ...record.lifetimeUsage },
|
|
399
|
+
compactionCount: record.compactionCount,
|
|
189
400
|
...(record.transcriptPath !== undefined && { transcriptPath: record.transcriptPath }),
|
|
190
|
-
...(record.invocation !== undefined && { invocation:
|
|
401
|
+
...(record.invocation !== undefined && { invocation: { ...record.invocation } }),
|
|
191
402
|
};
|
|
192
|
-
return checkpoint;
|
|
193
403
|
}
|
|
194
404
|
checkpoint(record) {
|
|
195
405
|
const cwd = this.recoveryCwds.get(record.id);
|
|
196
|
-
if (
|
|
197
|
-
|
|
198
|
-
writeAgentRecoveryCheckpoint(cwd, this.makeCheckpoint(record));
|
|
406
|
+
if (cwd)
|
|
407
|
+
writeAgentRecoveryCheckpoint(cwd, this.makeCheckpoint(record));
|
|
199
408
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
catch { /* recovery must remain best effort */ }
|
|
207
|
-
record.outputCleanup = undefined;
|
|
409
|
+
/** Checkpoint a record after external transcript wiring. */
|
|
410
|
+
checkpointRecord(id) {
|
|
411
|
+
const record = this.agents.get(id);
|
|
412
|
+
if (record)
|
|
413
|
+
this.checkpoint(record);
|
|
208
414
|
}
|
|
209
|
-
/**
|
|
415
|
+
/** Register durable transcript metadata for compatibility with callers that
|
|
416
|
+
* attach a pre-existing history (for example a restored session). */
|
|
210
417
|
setTranscript(id, historyFile, transcriptPath, cwd) {
|
|
211
418
|
const record = this.agents.get(id);
|
|
212
419
|
if (!record)
|
|
@@ -217,111 +424,171 @@ export class AgentManager {
|
|
|
217
424
|
this.recoveryCwds.set(id, cwd);
|
|
218
425
|
this.checkpoint(record);
|
|
219
426
|
}
|
|
220
|
-
/**
|
|
221
|
-
checkpointRecord(id) {
|
|
222
|
-
const record = this.agents.get(id);
|
|
223
|
-
if (record)
|
|
224
|
-
this.checkpoint(record);
|
|
225
|
-
}
|
|
226
|
-
/**
|
|
227
|
-
* Reload durable records from this project's checkpoint directory. A
|
|
228
|
-
* running/queued checkpoint means the process was killed before it could
|
|
229
|
-
* write its stopped state; treat it as stopped and retain its transcript.
|
|
230
|
-
* SIGKILL cannot run a final flush/checkpoint, so this active snapshot is
|
|
231
|
-
* necessarily the last recoverable state.
|
|
232
|
-
*/
|
|
427
|
+
/** Restore active checkpoints as stopped partial history after a restart. */
|
|
233
428
|
restoreRecovered(cwd) {
|
|
234
429
|
for (const checkpoint of readAgentRecoveryCheckpoints(cwd)) {
|
|
430
|
+
// A session_start can fire again in the same process (resume/switch).
|
|
431
|
+
// Never replace the live in-memory record with its older checkpoint: the
|
|
432
|
+
// checkpoint may intentionally omit `result` once durable history exists.
|
|
433
|
+
if (this.agents.has(checkpoint.id))
|
|
434
|
+
continue;
|
|
235
435
|
if (!checkpoint.transcriptPath || !readAgentHistory(cwd, checkpoint.transcriptPath))
|
|
236
436
|
continue;
|
|
237
437
|
const status = checkpoint.status === "running" || checkpoint.status === "queued"
|
|
238
|
-
? "stopped"
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
// Parent-branch records can still carry an unread in-memory result.
|
|
244
|
-
// Never replace that richer record with the checkpoint's transcript
|
|
245
|
-
// stub during the same session. Merge only durable locator metadata.
|
|
246
|
-
if (!existing.transcriptPath && checkpoint.transcriptPath) {
|
|
247
|
-
existing.transcriptPath = checkpoint.transcriptPath;
|
|
248
|
-
}
|
|
249
|
-
this.recoveryCwds.set(checkpoint.id, cwd);
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
|
-
this.agents.set(checkpoint.id, this.createRestoredRecord({
|
|
253
|
-
...checkpoint,
|
|
438
|
+
? "stopped" : checkpoint.status;
|
|
439
|
+
const record = {
|
|
440
|
+
id: checkpoint.id,
|
|
441
|
+
type: checkpoint.type,
|
|
442
|
+
description: checkpoint.description,
|
|
254
443
|
status,
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
444
|
+
result: checkpoint.result,
|
|
445
|
+
error: checkpoint.error,
|
|
446
|
+
toolUses: checkpoint.toolUses,
|
|
447
|
+
startedAt: checkpoint.startedAt,
|
|
448
|
+
completedAt: checkpoint.completedAt ?? Date.now(),
|
|
449
|
+
transcriptPath: checkpoint.transcriptPath,
|
|
450
|
+
historyFile: undefined,
|
|
451
|
+
invocation: checkpoint.invocation,
|
|
452
|
+
lifetimeUsage: { ...checkpoint.lifetimeUsage },
|
|
453
|
+
compactionCount: checkpoint.compactionCount,
|
|
454
|
+
};
|
|
455
|
+
this.agents.set(record.id, record);
|
|
456
|
+
this.recoveryCwds.set(record.id, cwd);
|
|
258
457
|
}
|
|
259
458
|
}
|
|
260
459
|
/**
|
|
261
|
-
*
|
|
262
|
-
*
|
|
460
|
+
* Restore terminal records from durable history without creating sessions.
|
|
461
|
+
* Invalid and live records are ignored so recovery cannot replace active work.
|
|
263
462
|
*/
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
id
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
startedAt
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
const args = { pi, ctx, type, prompt, options };
|
|
305
|
-
if (options.isBackground && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
|
|
306
|
-
// Queue it — will be started when a running agent completes
|
|
307
|
-
this.queue.push({ id, args });
|
|
308
|
-
return id;
|
|
463
|
+
restoreCompleted(records) {
|
|
464
|
+
const terminal = new Set(["completed", "steered", "stopped", "aborted", "error"]);
|
|
465
|
+
const restoredIds = new Set();
|
|
466
|
+
for (const candidate of records) {
|
|
467
|
+
const id = typeof candidate.id === "string" && candidate.id.length > 0 ? candidate.id : undefined;
|
|
468
|
+
const status = candidate.status;
|
|
469
|
+
if (!id || !status || !terminal.has(status))
|
|
470
|
+
continue;
|
|
471
|
+
if (this.agents.has(id) && !restoredIds.has(id))
|
|
472
|
+
continue;
|
|
473
|
+
const type = typeof candidate.type === "string" ? candidate.type : undefined;
|
|
474
|
+
const description = typeof candidate.description === "string" ? candidate.description : undefined;
|
|
475
|
+
const startedAt = candidate.startedAt;
|
|
476
|
+
if (!type || description === undefined || typeof startedAt !== "number" || !Number.isFinite(startedAt))
|
|
477
|
+
continue;
|
|
478
|
+
if (candidate.completedAt !== undefined && (typeof candidate.completedAt !== "number" || !Number.isFinite(candidate.completedAt)))
|
|
479
|
+
continue;
|
|
480
|
+
if (candidate.transcriptPath !== undefined && (typeof candidate.transcriptPath !== "string" || candidate.transcriptPath.includes("..") || candidate.transcriptPath.startsWith("/")))
|
|
481
|
+
continue;
|
|
482
|
+
const restored = {
|
|
483
|
+
...candidate,
|
|
484
|
+
id,
|
|
485
|
+
type,
|
|
486
|
+
description,
|
|
487
|
+
status,
|
|
488
|
+
toolUses: typeof candidate.toolUses === "number" && Number.isFinite(candidate.toolUses) ? candidate.toolUses : 0,
|
|
489
|
+
startedAt,
|
|
490
|
+
completedAt: candidate.completedAt ?? Date.now(),
|
|
491
|
+
lifetimeUsage: candidate.lifetimeUsage ? { ...candidate.lifetimeUsage } : { input: 0, output: 0, cacheWrite: 0 },
|
|
492
|
+
compactionCount: typeof candidate.compactionCount === "number" && Number.isFinite(candidate.compactionCount) ? candidate.compactionCount : 0,
|
|
493
|
+
session: undefined,
|
|
494
|
+
abortController: undefined,
|
|
495
|
+
promise: undefined,
|
|
496
|
+
startGate: undefined,
|
|
497
|
+
outputCleanup: undefined,
|
|
498
|
+
historyCleanup: undefined,
|
|
499
|
+
};
|
|
500
|
+
this.agents.set(id, restored);
|
|
501
|
+
restoredIds.add(id);
|
|
309
502
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Wire a parent abort signal for a record that is about to be QUEUED.
|
|
506
|
+
* `startAgent` does this for running agents, and a queued record never gets
|
|
507
|
+
* there, so without this Esc could not release a queue position.
|
|
508
|
+
*
|
|
509
|
+
* Returns false when the signal is ALREADY aborted, in which case the record
|
|
510
|
+
* is stopped here and must not be enqueued: `addEventListener` never fires on
|
|
511
|
+
* an aborted signal, so a `spawnAndWait` on it would wait forever — pi has no
|
|
512
|
+
* tool-execution timeout to bail it out.
|
|
513
|
+
*
|
|
514
|
+
* The listener is left in place when the agent starts. `startAgent` adds its
|
|
515
|
+
* own, so both fire on a later abort, but `abort()` on an already-stopped
|
|
516
|
+
* record is a no-op — so detaching would only be tidiness, and tidiness the
|
|
517
|
+
* `abortAll`/`dispose` paths could not offer anyway.
|
|
518
|
+
*/
|
|
519
|
+
armQueuedAbort(id, signal) {
|
|
520
|
+
if (signal === undefined)
|
|
521
|
+
return true;
|
|
522
|
+
if (signal.aborted) {
|
|
523
|
+
const record = this.agents.get(id);
|
|
524
|
+
if (record) {
|
|
525
|
+
record.status = "stopped";
|
|
526
|
+
record.completedAt = Date.now();
|
|
527
|
+
this.flushOutput(record);
|
|
528
|
+
this.checkpoint(record);
|
|
529
|
+
}
|
|
530
|
+
return false;
|
|
314
531
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
532
|
+
signal.addEventListener("abort", () => this.abort(id), { once: true });
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Kick off an agent's startup and register it under `startups`. The returned
|
|
537
|
+
* promise never rejects — the failure is delivered through `awaitStartup`,
|
|
538
|
+
* and to the record.
|
|
539
|
+
*
|
|
540
|
+
* @param queuedPool - The pool this start was QUEUED on, or undefined for an
|
|
541
|
+
* immediate start. A queue drain can be minutes after `spawn()` returned,
|
|
542
|
+
* and nobody is awaiting `awaitStartup` by then, so a failure has to live
|
|
543
|
+
* on the record as status "error" — what drainQueue did when the throw was
|
|
544
|
+
* still synchronous. An immediate start instead drops the record, exactly
|
|
545
|
+
* as the throw out of `spawn()` did: no orphan in `listAgents()`, and the
|
|
546
|
+
* handle goes back.
|
|
547
|
+
*/
|
|
548
|
+
launch(id, record, args, queuedPool) {
|
|
549
|
+
const startup = this.startAgent(id, record, args).then(() => { this.startups.delete(id); }, (err) => {
|
|
550
|
+
this.startups.delete(id);
|
|
551
|
+
if (queuedPool !== undefined) {
|
|
552
|
+
// Mirrors settleRun: an inline caller gets this failure as a throw
|
|
553
|
+
// out of spawnAndWait, so an unconsumed record would ALSO nudge the
|
|
554
|
+
// session about it — the same failure reported twice.
|
|
555
|
+
if (queuedPool === "foreground")
|
|
556
|
+
record.resultConsumed = true;
|
|
557
|
+
record.status = "error";
|
|
558
|
+
record.error = err instanceof Error ? err.message : String(err);
|
|
559
|
+
record.completedAt = Date.now();
|
|
560
|
+
this.flushOutput(record);
|
|
561
|
+
this.checkpoint(record);
|
|
562
|
+
this.onComplete?.(record);
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
this.agents.delete(id);
|
|
566
|
+
}
|
|
567
|
+
// The agent never kept its slot (startAgent gives it back on failure),
|
|
568
|
+
// so anything queued behind it can go now.
|
|
569
|
+
this.drainQueue();
|
|
319
570
|
throw err;
|
|
320
|
-
}
|
|
321
|
-
|
|
571
|
+
});
|
|
572
|
+
this.startups.set(id, startup);
|
|
573
|
+
// Nothing is obliged to await `startups` — swallow the rejection once here
|
|
574
|
+
// so an unawaited startup can't take the process down, and hand callers
|
|
575
|
+
// (drainQueue) that swallowed promise.
|
|
576
|
+
return startup.catch(() => { });
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Resolves once the agent is actually running, and rejects with the startup
|
|
580
|
+
* failure (strict worktree isolation) that `spawn()` used to throw before the
|
|
581
|
+
* repo copy became async. Resolves immediately for an agent that is already
|
|
582
|
+
* running, still queued, or unknown — so callers can await it unconditionally.
|
|
583
|
+
*
|
|
584
|
+
* Call it in the same tick as the `spawn()` it belongs to: a failed startup
|
|
585
|
+
* takes its record (and this entry) with it, exactly as the throw did.
|
|
586
|
+
*/
|
|
587
|
+
awaitStartup(id) {
|
|
588
|
+
return this.startups.get(id) ?? Promise.resolve();
|
|
322
589
|
}
|
|
323
590
|
/** Actually start an agent (called immediately or from queue drain). */
|
|
324
|
-
startAgent(id, record, { pi, ctx, type, prompt, options }) {
|
|
591
|
+
async startAgent(id, record, { pi, ctx, type, prompt, options }) {
|
|
325
592
|
// Re-validate a caller-supplied cwd: queued spawns can start minutes after
|
|
326
593
|
// spawn()'s check, and the directory may be gone by then (TOCTOU). Same
|
|
327
594
|
// curated errors; drainQueue parks a throw on the record as an error.
|
|
@@ -330,13 +597,46 @@ export class AgentManager {
|
|
|
330
597
|
// repo and both cleanup calls below MUST agree on this value forever.
|
|
331
598
|
const customCwd = options.cwd ?? undefined; // null (RPC "unset") → undefined
|
|
332
599
|
const baseCwd = customCwd ?? ctx.cwd;
|
|
600
|
+
// Take the running state — and with it the concurrency slot — BEFORE the
|
|
601
|
+
// first await. Creating a worktree is an awaited git call, and drainQueue
|
|
602
|
+
// reads the pool counters synchronously in a loop: incrementing after the
|
|
603
|
+
// await would let it start every queued agent at once while the first is
|
|
604
|
+
// still copying its repo. Claiming "running" here also keeps abort() and
|
|
605
|
+
// abortAll() able to reach an agent whose worktree is still being created.
|
|
606
|
+
//
|
|
607
|
+
// The pool is resolved ONCE, here, and carried to `settleRun` below:
|
|
608
|
+
// `poolFor` reads `maxConcurrentForeground`, which the user can change from
|
|
609
|
+
// `/agents → Settings` mid-run, so recomputing it at settle time would
|
|
610
|
+
// decrement a pool this run never charged (counter underflow, limit
|
|
611
|
+
// silently lifted) or skip the decrement for one it did (leaked slot —
|
|
612
|
+
// every later blocking spawn queues forever). The two startup exits below
|
|
613
|
+
// never reach `settleRun`, so they hand the slot back themselves.
|
|
614
|
+
const pool = this.poolFor(record);
|
|
615
|
+
const releaseSlot = () => {
|
|
616
|
+
if (pool === "background")
|
|
617
|
+
this.runningBackground--;
|
|
618
|
+
else if (pool === "foreground")
|
|
619
|
+
this.runningForeground--;
|
|
620
|
+
};
|
|
621
|
+
record.status = "running";
|
|
622
|
+
record.startedAt = Date.now();
|
|
623
|
+
record.startGate = undefined;
|
|
624
|
+
if (pool === "background")
|
|
625
|
+
this.runningBackground++;
|
|
626
|
+
else if (pool === "foreground")
|
|
627
|
+
this.runningForeground++;
|
|
628
|
+
this.checkpoint(record);
|
|
333
629
|
// Worktree isolation: try to create a temporary git worktree. Strict —
|
|
334
|
-
// fail loud if not possible (no silent fallback to main tree). Done
|
|
335
|
-
//
|
|
630
|
+
// fail loud if not possible (no silent fallback to main tree). Done BEFORE
|
|
631
|
+
// the run is kicked off so a failure doesn't leave a half-running agent.
|
|
632
|
+
// The project switch is enforced here as well as at the tool boundary
|
|
633
|
+
// because cross-extension RPC forwards its options unvalidated — a schema
|
|
634
|
+
// that omits the field can't stop a caller that never saw the schema.
|
|
336
635
|
let worktreeCwd;
|
|
337
|
-
if (options.isolation === "worktree") {
|
|
338
|
-
const wt = createWorktree(baseCwd, id);
|
|
636
|
+
if (options.isolation === "worktree" && isWorktreeIsolationEnabled()) {
|
|
637
|
+
const wt = await createWorktree(pi, baseCwd, id);
|
|
339
638
|
if (!wt) {
|
|
639
|
+
releaseSlot();
|
|
340
640
|
throw new Error('Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
|
|
341
641
|
'Initialize git and commit at least once, or omit `isolation`.');
|
|
342
642
|
}
|
|
@@ -349,21 +649,32 @@ export class AgentManager {
|
|
|
349
649
|
// subdirectory, silently dropping extensions/skills.
|
|
350
650
|
worktreeCwd = customCwd !== undefined ? wt.workPath : wt.path;
|
|
351
651
|
this.worktreeRepos.add(baseCwd);
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
652
|
+
// No longer "running" means a stop landed while the copy was being made
|
|
653
|
+
// (abort(), abortAll()) — a window that did not exist when creation was
|
|
654
|
+
// synchronous. The record is already terminal, so launching the run would
|
|
655
|
+
// burn tokens on work nobody is waiting for: discard the fresh (and by
|
|
656
|
+
// definition unchanged) worktree instead.
|
|
657
|
+
if (record.status !== "running") {
|
|
658
|
+
releaseSlot();
|
|
659
|
+
record.worktreeResult = await cleanupWorktree(pi, baseCwd, wt, options.description);
|
|
660
|
+
this.drainQueue();
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
359
663
|
}
|
|
360
664
|
this.onStart?.(record);
|
|
361
665
|
// Wire parent abort signal to stop the subagent when the parent is interrupted
|
|
362
666
|
let detachParentSignal;
|
|
363
667
|
if (options.signal) {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
668
|
+
// A queued spawn can start minutes after the caller handed us its signal,
|
|
669
|
+
// by which time it may already be aborted — and `addEventListener` would
|
|
670
|
+
// never fire, leaving a child the parent can no longer reach.
|
|
671
|
+
if (options.signal.aborted)
|
|
672
|
+
this.abort(id);
|
|
673
|
+
else {
|
|
674
|
+
const onParentAbort = () => this.abort(id);
|
|
675
|
+
options.signal.addEventListener("abort", onParentAbort, { once: true });
|
|
676
|
+
detachParentSignal = () => options.signal.removeEventListener("abort", onParentAbort);
|
|
677
|
+
}
|
|
367
678
|
}
|
|
368
679
|
const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
|
|
369
680
|
const promise = runAgent(ctx, type, prompt, {
|
|
@@ -374,13 +685,20 @@ export class AgentManager {
|
|
|
374
685
|
isolated: options.isolated,
|
|
375
686
|
inheritContext: options.inheritContext,
|
|
376
687
|
thinkingLevel: options.thinkingLevel,
|
|
688
|
+
structuredOutput: options.structuredOutput,
|
|
689
|
+
resumeSessionFile: options.resumeSessionFile,
|
|
690
|
+
nested: options.parentAgentId !== undefined,
|
|
691
|
+
workflow: options.workflowId !== undefined,
|
|
377
692
|
// Worktree wins for the working dir (the agent must run in the copy —
|
|
378
693
|
// which, with a custom cwd, was created from that target). Config stays
|
|
379
694
|
// with the parent project when a caller-supplied cwd is in play; it must
|
|
380
695
|
// stay undefined otherwise so plain worktree runs keep resolving config
|
|
381
696
|
// (incl. relative extension paths and memory) inside the worktree copy.
|
|
382
697
|
cwd: worktreeCwd ?? customCwd,
|
|
383
|
-
|
|
698
|
+
// Set iff a worktree was created (see above) — names the directory the
|
|
699
|
+
// copy came from, so the prompt can tell the agent not to work there.
|
|
700
|
+
worktreeBase: worktreeCwd ? baseCwd : undefined,
|
|
701
|
+
configCwd: options.configCwd ?? (customCwd !== undefined ? ctx.cwd : undefined),
|
|
384
702
|
signal: record.abortController.signal,
|
|
385
703
|
onToolActivity: (activity) => {
|
|
386
704
|
if (activity.type === "end")
|
|
@@ -391,6 +709,7 @@ export class AgentManager {
|
|
|
391
709
|
onTextDelta: options.onTextDelta,
|
|
392
710
|
onAssistantUsage: (usage) => {
|
|
393
711
|
addUsage(record.lifetimeUsage, usage);
|
|
712
|
+
this.onUsage?.(record, usage);
|
|
394
713
|
options.onAssistantUsage?.(usage);
|
|
395
714
|
},
|
|
396
715
|
onCompaction: (info) => {
|
|
@@ -398,14 +717,48 @@ export class AgentManager {
|
|
|
398
717
|
this.onCompact?.(record, info);
|
|
399
718
|
options.onCompaction?.(info);
|
|
400
719
|
},
|
|
720
|
+
nestedRuntime: {
|
|
721
|
+
manager: this,
|
|
722
|
+
parentAgentId: id,
|
|
723
|
+
depth: record.depth ?? 1,
|
|
724
|
+
maxSubagentDepth: record.maxSubagentDepth,
|
|
725
|
+
},
|
|
401
726
|
onSessionCreated: (session) => {
|
|
402
727
|
record.session = session;
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
728
|
+
// Capture now, while the session object exists: after eviction this
|
|
729
|
+
// path is the only thing that can reopen the conversation, and an
|
|
730
|
+
// in-memory session reports undefined, which correctly means
|
|
731
|
+
// "nothing to come back to".
|
|
732
|
+
// Optional chaining, not defensiveness for its own sake: this is the
|
|
733
|
+
// only field read off the session at creation, so an older pi or a
|
|
734
|
+
// stubbed session must degrade to "not resumable" rather than throw
|
|
735
|
+
// and take the whole spawn down with it.
|
|
736
|
+
record.sessionFile = session.sessionManager?.getSessionFile?.();
|
|
737
|
+
// Same reason, different field: the model and thinking level are only
|
|
738
|
+
// knowable once pi has resolved its defaults and clamped the level to
|
|
739
|
+
// what the model supports. Writing them back here makes the record
|
|
740
|
+
// authoritative, so every surface reads one place instead of each
|
|
741
|
+
// re-deriving "session, else the request" for itself.
|
|
742
|
+
if (session.model) {
|
|
743
|
+
record.invocation ??= {};
|
|
744
|
+
// Read the kept request first: a caller's level survives being clamped
|
|
745
|
+
// AND, one line later, being replaced by the effective one.
|
|
746
|
+
const requested = record.invocation.requestedThinking ?? record.invocation.thinking;
|
|
747
|
+
Object.assign(record.invocation, describeModel(session.model));
|
|
748
|
+
// Guarded for the reason above: a session that reports no level keeps
|
|
749
|
+
// the request rather than losing it. Overwriting unconditionally would
|
|
750
|
+
// turn an older or stubbed session into a blank `thinking:` tag, which
|
|
751
|
+
// is worse than the stale-but-true value it replaced.
|
|
752
|
+
if (session.thinkingLevel) {
|
|
753
|
+
record.invocation.thinking = session.thinkingLevel;
|
|
754
|
+
if (requested && requested !== session.thinkingLevel) {
|
|
755
|
+
record.invocation.requestedThinking = requested;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
if (record.historyFile) {
|
|
760
|
+
record.historyCleanup = streamAgentHistory(session, record.historyFile, record.id, ctx.cwd);
|
|
761
|
+
}
|
|
409
762
|
// Flush any steers that arrived before the session was ready
|
|
410
763
|
if (record.pendingSteers?.length) {
|
|
411
764
|
for (const msg of record.pendingSteers) {
|
|
@@ -413,15 +766,11 @@ export class AgentManager {
|
|
|
413
766
|
}
|
|
414
767
|
record.pendingSteers = undefined;
|
|
415
768
|
}
|
|
416
|
-
options.onSessionCreated?.(session);
|
|
417
769
|
this.checkpoint(record);
|
|
770
|
+
options.onSessionCreated?.(session);
|
|
418
771
|
},
|
|
419
772
|
})
|
|
420
|
-
.then(({ responseText, session, aborted, steered, failure }) => {
|
|
421
|
-
// A disposed manager no longer owns this run. Avoid late callbacks
|
|
422
|
-
// mutating a dead session or emitting completion side effects.
|
|
423
|
-
if (this.agents.get(id) !== record)
|
|
424
|
-
return responseText;
|
|
773
|
+
.then(async ({ responseText, session, aborted, steered, failure, structuredJson, structuredRetried }) => {
|
|
425
774
|
// Don't overwrite status if externally stopped via abort()
|
|
426
775
|
if (record.status !== "stopped") {
|
|
427
776
|
// Precedence: a hard abort keeps "aborted"; then a failed final turn
|
|
@@ -439,54 +788,46 @@ export class AgentManager {
|
|
|
439
788
|
}
|
|
440
789
|
}
|
|
441
790
|
record.result = responseText;
|
|
791
|
+
// Kept beside `result`, never inside it: `result` is prose meant for a
|
|
792
|
+
// reader — it is previewed, transcribed, and appended to below — while
|
|
793
|
+
// this is a machine-readable payload one caller asked for by schema.
|
|
794
|
+
record.structuredJson = structuredJson;
|
|
795
|
+
record.structuredRetried = structuredRetried;
|
|
442
796
|
record.session = session;
|
|
443
797
|
record.completedAt ??= Date.now();
|
|
444
798
|
detach();
|
|
445
|
-
//
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
record.outputCleanup();
|
|
449
|
-
}
|
|
450
|
-
catch { /* ignore */ }
|
|
451
|
-
record.outputCleanup = undefined;
|
|
452
|
-
}
|
|
799
|
+
// Flush both optional output and durable history before terminal state
|
|
800
|
+
// is checkpointed or completion is observable.
|
|
801
|
+
this.flushOutput(record);
|
|
453
802
|
// Clean up worktree if used
|
|
454
803
|
if (record.worktree) {
|
|
455
|
-
|
|
804
|
+
// The one moment the child's tree still exists and the child is done
|
|
805
|
+
// writing to it. try/catch, not decoration: a hook that throws must
|
|
806
|
+
// not leave the worktree behind.
|
|
807
|
+
if (options.onBeforeWorktreeCleanup) {
|
|
808
|
+
try {
|
|
809
|
+
await options.onBeforeWorktreeCleanup(record.worktree.path);
|
|
810
|
+
}
|
|
811
|
+
catch { /* ignore — never block cleanup */ }
|
|
812
|
+
}
|
|
813
|
+
const wtResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
|
|
456
814
|
record.worktreeResult = wtResult;
|
|
457
815
|
if (wtResult.hasChanges && wtResult.branch) {
|
|
458
816
|
// With a caller-supplied cwd the branch lives in THAT repo, not the
|
|
459
817
|
// parent session's — say so, or the orchestrator merges in the wrong repo.
|
|
460
818
|
const repoNote = customCwd !== undefined ? ` in \`${baseCwd}\`` : "";
|
|
819
|
+
// Appended to the prose only. A structured child's caller parses
|
|
820
|
+
// `structuredJson`, which stays untouched — but `result` is also
|
|
821
|
+
// what a human reads, so the note still belongs on it.
|
|
461
822
|
record.result = (record.result ?? "") +
|
|
462
823
|
`\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${baseCwd}\`)` : ""}`;
|
|
463
824
|
}
|
|
464
825
|
}
|
|
465
|
-
this.
|
|
466
|
-
|
|
467
|
-
// Mark resultConsumed so the callback skips notifications (result returned inline).
|
|
468
|
-
if (!options.isBackground) {
|
|
469
|
-
record.resultConsumed = true;
|
|
470
|
-
try {
|
|
471
|
-
this.onComplete?.(record);
|
|
472
|
-
}
|
|
473
|
-
catch { /* ignore completion side-effect errors */ }
|
|
474
|
-
}
|
|
475
|
-
else {
|
|
476
|
-
this.finishBackground(id);
|
|
477
|
-
try {
|
|
478
|
-
this.onComplete?.(record);
|
|
479
|
-
}
|
|
480
|
-
catch { /* ignore completion side-effect errors */ }
|
|
481
|
-
this.drainQueue();
|
|
482
|
-
}
|
|
826
|
+
this.abortOwnedChildren(id);
|
|
827
|
+
this.settleRun(record, true, pool);
|
|
483
828
|
return responseText;
|
|
484
829
|
})
|
|
485
|
-
.catch((err) => {
|
|
486
|
-
// A disposed manager no longer owns this run. Avoid late callbacks
|
|
487
|
-
// mutating a dead session or emitting completion side effects.
|
|
488
|
-
if (this.agents.get(id) !== record)
|
|
489
|
-
return "";
|
|
830
|
+
.catch(async (err) => {
|
|
490
831
|
// Don't overwrite status if externally stopped via abort()
|
|
491
832
|
if (record.status !== "stopped") {
|
|
492
833
|
record.status = "error";
|
|
@@ -494,108 +835,284 @@ export class AgentManager {
|
|
|
494
835
|
record.error = err instanceof Error ? err.message : String(err);
|
|
495
836
|
record.completedAt ??= Date.now();
|
|
496
837
|
detach();
|
|
497
|
-
//
|
|
498
|
-
|
|
499
|
-
try {
|
|
500
|
-
record.outputCleanup();
|
|
501
|
-
}
|
|
502
|
-
catch { /* ignore */ }
|
|
503
|
-
record.outputCleanup = undefined;
|
|
504
|
-
}
|
|
838
|
+
// Preserve partial assistant/tool history before recording the error.
|
|
839
|
+
this.flushOutput(record);
|
|
505
840
|
// Best-effort worktree cleanup on error
|
|
506
841
|
if (record.worktree) {
|
|
507
842
|
try {
|
|
508
|
-
const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
|
|
843
|
+
const wtResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
|
|
509
844
|
record.worktreeResult = wtResult;
|
|
510
845
|
}
|
|
511
846
|
catch { /* ignore cleanup errors */ }
|
|
512
847
|
}
|
|
513
|
-
this.
|
|
514
|
-
|
|
515
|
-
// Mark resultConsumed so the callback skips notifications (result returned inline).
|
|
516
|
-
if (!options.isBackground) {
|
|
517
|
-
record.resultConsumed = true;
|
|
518
|
-
this.onComplete?.(record);
|
|
519
|
-
}
|
|
520
|
-
else {
|
|
521
|
-
this.finishBackground(id);
|
|
522
|
-
this.onComplete?.(record);
|
|
523
|
-
this.drainQueue();
|
|
524
|
-
}
|
|
848
|
+
this.abortOwnedChildren(id);
|
|
849
|
+
this.settleRun(record, false, pool);
|
|
525
850
|
return "";
|
|
526
851
|
});
|
|
527
852
|
record.promise = promise;
|
|
853
|
+
// Notify caller that spawn is complete (record is in the map, promise is set).
|
|
854
|
+
// Called synchronously — onSessionCreated fires asynchronously inside runAgent.
|
|
855
|
+
// Used by spawnAndWait to let the caller set up output files before streaming
|
|
856
|
+
// starts. Read off the options, so a spawn that started from a queue drain
|
|
857
|
+
// still reaches the caller that queued it.
|
|
858
|
+
options.onSpawned?.(id);
|
|
528
859
|
}
|
|
529
|
-
/**
|
|
860
|
+
/**
|
|
861
|
+
* The shared tail of both settle paths: release whatever pool slot the run
|
|
862
|
+
* held, notify, and let the queue drain into the freed slot.
|
|
863
|
+
*
|
|
864
|
+
* The decrement lives HERE and nowhere else. `abort()` on a running record
|
|
865
|
+
* only fires its controller and leaves the run to settle normally, so
|
|
866
|
+
* decrementing there too would double-free — permanently lifting the limit.
|
|
867
|
+
*
|
|
868
|
+
* Foreground agents fire `onComplete` for lifecycle symmetry, with
|
|
869
|
+
* `resultConsumed` set so the callback skips notifications the inline result
|
|
870
|
+
* already delivered.
|
|
871
|
+
*
|
|
872
|
+
* @param guardCallback swallow a throwing `onComplete` (the success path does;
|
|
873
|
+
* the error path historically did not, and keeps not doing so).
|
|
874
|
+
* @param pool the pool this run was CHARGED TO at start time — passed in, not
|
|
875
|
+
* recomputed, so a mid-run change to `maxConcurrentForeground` can't make
|
|
876
|
+
* the release disagree with the acquire.
|
|
877
|
+
*/
|
|
878
|
+
settleRun(record, guardCallback, pool) {
|
|
879
|
+
// Terminal state is not durable until the stream has flushed. The
|
|
880
|
+
// checkpoint deliberately follows this call so stop/error/partial runs can
|
|
881
|
+
// be reopened after the live session is released.
|
|
882
|
+
this.flushOutput(record);
|
|
883
|
+
this.checkpoint(record);
|
|
884
|
+
if (!record.isBackground)
|
|
885
|
+
record.resultConsumed = true;
|
|
886
|
+
if (pool === "background")
|
|
887
|
+
this.runningBackground--;
|
|
888
|
+
else if (pool === "foreground")
|
|
889
|
+
this.runningForeground--;
|
|
890
|
+
if (guardCallback) {
|
|
891
|
+
try {
|
|
892
|
+
this.onComplete?.(record);
|
|
893
|
+
}
|
|
894
|
+
catch { /* ignore completion side-effect errors */ }
|
|
895
|
+
}
|
|
896
|
+
else {
|
|
897
|
+
this.onComplete?.(record);
|
|
898
|
+
}
|
|
899
|
+
// The isBackground half reproduces the pre-pool condition exactly — a
|
|
900
|
+
// background settle has always drained, even for a nested child that held
|
|
901
|
+
// no slot — so that path is unchanged whether or not the foreground pool is
|
|
902
|
+
// on. The `pool` half only adds the drain a freed FOREGROUND slot needs.
|
|
903
|
+
// A drain with nothing freed is a no-op anyway, but "no-op" is a claim
|
|
904
|
+
// about reachability, and matching the old condition needs no such claim.
|
|
905
|
+
if (record.isBackground || pool !== undefined)
|
|
906
|
+
this.drainQueue();
|
|
907
|
+
}
|
|
908
|
+
flushOutput(record) {
|
|
909
|
+
if (record.outputCleanup) {
|
|
910
|
+
try {
|
|
911
|
+
record.outputCleanup();
|
|
912
|
+
}
|
|
913
|
+
catch { /* best effort */ }
|
|
914
|
+
record.outputCleanup = undefined;
|
|
915
|
+
}
|
|
916
|
+
if (record.historyCleanup) {
|
|
917
|
+
try {
|
|
918
|
+
record.historyCleanup();
|
|
919
|
+
}
|
|
920
|
+
catch { /* best effort */ }
|
|
921
|
+
record.historyCleanup = undefined;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Stop the nested children a settled parent owns. Nested records are hidden
|
|
926
|
+
* from the UI and only their owner can consume them, so a child outliving its
|
|
927
|
+
* parent would burn tokens unseen with no way to reach it. Grandchildren are
|
|
928
|
+
* covered transitively — each abort lands in that child's own settle path.
|
|
929
|
+
*/
|
|
930
|
+
abortOwnedChildren(parentId) {
|
|
931
|
+
for (const [id, record] of this.agents) {
|
|
932
|
+
if (record.parentAgentId === parentId)
|
|
933
|
+
this.abort(id);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Start queued agents up to each pool's concurrency limit.
|
|
938
|
+
*
|
|
939
|
+
* `findIndex` on the entry's OWN pool rather than `shift`: with one queue
|
|
940
|
+
* serving two independent limits, a saturated foreground pool at the head
|
|
941
|
+
* would otherwise stall every background agent behind it. Taking the earliest
|
|
942
|
+
* eligible entry keeps FIFO within each pool, which is what callers see.
|
|
943
|
+
*/
|
|
530
944
|
drainQueue() {
|
|
531
|
-
|
|
532
|
-
const
|
|
945
|
+
for (;;) {
|
|
946
|
+
const i = this.queue.findIndex(e => this.poolHasRoom(e.pool));
|
|
947
|
+
if (i === -1)
|
|
948
|
+
return;
|
|
949
|
+
const [next] = this.queue.splice(i, 1);
|
|
533
950
|
const record = this.agents.get(next.id);
|
|
534
|
-
|
|
951
|
+
// Stale entries (aborted while queued) are not started — but are still
|
|
952
|
+
// released, since nothing else will.
|
|
953
|
+
if (!record || record.status !== "queued") {
|
|
954
|
+
next.release();
|
|
535
955
|
continue;
|
|
536
|
-
try {
|
|
537
|
-
this.startAgent(next.id, record, next.args);
|
|
538
|
-
}
|
|
539
|
-
catch (err) {
|
|
540
|
-
// Late failure (e.g. strict worktree-isolation) — surface on the record
|
|
541
|
-
// so the user/agent can see it via /agents, then keep draining.
|
|
542
|
-
record.status = "error";
|
|
543
|
-
record.error = err instanceof Error ? err.message : String(err);
|
|
544
|
-
record.completedAt = Date.now();
|
|
545
|
-
this.checkpoint(record);
|
|
546
|
-
this.onComplete?.(record);
|
|
547
956
|
}
|
|
957
|
+
// Detached, and never rejects: a late failure (e.g. strict worktree
|
|
958
|
+
// isolation) lands on the record inside `launch`, exactly as the
|
|
959
|
+
// synchronous throw did here before, and draining continues either way.
|
|
960
|
+
//
|
|
961
|
+
// The release waits for that startup to SETTLE rather than firing here.
|
|
962
|
+
// Startup is async now, so a release at drain time would wake a blocked
|
|
963
|
+
// `spawnAndWait` while `record.promise` was still undefined, and it would
|
|
964
|
+
// read a perfectly healthy agent as one that never ran.
|
|
965
|
+
void next.start().then(() => next.release(), () => next.release());
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Remove queued entries and wake anyone blocked on them. The single point
|
|
970
|
+
* that enforces "leaving the queue releases the waiter" — a missed release is
|
|
971
|
+
* an unbounded hang, not a failed call.
|
|
972
|
+
*/
|
|
973
|
+
dequeue(pred) {
|
|
974
|
+
const kept = [];
|
|
975
|
+
for (const entry of this.queue) {
|
|
976
|
+
if (pred(entry))
|
|
977
|
+
entry.release();
|
|
978
|
+
else
|
|
979
|
+
kept.push(entry);
|
|
548
980
|
}
|
|
981
|
+
this.queue = kept;
|
|
549
982
|
}
|
|
550
983
|
/**
|
|
551
984
|
* Spawn an agent and wait for completion (foreground use).
|
|
552
|
-
*
|
|
985
|
+
* Charged to the foreground pool (`maxConcurrentForeground`), which is
|
|
986
|
+
* unlimited by default; never to the background one.
|
|
553
987
|
* Returns { id, record } so callers can access the agent ID.
|
|
554
988
|
*
|
|
555
|
-
* @param onSpawned - Called synchronously
|
|
556
|
-
* Use this to set record.outputFile so
|
|
989
|
+
* @param onSpawned - Called synchronously once the run is kicked off, before
|
|
990
|
+
* onSessionCreated fires. Use this to set record.outputFile so
|
|
991
|
+
* streamToOutputFile can pick it up.
|
|
557
992
|
*/
|
|
558
993
|
async spawnAndWait(pi, ctx, type, prompt, options, onSpawned) {
|
|
994
|
+
// `blocking` is what maxConcurrentForeground bounds, and this is its only
|
|
995
|
+
// source. onSpawned rides on the options rather than on a field of this
|
|
996
|
+
// manager: a queued spawn starts at drain time, long after any install/
|
|
997
|
+
// restore pair around this call would have put the field back — and it now
|
|
998
|
+
// fires after an await (worktree creation) even on the immediate path.
|
|
559
999
|
const id = this.spawn(pi, ctx, type, prompt, {
|
|
560
1000
|
...options,
|
|
561
1001
|
isBackground: false,
|
|
1002
|
+
blocking: true,
|
|
562
1003
|
onSpawned,
|
|
563
1004
|
});
|
|
564
1005
|
const record = this.agents.get(id);
|
|
565
|
-
await
|
|
1006
|
+
// Queued: nothing to await yet — the promise appears when the drain starts
|
|
1007
|
+
// it. The gate resolves (never rejects) on every path out of the queue,
|
|
1008
|
+
// start and abort alike, so a rejection can never escape into the caller's
|
|
1009
|
+
// tool `execute` and take down pi's whole Promise.all tool batch.
|
|
1010
|
+
if (record.status === "queued")
|
|
1011
|
+
await record.startGate;
|
|
1012
|
+
// The run promise only exists once startup is past its awaited repo copy —
|
|
1013
|
+
// without this the call would return before the agent had started at all.
|
|
1014
|
+
// A startup failure (strict worktree isolation) rejects here, which is what
|
|
1015
|
+
// the immediate path owes its caller: pi only marks a tool result failed
|
|
1016
|
+
// when `execute` throws. A queued spawn's failure landed on the record
|
|
1017
|
+
// instead (nobody was awaiting `startups` at drain time) and is rethrown
|
|
1018
|
+
// below, so the contract is the same either way.
|
|
1019
|
+
await this.awaitStartup(id);
|
|
1020
|
+
// undefined when it was aborted while queued, or stopped mid-copy, and so
|
|
1021
|
+
// never ran — the record is already terminal with a completedAt, which is
|
|
1022
|
+
// what the caller renders.
|
|
1023
|
+
if (record.promise)
|
|
1024
|
+
await record.promise;
|
|
1025
|
+
// A record that ended "error" without ever getting a promise never ran: the
|
|
1026
|
+
// same startup failure spawn() rethrows on the immediate path (#179). Keep
|
|
1027
|
+
// one contract rather than letting queue pressure decide whether a strict
|
|
1028
|
+
// worktree failure throws or returns as a result.
|
|
1029
|
+
if (record.promise === undefined && record.status === "error") {
|
|
1030
|
+
throw new Error(record.error ?? "Agent failed to start");
|
|
1031
|
+
}
|
|
566
1032
|
return { id, record };
|
|
567
1033
|
}
|
|
568
1034
|
/**
|
|
569
1035
|
* Resume an existing agent session with a new prompt.
|
|
570
1036
|
*/
|
|
571
|
-
async resume(id, prompt, signal) {
|
|
1037
|
+
async resume(id, prompt, signal, options) {
|
|
572
1038
|
const record = this.agents.get(id);
|
|
573
1039
|
if (!record?.session)
|
|
574
1040
|
return undefined;
|
|
1041
|
+
// Background resume: settle asynchronously and notify on completion exactly
|
|
1042
|
+
// like a background spawn, returning immediately with the record still
|
|
1043
|
+
// "running" — or "queued" when at the concurrency limit. Previously
|
|
1044
|
+
// run_in_background was ignored on resume (the Agent tool's resume branch
|
|
1045
|
+
// returned before its background branch, and resume() only ever awaited
|
|
1046
|
+
// inline), so a resumed agent always blocked the caller until it finished.
|
|
1047
|
+
if (options?.isBackground) {
|
|
1048
|
+
// Never re-enter a run that is still in flight. Detaching means the caller
|
|
1049
|
+
// gets control back while the record stays "running", so nothing stops the
|
|
1050
|
+
// model from resuming the same agent again. Starting a second run would
|
|
1051
|
+
// overwrite record.abortController — orphaning the live run beyond the
|
|
1052
|
+
// reach of `/agents` stop and abortAll() — double-count the pool slot, and
|
|
1053
|
+
// then reject from session.prompt() with "Agent is already processing",
|
|
1054
|
+
// whose settle path would abort the LIVE run's children and report a
|
|
1055
|
+
// failure for a run that is still going. Refuse instead, leaving the
|
|
1056
|
+
// record untouched; the caller decides whether to wait or steer.
|
|
1057
|
+
if (record.status === "running" || record.status === "queued")
|
|
1058
|
+
return undefined;
|
|
1059
|
+
record.isBackground = true;
|
|
1060
|
+
record.resultConsumed = false;
|
|
1061
|
+
record.result = undefined;
|
|
1062
|
+
record.error = undefined;
|
|
1063
|
+
record.completedAt = undefined;
|
|
1064
|
+
record.status = "queued";
|
|
1065
|
+
const start = () => this.startResume(id, record, prompt, signal, options);
|
|
1066
|
+
if (occupiesPoolSlot(record) && !this.poolHasRoom("background")) {
|
|
1067
|
+
// At the concurrency limit — queue it, drains when a slot frees. A
|
|
1068
|
+
// detached resume has no inline caller, hence nothing to release. The
|
|
1069
|
+
// queue is shared with spawns, whose startup is async, so entries are
|
|
1070
|
+
// promise-shaped even though a resume starts synchronously; failures
|
|
1071
|
+
// land on the record here, since drainQueue no longer catches.
|
|
1072
|
+
this.queue.push({
|
|
1073
|
+
id,
|
|
1074
|
+
pool: "background",
|
|
1075
|
+
start: async () => {
|
|
1076
|
+
try {
|
|
1077
|
+
start();
|
|
1078
|
+
}
|
|
1079
|
+
catch (err) {
|
|
1080
|
+
record.status = "error";
|
|
1081
|
+
record.error = err instanceof Error ? err.message : String(err);
|
|
1082
|
+
record.completedAt = Date.now();
|
|
1083
|
+
this.onComplete?.(record);
|
|
1084
|
+
}
|
|
1085
|
+
},
|
|
1086
|
+
release: () => { },
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
else {
|
|
1090
|
+
start();
|
|
1091
|
+
}
|
|
1092
|
+
return record;
|
|
1093
|
+
}
|
|
1094
|
+
// Foreground resume: run inline and return the settled record.
|
|
575
1095
|
record.status = "running";
|
|
576
1096
|
record.startedAt = Date.now();
|
|
577
1097
|
record.completedAt = undefined;
|
|
578
1098
|
record.result = undefined;
|
|
579
1099
|
record.error = undefined;
|
|
580
|
-
const resumedModel = record.session.model;
|
|
581
|
-
record.invocation = {
|
|
582
|
-
...(record.invocation ?? {}),
|
|
583
|
-
...(resumedModel && { effectiveModelName: resumedModel.name ?? resumedModel.id }),
|
|
584
|
-
effectiveThinking: record.session.thinkingLevel,
|
|
585
|
-
};
|
|
586
|
-
this.checkpoint(record);
|
|
587
1100
|
try {
|
|
588
1101
|
const { text, failure } = await resumeAgent(record.session, prompt, {
|
|
589
1102
|
onToolActivity: (activity) => {
|
|
590
1103
|
if (activity.type === "end")
|
|
591
1104
|
record.toolUses++;
|
|
1105
|
+
options?.onToolActivity?.(activity);
|
|
592
1106
|
},
|
|
593
1107
|
onAssistantUsage: (usage) => {
|
|
594
1108
|
addUsage(record.lifetimeUsage, usage);
|
|
1109
|
+
this.onUsage?.(record, usage);
|
|
1110
|
+
options?.onAssistantUsage?.(usage);
|
|
595
1111
|
},
|
|
596
1112
|
onCompaction: (info) => {
|
|
597
1113
|
record.compactionCount++;
|
|
598
1114
|
this.onCompact?.(record, info);
|
|
1115
|
+
options?.onCompaction?.(info);
|
|
599
1116
|
},
|
|
600
1117
|
signal,
|
|
601
1118
|
});
|
|
@@ -606,16 +1123,121 @@ export class AgentManager {
|
|
|
606
1123
|
record.error = failure;
|
|
607
1124
|
record.result = text;
|
|
608
1125
|
record.completedAt = Date.now();
|
|
609
|
-
this.checkpoint(record);
|
|
610
1126
|
}
|
|
611
1127
|
catch (err) {
|
|
612
1128
|
record.status = "error";
|
|
613
1129
|
record.error = err instanceof Error ? err.message : String(err);
|
|
614
1130
|
record.completedAt = Date.now();
|
|
615
|
-
this.checkpoint(record);
|
|
616
1131
|
}
|
|
1132
|
+
// Same contract as the spawn settle paths: children spawned during the
|
|
1133
|
+
// resumed turn must not outlive it — nothing else can see or reach them.
|
|
1134
|
+
this.abortOwnedChildren(id);
|
|
617
1135
|
return record;
|
|
618
1136
|
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Start a background resume run: detached, settling and notifying like
|
|
1139
|
+
* startAgent's background path. Invoked immediately, or from drainQueue when
|
|
1140
|
+
* a concurrency slot frees. The session already exists (resume reuses it), so
|
|
1141
|
+
* there is no onSessionCreated to hang per-run wiring off — callers use
|
|
1142
|
+
* `options.onStarted`, which fires on both the immediate and the drained path.
|
|
1143
|
+
*/
|
|
1144
|
+
startResume(id, record, prompt, parentSignal, options) {
|
|
1145
|
+
if (!record.session)
|
|
1146
|
+
return;
|
|
1147
|
+
record.status = "running";
|
|
1148
|
+
record.startedAt = Date.now();
|
|
1149
|
+
if (occupiesPoolSlot(record))
|
|
1150
|
+
this.runningBackground++;
|
|
1151
|
+
this.onStart?.(record);
|
|
1152
|
+
// Fresh abort controller so /agents stop and steering target THIS run rather
|
|
1153
|
+
// than the previous one's settled controller.
|
|
1154
|
+
const abortController = new AbortController();
|
|
1155
|
+
record.abortController = abortController;
|
|
1156
|
+
// Optional, and NOT what the Agent tool passes for a detached resume: a
|
|
1157
|
+
// parent signal aborts on the parent's own interrupt (user Esc), which is
|
|
1158
|
+
// right for a foreground run whose result the caller is awaiting, and wrong
|
|
1159
|
+
// for a detached one — background spawns omit it for exactly this reason.
|
|
1160
|
+
let detachParentSignal;
|
|
1161
|
+
if (parentSignal) {
|
|
1162
|
+
const onParentAbort = () => this.abort(id);
|
|
1163
|
+
parentSignal.addEventListener("abort", onParentAbort, { once: true });
|
|
1164
|
+
detachParentSignal = () => parentSignal.removeEventListener("abort", onParentAbort);
|
|
1165
|
+
}
|
|
1166
|
+
// Per-run durable history starts at the existing session tail. The prompt
|
|
1167
|
+
// and all messages produced by this resumed run are then flushed by the
|
|
1168
|
+
// same manager-owned seam as a fresh spawn.
|
|
1169
|
+
if (record.historyFile) {
|
|
1170
|
+
const cwd = this.recoveryCwds.get(id);
|
|
1171
|
+
if (cwd) {
|
|
1172
|
+
const startIndex = Array.isArray(record.session.messages) ? record.session.messages.length : 0;
|
|
1173
|
+
record.historyCleanup = streamAgentHistory(record.session, record.historyFile, id, cwd, startIndex);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
// Per-run side effects (optional `.output` streaming) — see ResumeOptions.onStarted.
|
|
1177
|
+
// After the record is in its running shape, before the run is kicked off.
|
|
1178
|
+
try {
|
|
1179
|
+
options.onStarted?.();
|
|
1180
|
+
}
|
|
1181
|
+
catch { /* ignore caller wiring errors */ }
|
|
1182
|
+
const settle = () => {
|
|
1183
|
+
detachParentSignal?.();
|
|
1184
|
+
detachParentSignal = undefined;
|
|
1185
|
+
// Final flush of streaming files. The durable history stream is owned by
|
|
1186
|
+
// the manager; the optional `.output` stream is caller-wired.
|
|
1187
|
+
this.flushOutput(record);
|
|
1188
|
+
// Children spawned during the resumed turn must not outlive it.
|
|
1189
|
+
this.abortOwnedChildren(id);
|
|
1190
|
+
if (occupiesPoolSlot(record))
|
|
1191
|
+
this.runningBackground--;
|
|
1192
|
+
try {
|
|
1193
|
+
this.onComplete?.(record);
|
|
1194
|
+
}
|
|
1195
|
+
catch { /* ignore completion side-effect errors */ }
|
|
1196
|
+
this.drainQueue();
|
|
1197
|
+
};
|
|
1198
|
+
const promise = resumeAgent(record.session, prompt, {
|
|
1199
|
+
onToolActivity: (activity) => {
|
|
1200
|
+
if (activity.type === "end")
|
|
1201
|
+
record.toolUses++;
|
|
1202
|
+
options.onToolActivity?.(activity);
|
|
1203
|
+
},
|
|
1204
|
+
onAssistantUsage: (usage) => {
|
|
1205
|
+
addUsage(record.lifetimeUsage, usage);
|
|
1206
|
+
this.onUsage?.(record, usage);
|
|
1207
|
+
options.onAssistantUsage?.(usage);
|
|
1208
|
+
},
|
|
1209
|
+
onCompaction: (info) => {
|
|
1210
|
+
record.compactionCount++;
|
|
1211
|
+
this.onCompact?.(record, info);
|
|
1212
|
+
options.onCompaction?.(info);
|
|
1213
|
+
},
|
|
1214
|
+
signal: abortController.signal,
|
|
1215
|
+
})
|
|
1216
|
+
.then(({ text, failure }) => {
|
|
1217
|
+
// Don't overwrite status if externally stopped via abort().
|
|
1218
|
+
if (record.status !== "stopped") {
|
|
1219
|
+
// Same contract as the spawn path (#144): a failed final turn is an
|
|
1220
|
+
// error, not a completion — but the resumed text stays available.
|
|
1221
|
+
record.status = failure ? "error" : "completed";
|
|
1222
|
+
if (failure)
|
|
1223
|
+
record.error = failure;
|
|
1224
|
+
}
|
|
1225
|
+
record.result = text;
|
|
1226
|
+
record.completedAt ??= Date.now();
|
|
1227
|
+
settle();
|
|
1228
|
+
return text;
|
|
1229
|
+
})
|
|
1230
|
+
.catch((err) => {
|
|
1231
|
+
if (record.status !== "stopped") {
|
|
1232
|
+
record.status = "error";
|
|
1233
|
+
record.error = err instanceof Error ? err.message : String(err);
|
|
1234
|
+
}
|
|
1235
|
+
record.completedAt ??= Date.now();
|
|
1236
|
+
settle();
|
|
1237
|
+
return "";
|
|
1238
|
+
});
|
|
1239
|
+
record.promise = promise;
|
|
1240
|
+
}
|
|
619
1241
|
/**
|
|
620
1242
|
* Send a steering message to an agent from the UI (mirrors the steer_subagent
|
|
621
1243
|
* tool). A live session delivers it now — it interrupts the agent after its
|
|
@@ -643,52 +1265,91 @@ export class AgentManager {
|
|
|
643
1265
|
getRecord(id) {
|
|
644
1266
|
return this.agents.get(id);
|
|
645
1267
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
1268
|
+
/** Handles already in use, so a fresh spawn can pick an unclaimed one. */
|
|
1269
|
+
takenHandles() {
|
|
1270
|
+
const taken = new Set();
|
|
1271
|
+
for (const record of this.agents.values()) {
|
|
1272
|
+
if (record.handle)
|
|
1273
|
+
taken.add(record.handle);
|
|
1274
|
+
if (record.alias)
|
|
1275
|
+
taken.add(record.alias);
|
|
1276
|
+
}
|
|
1277
|
+
// Tombstones hold their names too: an evicted `@explore` is still
|
|
1278
|
+
// resurrectable, so a later Explore must become `explore-2` rather than
|
|
1279
|
+
// shadowing a conversation the user can still reach.
|
|
1280
|
+
for (const entry of this.tombstones.values()) {
|
|
1281
|
+
taken.add(entry.handle);
|
|
1282
|
+
if (entry.alias)
|
|
1283
|
+
taken.add(entry.alias);
|
|
656
1284
|
}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
1285
|
+
return taken;
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Resolve an `@name` from the prompt. Matches a top-level agent's handle
|
|
1289
|
+
* case-insensitively, preferring one that can still be steered and otherwise
|
|
1290
|
+
* the most recently started (which is the one a resume should continue), then
|
|
1291
|
+
* falls back to an exact agent id so `@<agentId>` works too.
|
|
1292
|
+
*/
|
|
1293
|
+
resolveMention(name) {
|
|
1294
|
+
const wanted = name.toLowerCase();
|
|
1295
|
+
let fallback;
|
|
1296
|
+
for (const record of this.agents.values()) {
|
|
1297
|
+
if (record.parentAgentId !== undefined)
|
|
660
1298
|
continue;
|
|
661
|
-
|
|
1299
|
+
// Handle and alias share one namespace, so at most one agent answers a
|
|
1300
|
+
// name and it makes no difference which of the two matched.
|
|
1301
|
+
if (record.handle?.toLowerCase() !== wanted && record.alias?.toLowerCase() !== wanted)
|
|
1302
|
+
continue;
|
|
1303
|
+
if (record.status === "running" || record.status === "queued")
|
|
1304
|
+
return { kind: "live", record };
|
|
1305
|
+
if (!fallback || record.startedAt > fallback.startedAt)
|
|
1306
|
+
fallback = record;
|
|
1307
|
+
}
|
|
1308
|
+
if (fallback)
|
|
1309
|
+
return { kind: "live", record: fallback };
|
|
1310
|
+
const byId = this.agents.get(name);
|
|
1311
|
+
if (byId?.parentAgentId === undefined && byId !== undefined)
|
|
1312
|
+
return { kind: "live", record: byId };
|
|
1313
|
+
// Only once nothing live answers: a tombstone is a conversation to reopen,
|
|
1314
|
+
// and reopening one while its record still exists would fork the session.
|
|
1315
|
+
for (const entry of this.tombstones.values()) {
|
|
1316
|
+
if (entry.handle.toLowerCase() === wanted || entry.alias?.toLowerCase() === wanted || entry.id === name) {
|
|
1317
|
+
return { kind: "tombstone", entry };
|
|
1318
|
+
}
|
|
662
1319
|
}
|
|
1320
|
+
return undefined;
|
|
663
1321
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
1322
|
+
/**
|
|
1323
|
+
* Forget an evicted agent, by handle. For the case where its session file has
|
|
1324
|
+
* gone: the entry can then only ever fail, while still holding the name
|
|
1325
|
+
* against the type that would otherwise start a fresh agent under it.
|
|
1326
|
+
*
|
|
1327
|
+
* A *successful* resume does not drop its tombstone — the live record it
|
|
1328
|
+
* creates already wins in `resolveMention`, and overwrites the entry in place
|
|
1329
|
+
* when it is itself evicted.
|
|
1330
|
+
*/
|
|
1331
|
+
dropTombstone(handle) {
|
|
1332
|
+
this.tombstones.delete(handle);
|
|
1333
|
+
}
|
|
1334
|
+
/** Evicted agents whose conversation can still be reopened, newest first. */
|
|
1335
|
+
listTombstones() {
|
|
1336
|
+
return [...this.tombstones.values()].sort((a, b) => b.completedAt - a.completedAt);
|
|
1337
|
+
}
|
|
1338
|
+
listAgents() {
|
|
1339
|
+
return [...this.agents.values()].sort((a, b) => b.startedAt - a.startedAt);
|
|
682
1340
|
}
|
|
683
1341
|
abort(id) {
|
|
684
1342
|
const record = this.agents.get(id);
|
|
685
1343
|
if (!record)
|
|
686
1344
|
return false;
|
|
687
|
-
// Remove from queue if queued
|
|
1345
|
+
// Remove from queue if queued. No decrement — the slot was never taken —
|
|
1346
|
+
// and no onComplete, matching what a queued background abort has always
|
|
1347
|
+
// done; a blocking caller learns of the stop from its own tool result.
|
|
688
1348
|
if (record.status === "queued") {
|
|
689
|
-
this.
|
|
1349
|
+
this.dequeue(q => q.id === id);
|
|
690
1350
|
record.status = "stopped";
|
|
691
1351
|
record.completedAt = Date.now();
|
|
1352
|
+
this.flushOutput(record);
|
|
692
1353
|
this.checkpoint(record);
|
|
693
1354
|
return true;
|
|
694
1355
|
}
|
|
@@ -699,15 +1360,48 @@ export class AgentManager {
|
|
|
699
1360
|
record.completedAt = Date.now();
|
|
700
1361
|
this.flushOutput(record);
|
|
701
1362
|
this.checkpoint(record);
|
|
702
|
-
this.finishBackground(id);
|
|
703
|
-
this.drainQueue();
|
|
704
1363
|
return true;
|
|
705
1364
|
}
|
|
706
1365
|
/** Dispose a record's session and remove it from the map. */
|
|
707
1366
|
removeRecord(id, record) {
|
|
708
|
-
|
|
1367
|
+
this.tombstone(record);
|
|
1368
|
+
const session = record.session;
|
|
1369
|
+
// Detached before the shutdown starts, so the record leaves the map at once and
|
|
1370
|
+
// nothing can observe a session that is half torn down.
|
|
709
1371
|
record.session = undefined;
|
|
710
1372
|
this.agents.delete(id);
|
|
1373
|
+
// A failed startup keeps its (rejected) entry so a late awaitStartup still
|
|
1374
|
+
// sees it; drop it with the record so the map can't grow unbounded.
|
|
1375
|
+
this.startups.delete(id);
|
|
1376
|
+
// Fire-and-forget is right here and only here: this runs from the 60s cleanup timer
|
|
1377
|
+
// and from `clearCompleted()` on session boundaries, with the process staying alive,
|
|
1378
|
+
// so handlers get their full window. The quit path awaits instead — see dispose().
|
|
1379
|
+
void shutdownChildSession(session);
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Preserve enough of a departing record for `@handle` to reopen its
|
|
1383
|
+
* conversation later. Nothing to keep unless it has both a handle to be
|
|
1384
|
+
* addressed by and a session file to reopen — an in-memory session leaves no
|
|
1385
|
+
* transcript, so the mention would have nothing to continue from.
|
|
1386
|
+
*/
|
|
1387
|
+
tombstone(record) {
|
|
1388
|
+
if (!record.handle || !record.sessionFile)
|
|
1389
|
+
return;
|
|
1390
|
+
this.tombstones.set(record.handle, {
|
|
1391
|
+
handle: record.handle,
|
|
1392
|
+
alias: record.alias,
|
|
1393
|
+
id: record.id,
|
|
1394
|
+
type: record.type,
|
|
1395
|
+
description: record.description,
|
|
1396
|
+
sessionFile: record.sessionFile,
|
|
1397
|
+
completedAt: record.completedAt ?? Date.now(),
|
|
1398
|
+
});
|
|
1399
|
+
// Bound the memory a long session can accumulate. Oldest first, since the
|
|
1400
|
+
// agent someone still wants to reach is the one they used most recently.
|
|
1401
|
+
while (this.tombstones.size > MAX_TOMBSTONES) {
|
|
1402
|
+
const oldest = [...this.tombstones.values()].reduce((a, b) => (a.completedAt <= b.completedAt ? a : b));
|
|
1403
|
+
this.tombstones.delete(oldest.handle);
|
|
1404
|
+
}
|
|
711
1405
|
}
|
|
712
1406
|
cleanup() {
|
|
713
1407
|
const cutoff = Date.now() - 10 * 60_000;
|
|
@@ -716,29 +1410,6 @@ export class AgentManager {
|
|
|
716
1410
|
continue;
|
|
717
1411
|
if ((record.completedAt ?? 0) >= cutoff)
|
|
718
1412
|
continue;
|
|
719
|
-
// A durable transcript is the source of truth for history. Release the
|
|
720
|
-
// live session after the TTL, but retain a lightweight record so opening
|
|
721
|
-
// history again in this session does not silently lose its identity or
|
|
722
|
-
// locator. Records without durable storage remain eligible for eviction.
|
|
723
|
-
if (record.transcriptPath) {
|
|
724
|
-
try {
|
|
725
|
-
record.session?.dispose?.();
|
|
726
|
-
}
|
|
727
|
-
catch { /* ignore cleanup failures */ }
|
|
728
|
-
record.session = undefined;
|
|
729
|
-
try {
|
|
730
|
-
record.outputCleanup?.();
|
|
731
|
-
}
|
|
732
|
-
catch { /* ignore cleanup failures */ }
|
|
733
|
-
record.outputCleanup = undefined;
|
|
734
|
-
record.outputFile = undefined;
|
|
735
|
-
record.historyFile = undefined;
|
|
736
|
-
// The durable transcript is the source of truth after the TTL. Keep
|
|
737
|
-
// only the small identity/status record in memory; get_subagent_result
|
|
738
|
-
// reloads the final answer from transcriptPath on demand.
|
|
739
|
-
record.result = undefined;
|
|
740
|
-
continue;
|
|
741
|
-
}
|
|
742
1413
|
this.removeRecord(id, record);
|
|
743
1414
|
}
|
|
744
1415
|
}
|
|
@@ -756,6 +1427,13 @@ export class AgentManager {
|
|
|
756
1427
|
continue;
|
|
757
1428
|
this.removeRecord(id, record);
|
|
758
1429
|
}
|
|
1430
|
+
// Unconditional: both callers are session boundaries (`session_start` and
|
|
1431
|
+
// `session_before_switch`), and `skipUnconsumed` only spares records whose
|
|
1432
|
+
// results the LLM has yet to read — it does not make the sweep partial in
|
|
1433
|
+
// the sense that matters here. A new session means new handles, or
|
|
1434
|
+
// `@explore` would silently reach an agent the user never started. Claude
|
|
1435
|
+
// Code resets its registry on `/clear` for the same reason.
|
|
1436
|
+
this.tombstones.clear();
|
|
759
1437
|
}
|
|
760
1438
|
/** Whether any agents are still running or queued. */
|
|
761
1439
|
hasRunning() {
|
|
@@ -770,20 +1448,19 @@ export class AgentManager {
|
|
|
770
1448
|
if (record) {
|
|
771
1449
|
record.status = "stopped";
|
|
772
1450
|
record.completedAt = Date.now();
|
|
1451
|
+
this.flushOutput(record);
|
|
773
1452
|
this.checkpoint(record);
|
|
774
1453
|
count++;
|
|
775
1454
|
}
|
|
776
1455
|
}
|
|
777
|
-
this.
|
|
778
|
-
// Abort running agents
|
|
779
|
-
// shutdown/session switch leaves the latest assistant message available.
|
|
1456
|
+
this.dequeue(() => true);
|
|
1457
|
+
// Abort running agents
|
|
780
1458
|
for (const record of this.agents.values()) {
|
|
781
1459
|
if (record.status === "running") {
|
|
782
1460
|
record.abortController?.abort();
|
|
783
1461
|
record.status = "stopped";
|
|
784
1462
|
record.completedAt = Date.now();
|
|
785
1463
|
this.flushOutput(record);
|
|
786
|
-
this.finishBackground(record.id);
|
|
787
1464
|
this.checkpoint(record);
|
|
788
1465
|
count++;
|
|
789
1466
|
}
|
|
@@ -796,39 +1473,76 @@ export class AgentManager {
|
|
|
796
1473
|
// agents finish they start queued ones, which need awaiting too.
|
|
797
1474
|
while (true) {
|
|
798
1475
|
this.drainQueue();
|
|
799
|
-
const pending = [
|
|
800
|
-
|
|
801
|
-
.
|
|
802
|
-
|
|
1476
|
+
const pending = [];
|
|
1477
|
+
for (const record of this.agents.values()) {
|
|
1478
|
+
if (record.status !== "running" && record.status !== "queued")
|
|
1479
|
+
continue;
|
|
1480
|
+
// An agent whose worktree is still being created is "running" with no
|
|
1481
|
+
// `promise` yet — without its startup the wait would return too early.
|
|
1482
|
+
const startup = this.startups.get(record.id);
|
|
1483
|
+
if (startup)
|
|
1484
|
+
pending.push(startup);
|
|
1485
|
+
if (record.promise)
|
|
1486
|
+
pending.push(record.promise);
|
|
1487
|
+
}
|
|
803
1488
|
if (pending.length === 0)
|
|
804
1489
|
break;
|
|
805
1490
|
await Promise.allSettled(pending);
|
|
806
1491
|
}
|
|
807
1492
|
}
|
|
808
|
-
|
|
1493
|
+
/**
|
|
1494
|
+
* @param pi - Needed to run `git worktree prune`, which is async now and so
|
|
1495
|
+
* cannot be reached through a stored spawn argument at shutdown. Omitting
|
|
1496
|
+
* it (tests, teardown of a manager that never spawned) skips the prune.
|
|
1497
|
+
*/
|
|
1498
|
+
async dispose(pi) {
|
|
809
1499
|
clearInterval(this.cleanupInterval);
|
|
810
|
-
//
|
|
811
|
-
|
|
1500
|
+
// Keep the pre-shutdown active snapshot marked active in the checkpoint. A
|
|
1501
|
+
// process can still be killed after dispose starts; restoreRecovered turns
|
|
1502
|
+
// that stale active marker into a stopped record, while the transcript has
|
|
1503
|
+
// already received the terminal abort/stop event below.
|
|
1504
|
+
const activeBeforeDispose = new Set([...this.agents.values()]
|
|
1505
|
+
.filter(record => record.status === "running" || record.status === "queued")
|
|
1506
|
+
.map(record => record.id));
|
|
1507
|
+
// Mark live work stopped and flush durable history before child sessions are
|
|
1508
|
+
// shut down. The caller also invokes abortAll(), but dispose is deliberately
|
|
1509
|
+
// safe and complete when used on its own.
|
|
1510
|
+
this.abortAll();
|
|
1511
|
+
// Clear queue — via dequeue, so anyone blocked in spawnAndWait is woken
|
|
1512
|
+
// rather than left awaiting a gate nothing will ever resolve.
|
|
1513
|
+
this.dequeue(() => true);
|
|
812
1514
|
for (const record of this.agents.values()) {
|
|
813
|
-
|
|
1515
|
+
this.flushOutput(record);
|
|
1516
|
+
this.checkpoint(record);
|
|
1517
|
+
if (activeBeforeDispose.has(record.id)) {
|
|
1518
|
+
const activeSnapshot = this.makeCheckpoint(record);
|
|
1519
|
+
delete activeSnapshot.completedAt;
|
|
1520
|
+
activeSnapshot.status = "running";
|
|
1521
|
+
const cwd = this.recoveryCwds.get(record.id);
|
|
1522
|
+
if (cwd)
|
|
1523
|
+
writeAgentRecoveryCheckpoint(cwd, activeSnapshot);
|
|
1524
|
+
}
|
|
814
1525
|
}
|
|
1526
|
+
const sessions = [...this.agents.values()].map(record => record.session);
|
|
815
1527
|
this.agents.clear();
|
|
816
1528
|
this.recoveryCwds.clear();
|
|
817
|
-
this.
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
}
|
|
830
|
-
catch { /* ignore */ }
|
|
1529
|
+
this.startups.clear();
|
|
1530
|
+
if (pi) {
|
|
1531
|
+
// Prune any orphaned git worktrees (crash recovery). Detached: dispose runs
|
|
1532
|
+
// on the shutdown path, which cannot wait for git. Started before the awaited
|
|
1533
|
+
// shutdown below rather than after it, so the git calls have that window to
|
|
1534
|
+
// finish in instead of racing the process exit that follows.
|
|
1535
|
+
const prune = (repo) => { pruneWorktrees(pi, repo).catch(() => { }); };
|
|
1536
|
+
prune(process.cwd());
|
|
1537
|
+
// Also prune repos that caller-supplied cwds created worktrees in — a clean
|
|
1538
|
+
// exit with in-flight agents would otherwise leave stale registrations there.
|
|
1539
|
+
for (const repo of this.worktreeRepos)
|
|
1540
|
+
prune(repo);
|
|
831
1541
|
}
|
|
1542
|
+
// Awaited, unlike the eviction path: pi awaits this extension's `session_shutdown`
|
|
1543
|
+
// handler and the process exits right after it returns, so anything left unawaited
|
|
1544
|
+
// here never runs at all. Bounded — each call carries its own ceiling, concurrently.
|
|
1545
|
+
await Promise.all(sessions.map(session => shutdownChildSession(session)));
|
|
832
1546
|
}
|
|
833
1547
|
}
|
|
834
1548
|
//# sourceMappingURL=agent-manager.js.map
|