@cad0p/pi-subagents-tintinweb 0.14.2 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -1
- package/CONTRIBUTING.md +8 -0
- package/README.md +98 -58
- package/docs/fork-differences.md +51 -0
- package/docs/verification-checklist.md +132 -0
- package/examples/agent-tool-description.md +3 -9
- package/package.json +13 -11
- package/pnpm-workspace.yaml +9 -0
- package/src/abortable.ts +43 -0
- package/src/agent-manager.ts +404 -140
- package/src/agent-runner.ts +462 -89
- package/src/agent-types.ts +269 -23
- package/src/checkpoint.ts +49 -0
- package/src/child-context.ts +30 -0
- package/src/cross-extension-rpc.ts +65 -5
- package/src/custom-agents.ts +161 -14
- package/src/default-agents.ts +1 -1
- package/src/index.ts +1014 -980
- package/src/invocation-config.ts +81 -12
- package/src/model-resolver.ts +18 -0
- package/src/model-scope.ts +62 -0
- package/src/nested-tools.ts +406 -0
- package/src/output-file.ts +18 -1
- package/src/prompts.ts +71 -0
- package/src/result-report.ts +170 -0
- package/src/schedule-store.ts +300 -13
- package/src/schedule.ts +222 -77
- package/src/settings.ts +206 -32
- package/src/status-note.ts +18 -1
- package/src/text-safety.ts +69 -0
- package/src/types.ts +101 -58
- package/src/ui/agent-widget.ts +104 -36
- package/src/ui/conversation-viewer.ts +36 -18
- package/src/ui/fleet-list.ts +19 -8
- package/src/ui/schedule-menu.ts +63 -19
- package/src/usage.ts +128 -8
- package/src/worktree.ts +19 -0
- package/src/group-join.ts +0 -141
package/src/agent-types.ts
CHANGED
|
@@ -5,7 +5,18 @@
|
|
|
5
5
|
* User agents override defaults with the same name. Disabled agents are kept but excluded from spawning.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
createBashToolDefinition,
|
|
10
|
+
createCodingTools,
|
|
11
|
+
createEditToolDefinition,
|
|
12
|
+
createFindToolDefinition,
|
|
13
|
+
createGrepToolDefinition,
|
|
14
|
+
createLsToolDefinition,
|
|
15
|
+
createPowerShellToolDefinition,
|
|
16
|
+
createReadOnlyTools,
|
|
17
|
+
createReadToolDefinition,
|
|
18
|
+
createWriteToolDefinition,
|
|
19
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
20
|
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
10
21
|
import type { AgentConfig } from "./types.js";
|
|
11
22
|
|
|
@@ -21,6 +32,40 @@ export const BUILTIN_TOOL_NAMES: string[] = [
|
|
|
21
32
|
...new Set([...createCodingTools("."), ...createReadOnlyTools(".")].map((t) => t.name)),
|
|
22
33
|
];
|
|
23
34
|
|
|
35
|
+
/**
|
|
36
|
+
* One-line `<tools>` snippets for the built-in tools, keyed by name.
|
|
37
|
+
*
|
|
38
|
+
* An append-mode child inherits the parent's `<tools>` block, which lists only
|
|
39
|
+
* the tools the parent has *active* — pi's default set is read/bash/edit/write —
|
|
40
|
+
* while an agent's `tools:` defaults to every built-in, grep/find/ls included.
|
|
41
|
+
* Retargeting the block from the inherited text alone would therefore advertise
|
|
42
|
+
* fewer tools than the child can call. These come from pi's own definitions, so
|
|
43
|
+
* the wording cannot drift from what pi prints in a main session.
|
|
44
|
+
*/
|
|
45
|
+
let builtinToolSnippets: Record<string, string> | undefined;
|
|
46
|
+
|
|
47
|
+
/** Snippets for pi's built-in tools, built on first use (see above). */
|
|
48
|
+
export function getBuiltinToolSnippets(): Record<string, string> {
|
|
49
|
+
// Built by calling pi's own factories rather than importing a snippet table, so
|
|
50
|
+
// the wording cannot drift. Called with the process cwd: a snippet is static text
|
|
51
|
+
// and nothing here executes the tool.
|
|
52
|
+
builtinToolSnippets ??= Object.fromEntries(
|
|
53
|
+
[
|
|
54
|
+
createReadToolDefinition("."),
|
|
55
|
+
createBashToolDefinition("."),
|
|
56
|
+
createEditToolDefinition("."),
|
|
57
|
+
createWriteToolDefinition("."),
|
|
58
|
+
createGrepToolDefinition("."),
|
|
59
|
+
createFindToolDefinition("."),
|
|
60
|
+
createLsToolDefinition("."),
|
|
61
|
+
createPowerShellToolDefinition("."),
|
|
62
|
+
].flatMap((definition) =>
|
|
63
|
+
definition.promptSnippet ? [[definition.name, definition.promptSnippet] as const] : [],
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
return builtinToolSnippets;
|
|
67
|
+
}
|
|
68
|
+
|
|
24
69
|
/** Unified runtime registry of all agents (defaults + user-defined). */
|
|
25
70
|
const agents = new Map<string, AgentConfig>();
|
|
26
71
|
|
|
@@ -33,6 +78,47 @@ export function isDefaultsDisabled(): boolean { return disableDefaults; }
|
|
|
33
78
|
/** Set whether default agents are disabled. */
|
|
34
79
|
export function setDefaultsDisabled(b: boolean): void { disableDefaults = b; }
|
|
35
80
|
|
|
81
|
+
/** `fallbackSubagent` value that disables the fallback entirely (strict dispatch). */
|
|
82
|
+
export const NO_FALLBACK = "none";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The agent type an unset `fallbackSubagent` targets. Naming it explicitly is
|
|
86
|
+
* equivalent to leaving the setting unset — see `resolveSpawnTypeIn`.
|
|
87
|
+
*/
|
|
88
|
+
export const IMPLICIT_FALLBACK = "general-purpose";
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Agent type substituted when a caller-supplied `subagent_type` doesn't resolve
|
|
92
|
+
* to exactly one enabled agent. `undefined` keeps the historical behavior
|
|
93
|
+
* (general-purpose); `NO_FALLBACK` makes dispatch fail closed. Set from
|
|
94
|
+
* `subagents.json` (`fallbackSubagent`).
|
|
95
|
+
*
|
|
96
|
+
* Module state rather than an index.ts closure because every caller-supplied
|
|
97
|
+
* spawn path needs it — the Agent tool, the scheduler, and cross-extension RPC.
|
|
98
|
+
*/
|
|
99
|
+
let fallbackSubagent: string | undefined;
|
|
100
|
+
|
|
101
|
+
/** Get the configured fallback agent type. undefined = general-purpose. */
|
|
102
|
+
export function getFallbackSubagent(): string | undefined { return fallbackSubagent; }
|
|
103
|
+
|
|
104
|
+
/** Set the configured fallback agent type. undefined = general-purpose. */
|
|
105
|
+
export function setFallbackSubagent(v: string | undefined): void { fallbackSubagent = v; }
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build a registry map: DEFAULT_AGENTS first (unless disabled via settings),
|
|
109
|
+
* then user agents overlaid on top (same name overrides the default).
|
|
110
|
+
* Pure — callers that must not disturb the process-wide registry (nested
|
|
111
|
+
* delegation resolving agents from its own config root) build their own map.
|
|
112
|
+
*/
|
|
113
|
+
export function buildAgentRegistry(userAgents: Map<string, AgentConfig>): Map<string, AgentConfig> {
|
|
114
|
+
const registry = new Map<string, AgentConfig>();
|
|
115
|
+
if (!disableDefaults) {
|
|
116
|
+
for (const [name, config] of DEFAULT_AGENTS) registry.set(name, config);
|
|
117
|
+
}
|
|
118
|
+
for (const [name, config] of userAgents) registry.set(name, config);
|
|
119
|
+
return registry;
|
|
120
|
+
}
|
|
121
|
+
|
|
36
122
|
/**
|
|
37
123
|
* Register agents into the unified registry.
|
|
38
124
|
* Starts with DEFAULT_AGENTS, then overlays user agents (overrides defaults with same name).
|
|
@@ -40,30 +126,51 @@ export function setDefaultsDisabled(b: boolean): void { disableDefaults = b; }
|
|
|
40
126
|
*/
|
|
41
127
|
export function registerAgents(userAgents: Map<string, AgentConfig>): void {
|
|
42
128
|
agents.clear();
|
|
43
|
-
|
|
44
|
-
// Start with defaults (unless disabled via settings)
|
|
45
|
-
if (!disableDefaults) {
|
|
46
|
-
for (const [name, config] of DEFAULT_AGENTS) {
|
|
47
|
-
agents.set(name, config);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Overlay user agents (overrides defaults with same name)
|
|
52
|
-
for (const [name, config] of userAgents) {
|
|
129
|
+
for (const [name, config] of buildAgentRegistry(userAgents)) {
|
|
53
130
|
agents.set(name, config);
|
|
54
131
|
}
|
|
55
132
|
}
|
|
56
133
|
|
|
57
|
-
/** Case-insensitive key resolution. */
|
|
58
|
-
function
|
|
59
|
-
if (
|
|
134
|
+
/** Case-insensitive key resolution within a registry. */
|
|
135
|
+
function resolveKeyIn(registry: Map<string, AgentConfig>, name: string): string | undefined {
|
|
136
|
+
if (registry.has(name)) return name;
|
|
60
137
|
const lower = name.toLowerCase();
|
|
61
|
-
for (const key of
|
|
138
|
+
for (const key of registry.keys()) {
|
|
62
139
|
if (key.toLowerCase() === lower) return key;
|
|
63
140
|
}
|
|
64
141
|
return undefined;
|
|
65
142
|
}
|
|
66
143
|
|
|
144
|
+
/** Case-insensitive key resolution. */
|
|
145
|
+
function resolveKey(name: string): string | undefined {
|
|
146
|
+
return resolveKeyIn(agents, name);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Resolve a type name case-insensitively in a registry. Returns the canonical key or undefined. */
|
|
150
|
+
export function resolveTypeIn(registry: Map<string, AgentConfig>, name: string): string | undefined {
|
|
151
|
+
return resolveKeyIn(registry, name);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Get the agent config for a type (case-insensitive) from a registry. */
|
|
155
|
+
export function getAgentConfigIn(registry: Map<string, AgentConfig>, name: string): AgentConfig | undefined {
|
|
156
|
+
const key = resolveKeyIn(registry, name);
|
|
157
|
+
return key ? registry.get(key) : undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Check if a type is valid and enabled (case-insensitive) in a registry. */
|
|
161
|
+
export function isValidTypeIn(registry: Map<string, AgentConfig>, type: string): boolean {
|
|
162
|
+
const key = resolveKeyIn(registry, type);
|
|
163
|
+
if (!key) return false;
|
|
164
|
+
return registry.get(key)?.enabled !== false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Get all enabled type names in a registry (for spawning and tool descriptions). */
|
|
168
|
+
export function getAvailableTypesIn(registry: Map<string, AgentConfig>): string[] {
|
|
169
|
+
return [...registry.entries()]
|
|
170
|
+
.filter(([_, config]) => config.enabled !== false)
|
|
171
|
+
.map(([name]) => name);
|
|
172
|
+
}
|
|
173
|
+
|
|
67
174
|
/** Resolve a type name case-insensitively. Returns the canonical key or undefined. */
|
|
68
175
|
export function resolveType(name: string): string | undefined {
|
|
69
176
|
return resolveKey(name);
|
|
@@ -71,15 +178,12 @@ export function resolveType(name: string): string | undefined {
|
|
|
71
178
|
|
|
72
179
|
/** Get the agent config for a type (case-insensitive). */
|
|
73
180
|
export function getAgentConfig(name: string): AgentConfig | undefined {
|
|
74
|
-
|
|
75
|
-
return key ? agents.get(key) : undefined;
|
|
181
|
+
return getAgentConfigIn(agents, name);
|
|
76
182
|
}
|
|
77
183
|
|
|
78
184
|
/** Get all enabled type names (for spawning and tool descriptions). */
|
|
79
185
|
export function getAvailableTypes(): string[] {
|
|
80
|
-
return
|
|
81
|
-
.filter(([_, config]) => config.enabled !== false)
|
|
82
|
-
.map(([name]) => name);
|
|
186
|
+
return getAvailableTypesIn(agents);
|
|
83
187
|
}
|
|
84
188
|
|
|
85
189
|
/** Get all type names including disabled (for UI listing). */
|
|
@@ -103,9 +207,146 @@ export function getUserAgentNames(): string[] {
|
|
|
103
207
|
|
|
104
208
|
/** Check if a type is valid and enabled (case-insensitive). */
|
|
105
209
|
export function isValidType(type: string): boolean {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
210
|
+
return isValidTypeIn(agents, type);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Case-insensitive resolution that refuses to guess. An exact match always wins;
|
|
215
|
+
* otherwise the name must match exactly one key. Two agents differing only in
|
|
216
|
+
* case are reachable (`loadCustomAgents` keys by filename across three
|
|
217
|
+
* directories), and picking whichever came first would silently dispatch a
|
|
218
|
+
* different agent, model and tool policy than the caller meant.
|
|
219
|
+
*/
|
|
220
|
+
function resolveUnambiguousKeyIn(registry: Map<string, AgentConfig>, name: string): string | undefined {
|
|
221
|
+
if (registry.has(name)) return name;
|
|
222
|
+
const lower = name.toLowerCase();
|
|
223
|
+
const matches = [...registry.keys()].filter(key => key.toLowerCase() === lower);
|
|
224
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Whether any key in the registry matches `name` case-insensitively. */
|
|
228
|
+
function isNameClaimed(registry: Map<string, AgentConfig>, name: string): boolean {
|
|
229
|
+
const lower = name.toLowerCase();
|
|
230
|
+
return [...registry.keys()].some((key) => key.toLowerCase() === lower);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The canonical key for a caller-supplied name that identifies exactly one
|
|
235
|
+
* ENABLED agent, or undefined. Strict by construction: no fallback, no guessing
|
|
236
|
+
* between case-variants.
|
|
237
|
+
*/
|
|
238
|
+
export function resolveEnabledTypeIn(
|
|
239
|
+
registry: Map<string, AgentConfig>,
|
|
240
|
+
requested: unknown,
|
|
241
|
+
): string | undefined {
|
|
242
|
+
const raw = typeof requested === "string" ? requested.trim() : "";
|
|
243
|
+
if (!raw) return undefined;
|
|
244
|
+
const key = resolveUnambiguousKeyIn(registry, raw);
|
|
245
|
+
return key !== undefined && registry.get(key)?.enabled !== false ? key : undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Outcome of resolving a caller-supplied `subagent_type` into a spawnable type. */
|
|
249
|
+
export type SpawnTypeResolution =
|
|
250
|
+
/** Spawn this type. `fellBackFrom` is set when it isn't what the caller asked for. */
|
|
251
|
+
| { ok: true; type: string; fellBackFrom?: string }
|
|
252
|
+
/** Refuse the spawn and return this message to the caller. */
|
|
253
|
+
| { ok: false; message: string };
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Resolve a caller-supplied agent type against a registry, applying the
|
|
257
|
+
* `fallbackSubagent` policy. The single decision point for every caller-supplied
|
|
258
|
+
* spawn — the Agent tool, the scheduler and cross-extension RPC — so a type that
|
|
259
|
+
* fails here never reaches `runAgent`, where `getConfig` would silently
|
|
260
|
+
* substitute general-purpose.
|
|
261
|
+
*
|
|
262
|
+
* Unknown, disabled, and case-ambiguous names are all treated the same way:
|
|
263
|
+
* the caller named something that doesn't identify exactly one enabled agent.
|
|
264
|
+
*
|
|
265
|
+
* Pure over `registry` — callers that need fresh agent files reload before
|
|
266
|
+
* calling (the Agent tool already does, per spawn). Reloading here would mean
|
|
267
|
+
* importing custom-agents.ts, which imports this module.
|
|
268
|
+
*/
|
|
269
|
+
export function resolveSpawnTypeIn(
|
|
270
|
+
registry: Map<string, AgentConfig>,
|
|
271
|
+
requested: unknown,
|
|
272
|
+
): SpawnTypeResolution {
|
|
273
|
+
const raw = typeof requested === "string" ? requested.trim() : "";
|
|
274
|
+
const available = () => getAvailableTypesIn(registry).join(", ") || "(none)";
|
|
275
|
+
|
|
276
|
+
const key = resolveEnabledTypeIn(registry, raw);
|
|
277
|
+
if (key !== undefined) return { ok: true, type: key };
|
|
278
|
+
|
|
279
|
+
// A missing type follows the same policy as a wrong one rather than always
|
|
280
|
+
// erroring: before this setting existed an empty type fell back like any
|
|
281
|
+
// other unresolvable name, and only opting in should change that.
|
|
282
|
+
const reason = raw ? `Unknown or disabled agent type: "${raw}".` : "No agent type given.";
|
|
283
|
+
|
|
284
|
+
// Trimmed like `requested`: a padded value set programmatically would
|
|
285
|
+
// otherwise be reported as a missing agent.
|
|
286
|
+
const configured = typeof fallbackSubagent === "string" ? fallbackSubagent.trim() : undefined;
|
|
287
|
+
|
|
288
|
+
if (configured !== undefined && configured.toLowerCase() === NO_FALLBACK) {
|
|
289
|
+
return { ok: false, message: `${reason} Available: ${available()}.` };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (configured !== undefined) {
|
|
293
|
+
const fallbackKey = resolveUnambiguousKeyIn(registry, configured);
|
|
294
|
+
if (fallbackKey === undefined || registry.get(fallbackKey)?.enabled === false) {
|
|
295
|
+
// Naming the implicit default is not a misconfiguration when nothing in the
|
|
296
|
+
// registry claims the name at all: an unset fallback already targets it, and
|
|
297
|
+
// the permissive hardcoded tier is what that resolves to even when the
|
|
298
|
+
// default agents are disabled — the case the settings row's own default
|
|
299
|
+
// cycles through. A registered-but-disabled general-purpose, or a
|
|
300
|
+
// case-ambiguous name, stays an error like any other: someone wrote that on
|
|
301
|
+
// purpose, and quietly running the all-tools tier is what `fallbackSubagent`
|
|
302
|
+
// exists to prevent. `none` is how strict dispatch is expressed.
|
|
303
|
+
if (configured.toLowerCase() === IMPLICIT_FALLBACK && !isNameClaimed(registry, configured)) {
|
|
304
|
+
return { ok: true, type: IMPLICIT_FALLBACK, fellBackFrom: raw };
|
|
305
|
+
}
|
|
306
|
+
// An explicitly configured fallback that is itself unusable is a
|
|
307
|
+
// misconfiguration, not a second chance to guess — say so rather than
|
|
308
|
+
// quietly dropping to general-purpose.
|
|
309
|
+
return {
|
|
310
|
+
ok: false,
|
|
311
|
+
message:
|
|
312
|
+
`${reason} The configured fallbackSubagent "${configured}" is itself ` +
|
|
313
|
+
`unknown or disabled. Available: ${available()}.`,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
return { ok: true, type: fallbackKey, fellBackFrom: raw };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Unset: historical behavior, deliberately unchanged. The fallback stays the
|
|
320
|
+
// default, so the pre-existing hole it leaves — an unregistered
|
|
321
|
+
// general-purpose resolving to `getConfig`'s all-tools hardcoded tier — is
|
|
322
|
+
// what `fallbackSubagent: none` is for, not something to close under everyone
|
|
323
|
+
// silently.
|
|
324
|
+
return { ok: true, type: IMPLICIT_FALLBACK, fellBackFrom: raw };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Resolve a caller-supplied agent type against the process-wide registry. */
|
|
328
|
+
export function resolveSpawnType(requested: unknown): SpawnTypeResolution {
|
|
329
|
+
return resolveSpawnTypeIn(agents, requested);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Whether a configured `fallbackSubagent` names something dispatch would accept.
|
|
334
|
+
*
|
|
335
|
+
* Mirrors `resolveSpawnTypeIn`'s policy exactly, including the ambiguity over ALL
|
|
336
|
+
* registry keys (not just the enabled ones) and the implicit default's unregistered
|
|
337
|
+
* case. The settings row uses this so it cannot tell the user a value is fine while
|
|
338
|
+
* dispatch refuses it, or the reverse.
|
|
339
|
+
*/
|
|
340
|
+
export function isFallbackResolvableIn(registry: Map<string, AgentConfig>, configured: string): boolean {
|
|
341
|
+
const name = configured.trim();
|
|
342
|
+
const key = resolveUnambiguousKeyIn(registry, name);
|
|
343
|
+
if (key !== undefined) return registry.get(key)?.enabled !== false;
|
|
344
|
+
return name.toLowerCase() === IMPLICIT_FALLBACK && !isNameClaimed(registry, name);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Whether a configured fallback resolves, against the process-wide registry. */
|
|
348
|
+
export function isFallbackResolvable(configured: string): boolean {
|
|
349
|
+
return isFallbackResolvableIn(agents, configured);
|
|
109
350
|
}
|
|
110
351
|
|
|
111
352
|
/** Tool names required for memory management. */
|
|
@@ -187,3 +428,8 @@ export function getConfig(type: string): {
|
|
|
187
428
|
};
|
|
188
429
|
}
|
|
189
430
|
|
|
431
|
+
// Seed the defaults at module load so consumers that run before the extension's
|
|
432
|
+
// init (direct store use, tools) see the same registry production uses;
|
|
433
|
+
// registerAgents() re-seeds on every explicit call.
|
|
434
|
+
registerAgents(new Map());
|
|
435
|
+
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* checkpoint.ts — the subagent-facing `checkpoint` write path, shared by the
|
|
3
|
+
* top-level extension registration (every subagent session) and the nested
|
|
4
|
+
* delegation tool set, so the two cannot drift on format or failure handling.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { appendFileSync } from "node:fs";
|
|
8
|
+
import type { AgentRecord } from "./types.js";
|
|
9
|
+
|
|
10
|
+
/** Derive the `.checkpoints.md` path from `outputFile`. Mirrors the
|
|
11
|
+
* `record.outputFile` gate (undefined when suppressed). Appends the suffix
|
|
12
|
+
* unconditionally — a `replace(/\.output$/, ...)` would silently return the
|
|
13
|
+
* transcript path unchanged if `outputFile` ever lacked the suffix. */
|
|
14
|
+
export function checkpointsFilePath(outputFile: string | undefined): string | undefined {
|
|
15
|
+
if (!outputFile) return undefined;
|
|
16
|
+
return `${outputFile}.checkpoints.md`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Record a checkpoint on `record` and append it to the `.checkpoints.md`
|
|
21
|
+
* history. Returns the tool text for the calling subagent.
|
|
22
|
+
*
|
|
23
|
+
* Best-effort: a write failure (disk full, missing parent dir) returns a soft
|
|
24
|
+
* warning instead of crashing the call. `checkpointsFileOk` is latching-true —
|
|
25
|
+
* see AgentRecord.checkpointsFileOk for the suppression contract.
|
|
26
|
+
*/
|
|
27
|
+
export function saveCheckpoint(record: AgentRecord, summary: string): string {
|
|
28
|
+
const turn = record.turnCount ?? 0;
|
|
29
|
+
const maxTurns = record.effectiveMaxTurns;
|
|
30
|
+
const elapsedSeconds = Math.round((Date.now() - record.startedAt) / 1000);
|
|
31
|
+
record.lastCheckpoint = { turn, summary };
|
|
32
|
+
|
|
33
|
+
const maxTurnsLabel = maxTurns != null ? `/${maxTurns}` : "";
|
|
34
|
+
const path = checkpointsFilePath(record.outputFile);
|
|
35
|
+
if (path) {
|
|
36
|
+
try {
|
|
37
|
+
appendFileSync(
|
|
38
|
+
path,
|
|
39
|
+
`## Turn ${turn}${maxTurnsLabel} — ${elapsedSeconds}s elapsed\n${summary}\n\n`,
|
|
40
|
+
"utf-8",
|
|
41
|
+
);
|
|
42
|
+
record.checkpointsFileOk = true;
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return `Checkpoint saved in memory, but file write failed: ${err instanceof Error ? err.message : String(err)}.`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return `Checkpoint saved (turn ${turn}${maxTurnsLabel}, ${elapsedSeconds}s).`;
|
|
49
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* child-context.ts — marks the async context in which a subagent's session is
|
|
3
|
+
* loaded and constructed.
|
|
4
|
+
*
|
|
5
|
+
* The extension factory runs during `loader.reload()`, so a child session that
|
|
6
|
+
* loads pi-subagents re-activates it in this process. That activation must not
|
|
7
|
+
* register the parent-facing tools (`Agent`, `get_subagent_result`,
|
|
8
|
+
* `steer_subagent`): its manager is empty, so an `Agent` there could only spawn
|
|
9
|
+
* into a manager nobody reads — and an opted-in child receives the nested
|
|
10
|
+
* `Agent` as an SDK custom tool, where a second definition of the same name
|
|
11
|
+
* would leave the survivor to pi's registration precedence.
|
|
12
|
+
*
|
|
13
|
+
* Async-context-local so concurrent top-level extension work is unaffected, and
|
|
14
|
+
* so a harness that activates the extension directly (no child context) still
|
|
15
|
+
* gets the parent tools.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
19
|
+
|
|
20
|
+
const childSessionContext = new AsyncLocalStorage<boolean>();
|
|
21
|
+
|
|
22
|
+
/** True while the current async context is building a subagent session. */
|
|
23
|
+
export function inChildSessionContext(): boolean {
|
|
24
|
+
return childSessionContext.getStore() === true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Run `fn` (resource loading / session construction) as a subagent session. */
|
|
28
|
+
export function runInChildSessionContext<T>(fn: () => Promise<T>): Promise<T> {
|
|
29
|
+
return childSessionContext.run(true, fn);
|
|
30
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-extension RPC handlers for the subagents extension.
|
|
3
3
|
*
|
|
4
|
-
* Exposes ping, spawn, and
|
|
5
|
-
* using per-request scoped reply channels.
|
|
4
|
+
* Exposes ping, spawn, stop, consume and steer RPCs over the pi.events event
|
|
5
|
+
* bus, using per-request scoped reply channels.
|
|
6
6
|
*
|
|
7
7
|
* Reply envelope follows pi-mono convention:
|
|
8
8
|
* success → { success: true, data?: T }
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { type ModelRegistry, resolveModel } from "./model-resolver.js";
|
|
13
|
+
import type { AgentRecord } from "./types.js";
|
|
13
14
|
|
|
14
15
|
/** Minimal event bus interface needed by the RPC handlers. */
|
|
15
16
|
export interface EventBus {
|
|
@@ -25,10 +26,27 @@ export type RpcReply<T = void> =
|
|
|
25
26
|
/** RPC protocol version — bumped when the envelope or method contracts change. */
|
|
26
27
|
export const PROTOCOL_VERSION = 2;
|
|
27
28
|
|
|
28
|
-
/** Minimal AgentManager interface needed by the spawn/stop RPCs. */
|
|
29
|
+
/** Minimal AgentManager interface needed by the spawn/stop/consume/steer RPCs. */
|
|
29
30
|
export interface SpawnCapable {
|
|
30
31
|
spawn(pi: unknown, ctx: unknown, type: string, prompt: string, options: any): string;
|
|
31
32
|
abort(id: string): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* The record behind an id, for the ownership check stop, consume and steer
|
|
35
|
+
* share. Narrowed to the one field that check reads, so the RPC layer keeps
|
|
36
|
+
* its deliberately shallow view of the manager.
|
|
37
|
+
*/
|
|
38
|
+
getRecord(id: string): Pick<AgentRecord, "parentAgentId"> | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Mark a settled agent's result as read by the caller, suppressing the
|
|
41
|
+
* completion notification — what `get_subagent_result` does when it returns
|
|
42
|
+
* one. False when there is no such agent, or it has not settled yet.
|
|
43
|
+
*/
|
|
44
|
+
consumeResult(id: string): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Deliver a message to a running or queued agent's conversation — what
|
|
47
|
+
* `steer_subagent` does. False when the agent is unknown or has settled.
|
|
48
|
+
*/
|
|
49
|
+
steer(id: string, message: string): boolean;
|
|
32
50
|
}
|
|
33
51
|
|
|
34
52
|
export interface RpcDeps {
|
|
@@ -42,6 +60,8 @@ export interface RpcHandle {
|
|
|
42
60
|
unsubPing: () => void;
|
|
43
61
|
unsubSpawn: () => void;
|
|
44
62
|
unsubStop: () => void;
|
|
63
|
+
unsubConsume: () => void;
|
|
64
|
+
unsubSteer: () => void;
|
|
45
65
|
}
|
|
46
66
|
|
|
47
67
|
/**
|
|
@@ -69,7 +89,7 @@ function handleRpc<P extends { requestId: string }>(
|
|
|
69
89
|
}
|
|
70
90
|
|
|
71
91
|
/**
|
|
72
|
-
* Register ping, spawn, and
|
|
92
|
+
* Register ping, spawn, stop, consume and steer RPC handlers on the event bus.
|
|
73
93
|
* Returns unsub functions for cleanup.
|
|
74
94
|
*/
|
|
75
95
|
export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
@@ -112,11 +132,51 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
112
132
|
},
|
|
113
133
|
);
|
|
114
134
|
|
|
135
|
+
// Only the session's own agents are this RPC's to stop, steer or consume. A
|
|
136
|
+
// nested child is owned by the agent *waiting on it*, and aborting, redirecting
|
|
137
|
+
// or silencing it out from under that owner turns another extension's request
|
|
138
|
+
// into a failed step here. Defence in depth rather than a live hole: no RPC
|
|
139
|
+
// hands out agent ids, so a caller has no ordinary way to name one it does not
|
|
140
|
+
// own — but the guard is cheap and the id may leak some other way.
|
|
141
|
+
const assertOwned = (agentId: string): void => {
|
|
142
|
+
const record = manager.getRecord(agentId);
|
|
143
|
+
if (!record) throw new Error("Agent not found");
|
|
144
|
+
if (record.parentAgentId) throw new Error("Agent is owned by another agent");
|
|
145
|
+
};
|
|
146
|
+
|
|
115
147
|
const unsubStop = handleRpc<{ requestId: string; agentId: string }>(
|
|
116
148
|
events, "subagents:rpc:stop", ({ agentId }) => {
|
|
149
|
+
assertOwned(agentId);
|
|
117
150
|
if (!manager.abort(agentId)) throw new Error("Agent not found");
|
|
118
151
|
},
|
|
119
152
|
);
|
|
120
153
|
|
|
121
|
-
|
|
154
|
+
// A caller that has already shown the model an agent's result — pi-tasks'
|
|
155
|
+
// TaskOutput is the one in practice — says so here, so the completion
|
|
156
|
+
// notification for that same result is not delivered on top of it and does
|
|
157
|
+
// not cost the parent a turn. Deliberately outside the ping version
|
|
158
|
+
// handshake: an extension built against protocol v2 simply never calls it.
|
|
159
|
+
const unsubConsume = handleRpc<{ requestId: string; agentId: string }>(
|
|
160
|
+
events, "subagents:rpc:consume", ({ agentId }) => {
|
|
161
|
+
assertOwned(agentId);
|
|
162
|
+
if (!manager.consumeResult(agentId)) throw new Error("Agent not found or still running");
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// An extension that spawned an agent over the bus and then learns something
|
|
167
|
+
// that agent needs — a second CI failure on the head it is already fixing, a
|
|
168
|
+
// review that landed mid-run — can hand it over instead of spawning a second
|
|
169
|
+
// agent for the same work or dropping the fact. Outside the ping version
|
|
170
|
+
// handshake, like consume: additive and best-effort.
|
|
171
|
+
const unsubSteer = handleRpc<{ requestId: string; agentId: string; message: unknown }>(
|
|
172
|
+
events, "subagents:rpc:steer", ({ agentId, message }) => {
|
|
173
|
+
if (typeof message !== "string" || message.trim().length === 0) {
|
|
174
|
+
throw new Error("Steer message must be a non-empty string");
|
|
175
|
+
}
|
|
176
|
+
assertOwned(agentId);
|
|
177
|
+
if (!manager.steer(agentId, message)) throw new Error("Agent is not running");
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
return { unsubPing, unsubSpawn, unsubStop, unsubConsume, unsubSteer };
|
|
122
182
|
}
|