@herbertgao/pi-subagents 0.18.5 → 0.22.0-local.1
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 +16 -0
- package/README.md +1 -1
- package/docs/packages.md +211 -0
- package/docs/rpc.md +61 -63
- package/docs/workflows.md +24 -16
- package/package.json +6 -6
- package/src/agent-file-toggle.ts +51 -14
- package/src/agent-manager.ts +123 -55
- package/src/agent-runner.ts +144 -13
- package/src/agent-source-badge.ts +78 -0
- package/src/completion-outbox.ts +122 -0
- package/src/custom-agents.ts +171 -79
- package/src/index.ts +598 -297
- package/src/invocation-config.ts +1 -1
- package/src/mention-clone.ts +48 -72
- package/src/nested-tools.ts +1 -1
- package/src/output-file.ts +15 -6
- package/src/package-resources.ts +724 -0
- package/src/schedule.ts +1 -1
- package/src/settings.ts +66 -0
- package/src/types.ts +26 -4
- package/src/ui/agent-widget.ts +2 -0
- package/src/ui/conversation-viewer.ts +354 -32
- package/src/ui/fleet-list.ts +33 -15
- package/src/workflow/collisions.ts +4 -1
- package/src/workflow/host.ts +6 -0
- package/src/workflow/json-schema.ts +9 -8
- package/src/workflow/saved.ts +48 -2
package/src/agent-runner.ts
CHANGED
|
@@ -14,9 +14,13 @@ import {
|
|
|
14
14
|
type AgentSession,
|
|
15
15
|
type AgentSessionEvent,
|
|
16
16
|
createAgentSession,
|
|
17
|
+
createCodemodeExtension,
|
|
18
|
+
createMcpExtension,
|
|
19
|
+
createToolSearchExtension,
|
|
17
20
|
DefaultResourceLoader,
|
|
18
21
|
type ExtensionAPI,
|
|
19
22
|
getAgentDir,
|
|
23
|
+
type InlineExtension,
|
|
20
24
|
SessionManager,
|
|
21
25
|
SettingsManager,
|
|
22
26
|
} from "@earendil-works/pi-coding-agent"
|
|
@@ -101,6 +105,8 @@ export function extensionCanonicalName(extPath: string): string {
|
|
|
101
105
|
* co-located file to `pi-subagents`.
|
|
102
106
|
*/
|
|
103
107
|
function extensionPackageName(extPath: string): string | undefined {
|
|
108
|
+
// `builtin:<name>` names no file; climbing from it would read the process cwd's manifest.
|
|
109
|
+
if (extPath.startsWith("builtin:")) return undefined
|
|
104
110
|
const entry = resolve(extPath)
|
|
105
111
|
let dir = dirname(extPath)
|
|
106
112
|
for (;;) {
|
|
@@ -269,6 +275,8 @@ export function installExtensionToolScope(
|
|
|
269
275
|
* seeded from `toolNames`.
|
|
270
276
|
*/
|
|
271
277
|
readmitToolNames: Set<string>
|
|
278
|
+
/** Receives the scope check the child's nested-call gate extension applies. */
|
|
279
|
+
nestedCallGate: NestedCallGate
|
|
272
280
|
},
|
|
273
281
|
): void {
|
|
274
282
|
const {
|
|
@@ -278,6 +286,7 @@ export function installExtensionToolScope(
|
|
|
278
286
|
extNames,
|
|
279
287
|
narrowing,
|
|
280
288
|
readmitToolNames,
|
|
289
|
+
nestedCallGate,
|
|
281
290
|
} = ctx
|
|
282
291
|
|
|
283
292
|
// The names allowed right now. Mirrors the `ext:` opt-in flip: when any `ext:`
|
|
@@ -308,13 +317,36 @@ export function installExtensionToolScope(
|
|
|
308
317
|
return keep
|
|
309
318
|
}
|
|
310
319
|
|
|
320
|
+
// Names this agent selects explicitly: its built-ins, injected tools, and
|
|
321
|
+
// `ext:foo/bar` narrowings. pi activates a named tool even when it is
|
|
322
|
+
// `defaultActive: false`, as `--tools` does.
|
|
323
|
+
const named = new Set([
|
|
324
|
+
...toolNames,
|
|
325
|
+
...readmitToolNames,
|
|
326
|
+
...[...narrowing.values()].flatMap((s) => [...s]),
|
|
327
|
+
])
|
|
328
|
+
|
|
329
|
+
// Activate what pi itself would: only tools declared to the model (`direct`/
|
|
330
|
+
// `model-only` exposure), and of those only named ones or ones not opted out
|
|
331
|
+
// with `defaultActive: false`. `codemode`/`deferred` tools (pi's MCP tools by
|
|
332
|
+
// default) stay undeclared until their extension or `tool_search` activates
|
|
333
|
+
// them — so an already-active tool is kept, or turn_end would undo that.
|
|
311
334
|
const renarrow = () => {
|
|
312
335
|
const allowed = inScope()
|
|
336
|
+
const current = session.getActiveToolNames()
|
|
337
|
+
const active = new Set(current)
|
|
313
338
|
const next = session
|
|
314
339
|
.getAllTools()
|
|
340
|
+
.filter(({ name, exposure = "direct" }) => {
|
|
341
|
+
if (!allowed.has(name)) return false
|
|
342
|
+
if (active.has(name)) return true
|
|
343
|
+
if (exposure !== "direct" && exposure !== "model-only") return false
|
|
344
|
+
return (
|
|
345
|
+
named.has(name) ||
|
|
346
|
+
session.getToolDefinition(name)?.defaultActive !== false
|
|
347
|
+
)
|
|
348
|
+
})
|
|
315
349
|
.map((t) => t.name)
|
|
316
|
-
.filter((n) => allowed.has(n))
|
|
317
|
-
const current = session.getActiveToolNames()
|
|
318
350
|
// setActiveToolsByName unconditionally rebuilds the system prompt, so skip
|
|
319
351
|
// the no-op that steady-state turns would otherwise pay for every turn.
|
|
320
352
|
if (
|
|
@@ -333,6 +365,8 @@ export function installExtensionToolScope(
|
|
|
333
365
|
if (event.type === "turn_end") renarrow()
|
|
334
366
|
})
|
|
335
367
|
|
|
368
|
+
nestedCallGate.allows = (name) => inScope().has(name)
|
|
369
|
+
|
|
336
370
|
const priorBeforeToolCall = session.agent.beforeToolCall
|
|
337
371
|
session.agent.beforeToolCall = async (context, signal) => {
|
|
338
372
|
if (!inScope().has(context.toolCall.name)) {
|
|
@@ -345,6 +379,35 @@ export function installExtensionToolScope(
|
|
|
345
379
|
}
|
|
346
380
|
}
|
|
347
381
|
|
|
382
|
+
/** Set once the child's tool scope is installed; until then the gate admits everything. */
|
|
383
|
+
type NestedCallGate = { allows?: (name: string) => boolean }
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Inline extension that applies the subagent's tool scope to calls a tool makes
|
|
387
|
+
* through `ctx.executeTool()` — codemode scripts calling MCP tools. pi runs those
|
|
388
|
+
* past extensions' `tool_call` handlers only, never `agent.beforeToolCall`, and
|
|
389
|
+
* every registered `codemode`/`deferred` tool is callable there whether or not
|
|
390
|
+
* it is in scope.
|
|
391
|
+
*/
|
|
392
|
+
const SCOPE_GATE_NAME = "pi-subagents-scope"
|
|
393
|
+
|
|
394
|
+
function scopeGateExtension(gate: NestedCallGate): InlineExtension {
|
|
395
|
+
return {
|
|
396
|
+
name: SCOPE_GATE_NAME,
|
|
397
|
+
hidden: true,
|
|
398
|
+
factory: (ext) => {
|
|
399
|
+
ext.on("tool_call", (event) =>
|
|
400
|
+
gate.allows?.(event.toolName) === false
|
|
401
|
+
? {
|
|
402
|
+
block: true,
|
|
403
|
+
reason: `Tool "${event.toolName}" is not available to this subagent.`,
|
|
404
|
+
}
|
|
405
|
+
: undefined,
|
|
406
|
+
)
|
|
407
|
+
},
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
348
411
|
/** Default max turns. undefined = unlimited (no turn limit). */
|
|
349
412
|
let defaultMaxTurns: number | undefined
|
|
350
413
|
|
|
@@ -459,6 +522,7 @@ export interface RunOptions {
|
|
|
459
522
|
model?: Model<any>
|
|
460
523
|
maxTurns?: number
|
|
461
524
|
signal?: AbortSignal
|
|
525
|
+
/** Applies only when the agent file leaves `isolated:` unset — frontmatter is authoritative. */
|
|
462
526
|
isolated?: boolean
|
|
463
527
|
inheritContext?: boolean
|
|
464
528
|
thinkingLevel?: ThinkingLevel
|
|
@@ -708,14 +772,15 @@ export async function runAgent(
|
|
|
708
772
|
if (options.worktreeBase) extras.worktreeBase = options.worktreeBase
|
|
709
773
|
if (options.workflow && !options.structuredOutput) extras.workflowChild = true
|
|
710
774
|
|
|
775
|
+
// Resolved here, not by each caller: mention and RPC spawns pass no `isolated`,
|
|
776
|
+
// and the agent file's value must hold on every path.
|
|
777
|
+
const isolated = agentConfig?.isolated ?? options.isolated
|
|
711
778
|
// Resolve extensions/skills: isolated overrides to false
|
|
712
|
-
const extensions =
|
|
779
|
+
const extensions = isolated ? false : config.extensions
|
|
713
780
|
// Nulling excludes under isolated also suppresses the orphaned-exclude warning —
|
|
714
781
|
// isolation is an intentional override, not a misconfiguration.
|
|
715
|
-
const excludeExtensions =
|
|
716
|
-
|
|
717
|
-
: config.excludeExtensions
|
|
718
|
-
const skills = options.isolated ? false : config.skills
|
|
782
|
+
const excludeExtensions = isolated ? undefined : config.excludeExtensions
|
|
783
|
+
const skills = isolated ? false : config.skills
|
|
719
784
|
|
|
720
785
|
// Skill preloading: when skills is string[], preload their content into prompt
|
|
721
786
|
if (Array.isArray(skills)) {
|
|
@@ -808,7 +873,7 @@ export async function runAgent(
|
|
|
808
873
|
// which extensions load. `ext:foo` against an extension that `extensions:` excluded
|
|
809
874
|
// is an orphan and warns after reload. `isolated` means no extension tools at all.
|
|
810
875
|
const { extNames, narrowing } = parseExtSelectors(
|
|
811
|
-
|
|
876
|
+
isolated ? [] : (agentConfig?.extSelectors ?? []),
|
|
812
877
|
)
|
|
813
878
|
const noExtensions = extensions === false
|
|
814
879
|
|
|
@@ -847,6 +912,7 @@ export async function runAgent(
|
|
|
847
912
|
return {
|
|
848
913
|
...base,
|
|
849
914
|
extensions: base.extensions.filter((e) => {
|
|
915
|
+
if (e.path === `<inline:${SCOPE_GATE_NAME}>`) return true // ours, not the agent's
|
|
850
916
|
const canons = extensionCanonicalNames(e.path)
|
|
851
917
|
if (canons.some((n) => excludeNames.has(n))) return false // exclude wins
|
|
852
918
|
return loadAll || canons.some((n) => keepNames.has(n))
|
|
@@ -854,7 +920,68 @@ export async function runAgent(
|
|
|
854
920
|
}
|
|
855
921
|
}
|
|
856
922
|
|
|
923
|
+
// The child inherits the parent's project-trust decision. pi's SettingsManager
|
|
924
|
+
// defaults to trusted, so without this a subagent in a project the user declined
|
|
925
|
+
// to trust would load its `.pi` extensions and settings, and pi's built-in MCP
|
|
926
|
+
// would start the stdio servers in its `.pi/mcp.json`.
|
|
927
|
+
const projectTrusted = ctx.isProjectTrusted()
|
|
928
|
+
const settingsManager = SettingsManager.create(configCwd, agentDir, {
|
|
929
|
+
projectTrusted,
|
|
930
|
+
})
|
|
931
|
+
// That trust covers the parent's project (`configCwd`), not a directory a spawn
|
|
932
|
+
// was sent into. The session answers `ctx.isProjectTrusted()` for its own cwd,
|
|
933
|
+
// which pi's MCP uses to read `<cwd>/.pi/mcp.json` — so a cross-directory session
|
|
934
|
+
// keeps the parent project's settings but reports its cwd as untrusted. The
|
|
935
|
+
// loader keeps the trusted manager: it loads the parent project's extensions.
|
|
936
|
+
const sessionSettingsManager =
|
|
937
|
+
resolve(effectiveCwd) === resolve(configCwd)
|
|
938
|
+
? settingsManager
|
|
939
|
+
: Object.assign(
|
|
940
|
+
SettingsManager.create(configCwd, agentDir, { projectTrusted }),
|
|
941
|
+
{
|
|
942
|
+
isProjectTrusted: () => false,
|
|
943
|
+
},
|
|
944
|
+
)
|
|
945
|
+
const nestedCallGate: NestedCallGate = {}
|
|
946
|
+
|
|
857
947
|
const loader = new DefaultResourceLoader({
|
|
948
|
+
settingsManager,
|
|
949
|
+
// pi's CLI hands its built-in extensions to the loader it builds; this one starts
|
|
950
|
+
// with none. Forwarded as `builtin:` resources they load as in the main session —
|
|
951
|
+
// default-on, disabled by `-builtin:<name>` in settings — and the per-agent
|
|
952
|
+
// allowlist filters them like any other extension. pi's `llama.cpp` built-in is
|
|
953
|
+
// CLI-only (not exported). Fresh factories per spawn: MCP state lives in the closure.
|
|
954
|
+
extensionFactories: [
|
|
955
|
+
{
|
|
956
|
+
name: "codemode",
|
|
957
|
+
factory: createCodemodeExtension(),
|
|
958
|
+
builtin: true,
|
|
959
|
+
replaceable: true,
|
|
960
|
+
},
|
|
961
|
+
{
|
|
962
|
+
name: "tool-search",
|
|
963
|
+
factory: createToolSearchExtension(),
|
|
964
|
+
builtin: true,
|
|
965
|
+
replaceable: true,
|
|
966
|
+
},
|
|
967
|
+
{
|
|
968
|
+
name: "mcp",
|
|
969
|
+
factory: createMcpExtension(),
|
|
970
|
+
builtin: true,
|
|
971
|
+
replaceable: true,
|
|
972
|
+
},
|
|
973
|
+
// Under `noExtensions` the static allowlist gates the registry itself.
|
|
974
|
+
...(noExtensions ? [] : [scopeGateExtension(nestedCallGate)]),
|
|
975
|
+
],
|
|
976
|
+
// Mirrors pi's CLI: `--no-mcp` keeps MCP out of subagents too, and `--no-extensions`
|
|
977
|
+
// keeps all the built-ins out. Discovered extensions still follow `extensions:`.
|
|
978
|
+
disabledBuiltinExtensions: process.argv.some(
|
|
979
|
+
(a) => a === "--no-extensions" || a === "-ne",
|
|
980
|
+
)
|
|
981
|
+
? ["codemode", "tool-search", "mcp"]
|
|
982
|
+
: process.argv.includes("--no-mcp")
|
|
983
|
+
? ["mcp"]
|
|
984
|
+
: undefined,
|
|
858
985
|
cwd: configCwd,
|
|
859
986
|
agentDir,
|
|
860
987
|
noExtensions,
|
|
@@ -967,7 +1094,7 @@ export async function runAgent(
|
|
|
967
1094
|
? options.nestedRuntime
|
|
968
1095
|
: undefined
|
|
969
1096
|
const nestedTools =
|
|
970
|
-
agentConfig?.allowedSubagents && nestedRuntime && !
|
|
1097
|
+
agentConfig?.allowedSubagents && nestedRuntime && !isolated
|
|
971
1098
|
? createNestedSubagentTools({
|
|
972
1099
|
manager: nestedRuntime.manager,
|
|
973
1100
|
pi: options.pi,
|
|
@@ -1075,7 +1202,6 @@ export async function runAgent(
|
|
|
1075
1202
|
sessionExcludeTools = [...denyTools]
|
|
1076
1203
|
}
|
|
1077
1204
|
|
|
1078
|
-
const settingsManager = SettingsManager.create(configCwd, agentDir)
|
|
1079
1205
|
const configuredSessionDir = resolveConfiguredSessionDir(
|
|
1080
1206
|
agentConfig?.sessionDir,
|
|
1081
1207
|
effectiveCwd,
|
|
@@ -1125,7 +1251,7 @@ export async function runAgent(
|
|
|
1125
1251
|
cwd: effectiveCwd,
|
|
1126
1252
|
agentDir,
|
|
1127
1253
|
sessionManager,
|
|
1128
|
-
settingsManager,
|
|
1254
|
+
settingsManager: sessionSettingsManager,
|
|
1129
1255
|
modelRegistry: ctx.modelRegistry,
|
|
1130
1256
|
// `as never` is what keeps this assignable across the supported Pi range:
|
|
1131
1257
|
// pre-0.80.8 the field exists only via the `modelRuntime?: unknown` shim
|
|
@@ -1184,6 +1310,7 @@ export async function runAgent(
|
|
|
1184
1310
|
extNames,
|
|
1185
1311
|
narrowing,
|
|
1186
1312
|
readmitToolNames,
|
|
1313
|
+
nestedCallGate,
|
|
1187
1314
|
})
|
|
1188
1315
|
}
|
|
1189
1316
|
|
|
@@ -1267,7 +1394,10 @@ export async function runAgent(
|
|
|
1267
1394
|
const startLen = session.messages.length
|
|
1268
1395
|
let structuredRetried = false
|
|
1269
1396
|
try {
|
|
1270
|
-
|
|
1397
|
+
// A signal that fired before this point never calls the listener above, and
|
|
1398
|
+
// pi's `session.abort()` is a no-op until a run is active — so a child whose
|
|
1399
|
+
// parent is already gone is not prompted at all.
|
|
1400
|
+
if (options.signal?.aborted !== true) await session.prompt(effectivePrompt)
|
|
1271
1401
|
|
|
1272
1402
|
// One more prompt when a schema was asked for and nothing usable came back
|
|
1273
1403
|
// — the model answered in prose, or only ever called the tool invalidly.
|
|
@@ -1374,7 +1504,8 @@ export async function resumeAgent(
|
|
|
1374
1504
|
: () => {}
|
|
1375
1505
|
|
|
1376
1506
|
try {
|
|
1377
|
-
|
|
1507
|
+
// Already aborted: not prompted, as in `runAgent`.
|
|
1508
|
+
if (options.signal?.aborted !== true) await session.prompt(prompt)
|
|
1378
1509
|
} finally {
|
|
1379
1510
|
collector.unsubscribe()
|
|
1380
1511
|
unsubEvents()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-source-badge.ts — How `/agents → Agent types` shows where an agent came
|
|
3
|
+
* from: the per-row badge, the legend that explains the badges actually on
|
|
4
|
+
* screen, and the description line under the highlighted row.
|
|
5
|
+
*
|
|
6
|
+
* These live outside src/index.ts for the same reason as agent-file-toggle.ts:
|
|
7
|
+
* the `/agents` handler is a large closure reached only through
|
|
8
|
+
* `registerCommand`, which every test mocks, so nothing inside it can be
|
|
9
|
+
* asserted on directly.
|
|
10
|
+
*
|
|
11
|
+
* The badges are deliberately terse — the list is one row per agent and the
|
|
12
|
+
* model already occupies the right-hand column. Anything wordier than a glyph
|
|
13
|
+
* (the package's name, in particular) goes on the description line, which
|
|
14
|
+
* `SettingsList` renders for the highlighted row only.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { AgentConfig } from "./types.js"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The badge column: a two-cell glyph plus a space, so every label starts at the
|
|
21
|
+
* same offset whether or not the agent is badged.
|
|
22
|
+
*
|
|
23
|
+
* Built-in defaults are unmarked. They are the majority of the list, and a
|
|
24
|
+
* badge on them would make the three that matter — the agents the user or a
|
|
25
|
+
* package put there — harder to pick out, not easier.
|
|
26
|
+
*/
|
|
27
|
+
export function sourceIndicator(cfg: AgentConfig | undefined): string {
|
|
28
|
+
const disabled = cfg?.enabled === false
|
|
29
|
+
if (cfg?.source === "project") return disabled ? "✕• " : "• "
|
|
30
|
+
if (cfg?.source === "global") return disabled ? "✕◦ " : "◦ "
|
|
31
|
+
if (cfg?.source === "package") return disabled ? "✕▪ " : "▪ "
|
|
32
|
+
if (disabled) return "✕ "
|
|
33
|
+
return " "
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The legend line, naming only the badges the given roster actually renders.
|
|
38
|
+
*
|
|
39
|
+
* Keyed on what `sourceIndicator` would emit, so the two cannot drift into a
|
|
40
|
+
* glyph with no key (which is what `▪` was) or a key for a glyph that is not on
|
|
41
|
+
* screen. A defaults-only roster returns "", and the caller omits the line.
|
|
42
|
+
*
|
|
43
|
+
* `source` is undefined for built-in defaults — the `"default"` member of the
|
|
44
|
+
* union is declared but never assigned, since default-agents.ts sets only
|
|
45
|
+
* `isDefault` — so they contribute no key, matching their blank badge.
|
|
46
|
+
*/
|
|
47
|
+
export function sourceLegend(
|
|
48
|
+
cfgs: readonly (AgentConfig | undefined)[],
|
|
49
|
+
): string {
|
|
50
|
+
const parts: string[] = []
|
|
51
|
+
if (cfgs.some((c) => c?.source === "project")) parts.push("• = project")
|
|
52
|
+
if (cfgs.some((c) => c?.source === "global")) parts.push("◦ = global")
|
|
53
|
+
if (cfgs.some((c) => c?.source === "package")) parts.push("▪ = package")
|
|
54
|
+
if (cfgs.some((c) => c?.enabled === false)) parts.push("✕ = disabled")
|
|
55
|
+
return parts.join(" ")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The description line under the highlighted row.
|
|
60
|
+
*
|
|
61
|
+
* `packageName` is the one piece of provenance a badge cannot carry: with two
|
|
62
|
+
* agent-providing packages installed, `▪` says only that *some* package won.
|
|
63
|
+
* It is prefixed rather than appended so it stays visible when a long
|
|
64
|
+
* description wraps, and it prefixes "(disabled)" too — a disabled package
|
|
65
|
+
* agent is exactly the case where you want to know whose it was.
|
|
66
|
+
*
|
|
67
|
+
* Falls back to the agent's own name when it has no description, which is what
|
|
68
|
+
* a hand-written agent file without one already showed.
|
|
69
|
+
*/
|
|
70
|
+
export function rowDescription(
|
|
71
|
+
name: string,
|
|
72
|
+
cfg: AgentConfig | undefined,
|
|
73
|
+
packageName?: string,
|
|
74
|
+
): string {
|
|
75
|
+
const base =
|
|
76
|
+
cfg?.enabled === false ? "(disabled)" : (cfg?.description ?? name)
|
|
77
|
+
return packageName ? `${packageName} · ${base}` : base
|
|
78
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* completion-outbox.ts — When background completions reach the parent.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code queues each completion and, after every tool batch, hands the
|
|
5
|
+
* model everything that queued up in one go: a busy parent sees what finished
|
|
6
|
+
* during the batch before its next model call, and nothing waits for a sibling.
|
|
7
|
+
* pi's steering queue would split those into one model call each under its
|
|
8
|
+
* default `one-at-a-time` mode, so the outbox holds them and sends ONE message
|
|
9
|
+
* at the boundary instead.
|
|
10
|
+
*
|
|
11
|
+
* - Parent busy: held until `turn_end`, which pi emits after the tool batch and
|
|
12
|
+
* before it reads the steering queue.
|
|
13
|
+
* - Parent idle: sent after a short hold, so near-simultaneous completions
|
|
14
|
+
* share a turn and `get_subagent_result` still gets a moment to consume one.
|
|
15
|
+
* - Parent turn aborted (Esc) or failed: held through it — pi clears its own
|
|
16
|
+
* queues on Esc — and sent once the run has ended, starting a turn. Claude
|
|
17
|
+
* Code keeps an agent's completion queued through Esc the same way and runs
|
|
18
|
+
* it as soon as the interrupted turn stops.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { NotificationDetails } from "./types.js"
|
|
22
|
+
|
|
23
|
+
/** How long an idle parent's notification is held before it is sent. */
|
|
24
|
+
export const NOTIFICATION_HOLD_MS = 200
|
|
25
|
+
|
|
26
|
+
export interface OutboxMessage {
|
|
27
|
+
content: string
|
|
28
|
+
details: NotificationDetails
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A notification for one or more agents, built when they finished: a resume
|
|
33
|
+
* before the send must not rewrite it. Reading an agent's result cancels its
|
|
34
|
+
* id, and an item left with none is dropped; `build` gets the ids still pending.
|
|
35
|
+
*/
|
|
36
|
+
export interface OutboxItem {
|
|
37
|
+
ids: string[]
|
|
38
|
+
build(ids: readonly string[]): OutboxMessage
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class CompletionOutbox {
|
|
42
|
+
private items: OutboxItem[] = []
|
|
43
|
+
private timer: ReturnType<typeof setTimeout> | undefined
|
|
44
|
+
|
|
45
|
+
constructor(
|
|
46
|
+
/** Delivers one message as a steer that triggers a turn when the parent is idle. */
|
|
47
|
+
private send: (message: OutboxMessage) => void,
|
|
48
|
+
/** Whether the parent has a run in flight, so a `turn_end` is coming. */
|
|
49
|
+
private parentRunning: () => boolean,
|
|
50
|
+
) {}
|
|
51
|
+
|
|
52
|
+
/** A newer completion of the same agent (a resume) replaces its pending one. */
|
|
53
|
+
add(item: OutboxItem): void {
|
|
54
|
+
for (const id of item.ids) this.cancel(id)
|
|
55
|
+
this.items.push(item)
|
|
56
|
+
if (!this.parentRunning()) this.arm()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The parent finished a turn: its tool batch is done, the next model call is not. */
|
|
60
|
+
onTurnEnd(stopReason: string | undefined): void {
|
|
61
|
+
// An aborted turn ends its run; a failed one may be retried. Either way
|
|
62
|
+
// what is held waits for the next boundary or the run's end — sent now,
|
|
63
|
+
// it would sit in a queue pi is about to clear.
|
|
64
|
+
if (stopReason !== "aborted" && stopReason !== "error") this.flush()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The agent's result was read: it must not also be notified. */
|
|
68
|
+
cancel(id: string): void {
|
|
69
|
+
for (const item of this.items) item.ids = item.ids.filter((i) => i !== id)
|
|
70
|
+
this.items = this.items.filter((item) => item.ids.length > 0)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Completions that landed after the run's last `turn_end` would otherwise wait for the next run.
|
|
75
|
+
* Called at `agent_end` and again at `agent_settled`: one can land while the ending run is still open.
|
|
76
|
+
*/
|
|
77
|
+
onAgentEnd(): void {
|
|
78
|
+
if (this.items.length > 0) this.arm()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
dispose(): void {
|
|
82
|
+
if (this.timer) clearTimeout(this.timer)
|
|
83
|
+
this.timer = undefined
|
|
84
|
+
this.items = []
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Not reset by later items: the first completion is never pushed back. */
|
|
88
|
+
private arm(): void {
|
|
89
|
+
this.timer ??= setTimeout(() => {
|
|
90
|
+
this.timer = undefined
|
|
91
|
+
this.flush()
|
|
92
|
+
}, NOTIFICATION_HOLD_MS)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private flush(): void {
|
|
96
|
+
if (this.timer) clearTimeout(this.timer)
|
|
97
|
+
this.timer = undefined
|
|
98
|
+
const items = this.items
|
|
99
|
+
this.items = []
|
|
100
|
+
const messages = items.map((item) => item.build(item.ids))
|
|
101
|
+
if (messages.length === 0) return
|
|
102
|
+
// A send from a timer can outlive the session it was meant for (pi -p
|
|
103
|
+
// tearing down): pi throws on the stale context, and nothing is left to tell.
|
|
104
|
+
try {
|
|
105
|
+
this.send(combine(messages))
|
|
106
|
+
} catch {
|
|
107
|
+
/* stale context */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** One message stays as it was built; several become one message with every agent's details. */
|
|
113
|
+
function combine(messages: OutboxMessage[]): OutboxMessage {
|
|
114
|
+
if (messages.length === 1) return messages[0]
|
|
115
|
+
const [first, ...rest] = messages.flatMap(
|
|
116
|
+
({ details: { others, ...own } }) => [own, ...(others ?? [])],
|
|
117
|
+
)
|
|
118
|
+
return {
|
|
119
|
+
content: `${messages.length} background tasks finished.\n\n${messages.map((m) => m.content).join("\n\n")}`,
|
|
120
|
+
details: { ...first, others: rest },
|
|
121
|
+
}
|
|
122
|
+
}
|