@signalridge/pi-subagents 1.4.0 → 1.6.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 +68 -0
- package/README.md +69 -12
- package/package.json +6 -6
- package/src/agent-color.ts +188 -0
- package/src/agent-file-toggle.ts +8 -0
- package/src/agent-manager.ts +793 -62
- package/src/agent-runner.ts +361 -25
- package/src/agent-tiers.ts +189 -8
- package/src/agent-types.ts +3 -0
- package/src/ask-tools.ts +114 -0
- package/src/cross-extension-rpc.ts +16 -5
- package/src/custom-agents.ts +67 -2
- package/src/default-agents.ts +6 -5
- package/src/gate.ts +0 -0
- package/src/index.ts +3386 -1128
- package/src/mention-clone.ts +196 -0
- package/src/mention.ts +141 -0
- package/src/output-file.ts +23 -1
- package/src/settings.ts +208 -9
- package/src/supervisor.ts +115 -0
- package/src/types.ts +59 -2
- package/src/ui/agent-mention.ts +163 -0
- package/src/ui/conversation-viewer.ts +4 -3
- package/src/ui/fleet-list.ts +6 -5
- package/src/worktree.ts +128 -648
package/src/settings.ts
CHANGED
|
@@ -2,13 +2,23 @@
|
|
|
2
2
|
// - Global: ~/.pi/agent/subagents.json (via getAgentDir()) — manual defaults, never written here
|
|
3
3
|
// - Project: <cwd>/.pi/subagents.json — written by /agents → Settings; overrides global on load
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { basename, dirname, join } from "node:path";
|
|
7
8
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
8
9
|
import type { WorkflowTier } from "@signalridge/pi-subagents-protocol";
|
|
10
|
+
// Imported only for the applySettings fallback so a persisted defaultToolTimeoutMs
|
|
11
|
+
// takes effect even before the host wires the new applier — avoids needing to
|
|
12
|
+
// edit index.ts in the same change.
|
|
13
|
+
import { setDefaultToolTimeoutMs } from "./agent-runner.js";
|
|
9
14
|
import { NO_FALLBACK } from "./agent-types.js";
|
|
10
15
|
import type { JoinMode, ThinkingLevel } from "./types.js";
|
|
11
16
|
|
|
17
|
+
/** How a `@handle message` mention is dispatched. See `agentMentions`. */
|
|
18
|
+
export const AGENT_MENTION_MODES = ["model", "direct", "off"] as const;
|
|
19
|
+
export type AgentMentionMode = (typeof AGENT_MENTION_MODES)[number];
|
|
20
|
+
const VALID_AGENT_MENTION_MODES: ReadonlySet<string> = new Set(AGENT_MENTION_MODES);
|
|
21
|
+
|
|
12
22
|
/** A tier's thinking value: a level, or `inherit` to keep the parent's. */
|
|
13
23
|
export type TierThinking = ThinkingLevel | "inherit";
|
|
14
24
|
|
|
@@ -82,6 +92,25 @@ export interface SubagentsSettings {
|
|
|
82
92
|
* these. See `agent-tiers.ts` for resolution and precedence.
|
|
83
93
|
*/
|
|
84
94
|
agentTiers?: AgentTiersSettings;
|
|
95
|
+
/**
|
|
96
|
+
* The model a subagent runs when nothing else chose one — no tier applied and
|
|
97
|
+
* no programmatic override. It takes the place of the parent session's model
|
|
98
|
+
* as the last step of resolution, so a workspace can say "subagents run on the
|
|
99
|
+
* cheap model" without first defining a tier catalogue.
|
|
100
|
+
*
|
|
101
|
+
* A `provider/model` reference, or the literal `"inherit"` to follow the
|
|
102
|
+
* parent. `"inherit"` is spellable rather than merely omittable because a
|
|
103
|
+
* project needs a way to undo a global default; omitting the key inherits
|
|
104
|
+
* whatever global set.
|
|
105
|
+
*
|
|
106
|
+
* Deliberately weaker than a tier: a tier naming an unavailable model fails
|
|
107
|
+
* the spawn, because someone asked for that policy by name, while an
|
|
108
|
+
* unresolvable default model falls back to the parent. Failing every spawn on
|
|
109
|
+
* a machine that happens to lack one provider is the wrong trade for a value
|
|
110
|
+
* nobody named at the call site — the `/agents → Settings` row shows the
|
|
111
|
+
* fallback instead.
|
|
112
|
+
*/
|
|
113
|
+
defaultModel?: string;
|
|
85
114
|
maxConcurrent?: number;
|
|
86
115
|
/**
|
|
87
116
|
* 0 = unlimited — the extension's single source of truth for that convention:
|
|
@@ -90,6 +119,19 @@ export interface SubagentsSettings {
|
|
|
90
119
|
*/
|
|
91
120
|
defaultMaxTurns?: number;
|
|
92
121
|
graceTurns?: number;
|
|
122
|
+
/**
|
|
123
|
+
* Token budget for one subagent run. `0` (default) = unlimited, matching
|
|
124
|
+
* `defaultMaxTurns`. A wrap-up steer is sent at 80% and the run is aborted at
|
|
125
|
+
* 100%. Bounds what one agent can spend, which a turn count cannot — a single
|
|
126
|
+
* turn can burn an arbitrary number of tokens. Frontmatter `max_tokens` wins.
|
|
127
|
+
*/
|
|
128
|
+
defaultMaxTokens?: number;
|
|
129
|
+
/**
|
|
130
|
+
* Tool-call budget for one subagent run. `0` (default) = unlimited, with the
|
|
131
|
+
* same 80%/100% shape as `defaultMaxTokens`. Frontmatter `max_tool_calls`
|
|
132
|
+
* wins.
|
|
133
|
+
*/
|
|
134
|
+
defaultMaxToolCalls?: number;
|
|
93
135
|
defaultJoinMode?: JoinMode;
|
|
94
136
|
/**
|
|
95
137
|
* Master switch for the schedule subagent feature. Defaults to `true`.
|
|
@@ -163,6 +205,32 @@ export interface SubagentsSettings {
|
|
|
163
205
|
* (`isolation: worktree`), or memory files.
|
|
164
206
|
*/
|
|
165
207
|
outputTranscript?: boolean;
|
|
208
|
+
/**
|
|
209
|
+
* Whether evicted agent records stay addressable as resumable entries
|
|
210
|
+
* (`@handle` reopen). Defaults to true. When false, a cleaned-up record is
|
|
211
|
+
* forgotten entirely.
|
|
212
|
+
*/
|
|
213
|
+
rememberAgents?: boolean;
|
|
214
|
+
/**
|
|
215
|
+
* How `@handle message` typed at the prompt is dispatched.
|
|
216
|
+
*
|
|
217
|
+
* - `"model"` (default) — a mention that names an agent TYPE takes its turn
|
|
218
|
+
* in an off-screen clone of the conversation, so the started agent gets a
|
|
219
|
+
* prompt written with context instead of only the words after the handle.
|
|
220
|
+
* Messaging and resuming an existing agent stay direct either way.
|
|
221
|
+
* - `"direct"` — start the agent straight from the typed text, no clone.
|
|
222
|
+
* - `"off"` — the text goes to the main model verbatim, exactly as it did
|
|
223
|
+
* before mentions existed.
|
|
224
|
+
*
|
|
225
|
+
* A boolean is still accepted and read as `"model"`/`"off"`.
|
|
226
|
+
*/
|
|
227
|
+
agentMentions?: AgentMentionMode;
|
|
228
|
+
/**
|
|
229
|
+
* Whether subagents may interrupt with `contact_supervisor` to ask their
|
|
230
|
+
* human a question. Defaults to true. The tool is only ever injected where
|
|
231
|
+
* there is a UI to ask through, so this turns it off where there is one.
|
|
232
|
+
*/
|
|
233
|
+
supervisorQuestions?: boolean;
|
|
166
234
|
/**
|
|
167
235
|
* Hard ceiling on nested subagent delegation, counted from the main session:
|
|
168
236
|
* main = 0, its subagents = 1, their children = 2. Defaults to `2`; `0` or `1`
|
|
@@ -170,6 +238,22 @@ export interface SubagentsSettings {
|
|
|
170
238
|
* change applies to agents started after it.
|
|
171
239
|
*/
|
|
172
240
|
maxSubagentDepth?: number;
|
|
241
|
+
/**
|
|
242
|
+
* Cumulative descendants any one top-level agent may start, over its whole
|
|
243
|
+
* life. Defaults to `64`. The depth cap bounds how DEEP nesting goes and
|
|
244
|
+
* nothing about how WIDE it gets — this is the horizontal bound. Minimum 1;
|
|
245
|
+
* turn nesting off with `maxSubagentDepth` instead.
|
|
246
|
+
*/
|
|
247
|
+
maxSubagentSpawnsPerBranch?: number;
|
|
248
|
+
/**
|
|
249
|
+
* Default per-tool timeout in milliseconds. `0` = disabled (no timeout). When
|
|
250
|
+
* set, any tool call that does not settle within this window is aborted with
|
|
251
|
+
* a timeout error, preventing a hung `bash` or MCP tool from stalling the
|
|
252
|
+
* subagent forever. `0` when unset, matching tintinweb (no per-tool timeout
|
|
253
|
+
* by default; hung tools are reclaimed via abort/quiescence). Frontmatter does
|
|
254
|
+
* not override.
|
|
255
|
+
*/
|
|
256
|
+
defaultToolTimeoutMs?: number;
|
|
173
257
|
/**
|
|
174
258
|
* Agent type substituted when a caller-supplied `subagent_type` doesn't
|
|
175
259
|
* resolve to exactly one enabled agent (unknown, disabled, or ambiguous by
|
|
@@ -185,16 +269,28 @@ export interface SubagentsSettings {
|
|
|
185
269
|
* meaning one thing here and another in the resolver.
|
|
186
270
|
*/
|
|
187
271
|
fallbackSubagent?: string;
|
|
272
|
+
/**
|
|
273
|
+
* Project-wide switch for worktree isolation (upstream #184). When false,
|
|
274
|
+
* no caller can create a worktree regardless of isolation param. Defaults to
|
|
275
|
+
* true (unchanged behaviour). Routed through worktree.ts singleton.
|
|
276
|
+
*/
|
|
277
|
+
worktreeIsolation?: boolean;
|
|
188
278
|
}
|
|
189
279
|
|
|
190
280
|
export type ToolDescriptionMode = "full" | "compact" | "custom";
|
|
191
281
|
|
|
192
282
|
/** Setter hooks used by applySettings to wire persisted values into in-memory state. */
|
|
193
283
|
export interface SettingsAppliers {
|
|
284
|
+
setWorktreeIsolation?: (enabled: boolean) => void;
|
|
194
285
|
setMaxConcurrent: (n: number) => void;
|
|
195
286
|
setDefaultMaxTurns: (n: number) => void;
|
|
196
287
|
setGraceTurns: (n: number) => void;
|
|
288
|
+
setDefaultMaxTokens: (n: number) => void;
|
|
289
|
+
setDefaultMaxToolCalls: (n: number) => void;
|
|
290
|
+
setDefaultToolTimeout?: (ms: number) => void;
|
|
197
291
|
setDefaultJoinMode: (mode: JoinMode) => void;
|
|
292
|
+
/** `undefined` and `"inherit"` both mean "follow the parent session". */
|
|
293
|
+
setDefaultModel: (ref: string | undefined) => void;
|
|
198
294
|
setSchedulingEnabled: (b: boolean) => void;
|
|
199
295
|
setScopeModels: (enabled: boolean) => void;
|
|
200
296
|
setStrictAgentFiles: (b: boolean) => void;
|
|
@@ -202,7 +298,11 @@ export interface SettingsAppliers {
|
|
|
202
298
|
setToolDescriptionMode: (mode: ToolDescriptionMode) => void;
|
|
203
299
|
setFleetView: (b: boolean) => void;
|
|
204
300
|
setOutputTranscript: (b: boolean) => void;
|
|
301
|
+
setRememberAgents: (b: boolean) => void;
|
|
302
|
+
setAgentMentions: (mode: AgentMentionMode) => void;
|
|
303
|
+
setSupervisorQuestions: (b: boolean) => void;
|
|
205
304
|
setMaxSubagentDepth: (n: number) => void;
|
|
305
|
+
setMaxSubagentSpawnsPerBranch: (n: number) => void;
|
|
206
306
|
setFallbackSubagent: (v: string | undefined) => void;
|
|
207
307
|
/** Optional because non-runtime settings tests and consumers need not apply workflow state. */
|
|
208
308
|
setWorkflow?: (settings: WorkflowSettings) => void;
|
|
@@ -215,15 +315,23 @@ export type SettingsEmit = (event: string, payload: unknown) => void;
|
|
|
215
315
|
|
|
216
316
|
const VALID_JOIN_MODES: ReadonlySet<string> = new Set<JoinMode>(["async", "group", "smart"]);
|
|
217
317
|
const VALID_TOOL_DESCRIPTION_MODES: ReadonlySet<string> = new Set<ToolDescriptionMode>(["full", "compact", "custom"]);
|
|
218
|
-
|
|
318
|
+
/**
|
|
319
|
+
* Thinking values a tier profile accepts, `inherit` first and then ascending.
|
|
320
|
+
*
|
|
321
|
+
* Ordered because the `/agents → Model tiers` editor offers them in this order;
|
|
322
|
+
* note `off` is absent — a profile that wants no thinking says so through the
|
|
323
|
+
* model it names, and `clampThinkingLevel` handles a model that supports none.
|
|
324
|
+
*/
|
|
325
|
+
export const TIER_THINKING_LEVELS: readonly TierThinking[] = [
|
|
326
|
+
"inherit",
|
|
219
327
|
"minimal",
|
|
220
328
|
"low",
|
|
221
329
|
"medium",
|
|
222
330
|
"high",
|
|
223
331
|
"xhigh",
|
|
224
332
|
"max",
|
|
225
|
-
|
|
226
|
-
|
|
333
|
+
];
|
|
334
|
+
const VALID_THINKING_LEVELS: ReadonlySet<string> = new Set(TIER_THINKING_LEVELS);
|
|
227
335
|
const WORKFLOW_TIER_NAMES: readonly WorkflowTier[] = ["small", "medium", "large"];
|
|
228
336
|
const MAX_MODEL_REFERENCE_LENGTH = 512;
|
|
229
337
|
/** Mirrors MAX_AGENT_TIER_KEY_LENGTH in agent-tiers.ts; duplicated to keep settings dependency-free. */
|
|
@@ -239,12 +347,28 @@ const MAX_CONCURRENT_CEILING = 1024;
|
|
|
239
347
|
const MAX_TURNS_CEILING = 10_000;
|
|
240
348
|
const GRACE_TURNS_CEILING = 1_000;
|
|
241
349
|
const SUBAGENT_DEPTH_CEILING = 16;
|
|
350
|
+
/**
|
|
351
|
+
* Upper bound on the branch spawn budget. A configured value this high is
|
|
352
|
+
* already far past the point where a fan-out is intentional; the ceiling exists
|
|
353
|
+
* so a typo cannot turn the horizontal bound off by writing a huge number.
|
|
354
|
+
*/
|
|
355
|
+
const SUBAGENT_SPAWNS_PER_BRANCH_CEILING = 4_096;
|
|
356
|
+
/** Per-tool timeout ceiling: 10 minutes, matching gate timeout. */
|
|
357
|
+
const TOOL_TIMEOUT_CEILING_MS = 600_000;
|
|
242
358
|
|
|
243
359
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
244
360
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
245
361
|
}
|
|
246
362
|
|
|
247
|
-
|
|
363
|
+
/**
|
|
364
|
+
* `inherit`, or a bounded whitespace-free `provider/model` reference.
|
|
365
|
+
*
|
|
366
|
+
* Exported so the `/agents` menus can reject a typed reference at the prompt.
|
|
367
|
+
* Without that they would hand an invalid value to `saveSettings`, which drops
|
|
368
|
+
* unrecognized fields silently — a success toast for a setting that never
|
|
369
|
+
* persisted.
|
|
370
|
+
*/
|
|
371
|
+
export function isModelReference(value: unknown): value is string {
|
|
248
372
|
if (typeof value !== "string") return false;
|
|
249
373
|
const model = value.trim();
|
|
250
374
|
if (model === "inherit") return true;
|
|
@@ -267,7 +391,7 @@ function sanitizeWorkflowProfile(raw: unknown): WorkflowTierProfile | undefined
|
|
|
267
391
|
const keys = Object.keys(raw);
|
|
268
392
|
if (keys.some((key) => key !== "model" && key !== "thinking")) return undefined;
|
|
269
393
|
if (!Object.hasOwn(raw, "model") || !Object.hasOwn(raw, "thinking")) return undefined;
|
|
270
|
-
if (!
|
|
394
|
+
if (!isModelReference(raw.model) || !validThinkingLevel(raw.thinking)) return undefined;
|
|
271
395
|
return { model: raw.model.trim(), thinking: raw.thinking };
|
|
272
396
|
}
|
|
273
397
|
|
|
@@ -318,7 +442,7 @@ function sanitizeAgentTierProfile(raw: unknown): AgentTierProfile | undefined {
|
|
|
318
442
|
const keys = Object.keys(raw);
|
|
319
443
|
if (keys.some((key) => key !== "model" && key !== "thinking" && key !== "description")) return undefined;
|
|
320
444
|
if (!Object.hasOwn(raw, "model") || !Object.hasOwn(raw, "thinking")) return undefined;
|
|
321
|
-
if (!
|
|
445
|
+
if (!isModelReference(raw.model) || !validThinkingLevel(raw.thinking)) return undefined;
|
|
322
446
|
if (
|
|
323
447
|
Object.hasOwn(raw, "description") &&
|
|
324
448
|
(typeof raw.description !== "string" ||
|
|
@@ -390,6 +514,13 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
390
514
|
) {
|
|
391
515
|
out.graceTurns = r.graceTurns as number;
|
|
392
516
|
}
|
|
517
|
+
// `0` is accepted and means unlimited, matching `defaultMaxTurns`.
|
|
518
|
+
if (Number.isInteger(r.defaultMaxTokens) && (r.defaultMaxTokens as number) >= 0) {
|
|
519
|
+
out.defaultMaxTokens = r.defaultMaxTokens as number;
|
|
520
|
+
}
|
|
521
|
+
if (Number.isInteger(r.defaultMaxToolCalls) && (r.defaultMaxToolCalls as number) >= 0) {
|
|
522
|
+
out.defaultMaxToolCalls = r.defaultMaxToolCalls as number;
|
|
523
|
+
}
|
|
393
524
|
if (
|
|
394
525
|
Number.isInteger(r.maxSubagentDepth) &&
|
|
395
526
|
(r.maxSubagentDepth as number) >= 0 &&
|
|
@@ -397,6 +528,26 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
397
528
|
) {
|
|
398
529
|
out.maxSubagentDepth = r.maxSubagentDepth as number;
|
|
399
530
|
}
|
|
531
|
+
// Minimum 1, not 0: zero reads as a limit, and silently meaning "unlimited"
|
|
532
|
+
// is how a safety valve gets disabled by accident. Nesting is turned off with
|
|
533
|
+
// `maxSubagentDepth`, which says so.
|
|
534
|
+
if (
|
|
535
|
+
Number.isInteger(r.maxSubagentSpawnsPerBranch) &&
|
|
536
|
+
(r.maxSubagentSpawnsPerBranch as number) >= 1 &&
|
|
537
|
+
(r.maxSubagentSpawnsPerBranch as number) <= SUBAGENT_SPAWNS_PER_BRANCH_CEILING
|
|
538
|
+
) {
|
|
539
|
+
out.maxSubagentSpawnsPerBranch = r.maxSubagentSpawnsPerBranch as number;
|
|
540
|
+
}
|
|
541
|
+
if (
|
|
542
|
+
Number.isInteger(r.defaultToolTimeoutMs) &&
|
|
543
|
+
(r.defaultToolTimeoutMs as number) >= 0 &&
|
|
544
|
+
(r.defaultToolTimeoutMs as number) <= TOOL_TIMEOUT_CEILING_MS
|
|
545
|
+
) {
|
|
546
|
+
out.defaultToolTimeoutMs = r.defaultToolTimeoutMs as number;
|
|
547
|
+
}
|
|
548
|
+
if (isModelReference(r.defaultModel)) {
|
|
549
|
+
out.defaultModel = r.defaultModel.trim();
|
|
550
|
+
}
|
|
400
551
|
if (typeof r.defaultJoinMode === "string" && VALID_JOIN_MODES.has(r.defaultJoinMode)) {
|
|
401
552
|
out.defaultJoinMode = r.defaultJoinMode as JoinMode;
|
|
402
553
|
}
|
|
@@ -421,6 +572,17 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
421
572
|
if (typeof r.outputTranscript === "boolean") {
|
|
422
573
|
out.outputTranscript = r.outputTranscript;
|
|
423
574
|
}
|
|
575
|
+
if (typeof r.rememberAgents === "boolean") {
|
|
576
|
+
out.rememberAgents = r.rememberAgents;
|
|
577
|
+
}
|
|
578
|
+
if (typeof r.agentMentions === "boolean") {
|
|
579
|
+
out.agentMentions = r.agentMentions ? "model" : "off";
|
|
580
|
+
} else if (typeof r.agentMentions === "string" && VALID_AGENT_MENTION_MODES.has(r.agentMentions)) {
|
|
581
|
+
out.agentMentions = r.agentMentions as AgentMentionMode;
|
|
582
|
+
}
|
|
583
|
+
if (typeof r.supervisorQuestions === "boolean") {
|
|
584
|
+
out.supervisorQuestions = r.supervisorQuestions;
|
|
585
|
+
}
|
|
424
586
|
if (r.fallbackSubagent === false) {
|
|
425
587
|
// The only non-string spelling worth accepting: a boolean would otherwise be
|
|
426
588
|
// dropped, silently leaving the PERMISSIVE default in place. Every string is
|
|
@@ -431,6 +593,9 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
431
593
|
} else if (typeof r.fallbackSubagent === "string" && r.fallbackSubagent.trim()) {
|
|
432
594
|
out.fallbackSubagent = r.fallbackSubagent.trim();
|
|
433
595
|
}
|
|
596
|
+
if (typeof r.worktreeIsolation === "boolean") {
|
|
597
|
+
out.worktreeIsolation = r.worktreeIsolation;
|
|
598
|
+
}
|
|
434
599
|
|
|
435
600
|
const workflow = sanitizeWorkflow(r.workflow);
|
|
436
601
|
if (workflow) out.workflow = workflow;
|
|
@@ -759,7 +924,23 @@ export function saveSettings(s: SubagentsSettings, cwd: string = process.cwd()):
|
|
|
759
924
|
const path = projectPath(cwd);
|
|
760
925
|
try {
|
|
761
926
|
mkdirSync(dirname(path), { recursive: true });
|
|
762
|
-
|
|
927
|
+
// Atomic write: settings are written on every /agents mutation, and a torn
|
|
928
|
+
// write (crash, kill, disk full between truncate and flush) would silently
|
|
929
|
+
// discard the project's entire configuration. Write to a unique temp file
|
|
930
|
+
// in the same directory, then rename over the target — rename is atomic on
|
|
931
|
+
// POSIX and Windows. Same pattern as packages/pi-goal/src/settings.ts.
|
|
932
|
+
const document = `${JSON.stringify(sanitize(s), null, 2)}\n`;
|
|
933
|
+
const temporaryPath = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
934
|
+
try {
|
|
935
|
+
writeFileSync(temporaryPath, document, { encoding: "utf8", flag: "wx" });
|
|
936
|
+
renameSync(temporaryPath, path);
|
|
937
|
+
} finally {
|
|
938
|
+
try {
|
|
939
|
+
rmSync(temporaryPath, { force: true });
|
|
940
|
+
} catch {
|
|
941
|
+
// Best-effort cleanup must not replace the save result.
|
|
942
|
+
}
|
|
943
|
+
}
|
|
763
944
|
return true;
|
|
764
945
|
} catch {
|
|
765
946
|
return false;
|
|
@@ -771,8 +952,23 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
|
|
|
771
952
|
if (typeof s.maxConcurrent === "number") appliers.setMaxConcurrent(s.maxConcurrent);
|
|
772
953
|
if (typeof s.defaultMaxTurns === "number") appliers.setDefaultMaxTurns(s.defaultMaxTurns);
|
|
773
954
|
if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns);
|
|
955
|
+
if (typeof s.defaultMaxTokens === "number") appliers.setDefaultMaxTokens(s.defaultMaxTokens);
|
|
956
|
+
if (typeof s.defaultMaxToolCalls === "number") appliers.setDefaultMaxToolCalls(s.defaultMaxToolCalls);
|
|
774
957
|
if (typeof s.maxSubagentDepth === "number") appliers.setMaxSubagentDepth(s.maxSubagentDepth);
|
|
958
|
+
if (typeof s.maxSubagentSpawnsPerBranch === "number")
|
|
959
|
+
appliers.setMaxSubagentSpawnsPerBranch(s.maxSubagentSpawnsPerBranch);
|
|
960
|
+
if (typeof s.defaultToolTimeoutMs === "number") {
|
|
961
|
+
appliers.setDefaultToolTimeout?.(s.defaultToolTimeoutMs);
|
|
962
|
+
// Fallback for hosts that have not yet wired the new applier (and for tests
|
|
963
|
+
// that call applySettings with a minimal appliers object): ensure the
|
|
964
|
+
// in-memory timeout still follows the persisted value.
|
|
965
|
+
setDefaultToolTimeoutMs(s.defaultToolTimeoutMs);
|
|
966
|
+
}
|
|
775
967
|
if (typeof s.fallbackSubagent === "string") appliers.setFallbackSubagent(s.fallbackSubagent);
|
|
968
|
+
if (typeof s.worktreeIsolation === "boolean") appliers.setWorktreeIsolation?.(s.worktreeIsolation);
|
|
969
|
+
// Applied whenever the key is present, `"inherit"` included: that spelling is
|
|
970
|
+
// how a project cancels a global default model, so it has to reach the setter.
|
|
971
|
+
if (typeof s.defaultModel === "string") appliers.setDefaultModel(s.defaultModel);
|
|
776
972
|
if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
|
|
777
973
|
if (typeof s.schedulingEnabled === "boolean") appliers.setSchedulingEnabled(s.schedulingEnabled);
|
|
778
974
|
if (typeof s.scopeModels === "boolean") appliers.setScopeModels(s.scopeModels);
|
|
@@ -781,6 +977,9 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
|
|
|
781
977
|
if (s.toolDescriptionMode) appliers.setToolDescriptionMode(s.toolDescriptionMode);
|
|
782
978
|
if (typeof s.fleetView === "boolean") appliers.setFleetView(s.fleetView);
|
|
783
979
|
if (typeof s.outputTranscript === "boolean") appliers.setOutputTranscript(s.outputTranscript);
|
|
980
|
+
if (typeof s.rememberAgents === "boolean") appliers.setRememberAgents(s.rememberAgents);
|
|
981
|
+
if (s.agentMentions) appliers.setAgentMentions(s.agentMentions);
|
|
982
|
+
if (typeof s.supervisorQuestions === "boolean") appliers.setSupervisorQuestions(s.supervisorQuestions);
|
|
784
983
|
if (s.workflow) appliers.setWorkflow?.(s.workflow);
|
|
785
984
|
// Applied unconditionally so a session that had tiers and no longer does gets
|
|
786
985
|
// the empty catalogue rather than keeping the previous one.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* supervisor.ts — `contact_supervisor`, the child→parent direction.
|
|
3
|
+
*
|
|
4
|
+
* `steer_subagent` sends guidance downward. Nothing sent anything upward: a
|
|
5
|
+
* subagent that hit a genuine fork in the road could only guess, finish wrong,
|
|
6
|
+
* and have the guess discovered later in its result.
|
|
7
|
+
*
|
|
8
|
+
* The answer comes from the HUMAN, not from a supervising model. Our subagents
|
|
9
|
+
* share the parent's `ExtensionContext`, so the parent's UI is directly
|
|
10
|
+
* reachable — asking the person who is sitting there is both cheaper and more
|
|
11
|
+
* correct than delegating the judgement to another model, and it is the same
|
|
12
|
+
* reason this package has no LLM arbitrator anywhere else.
|
|
13
|
+
*
|
|
14
|
+
* That reachability is also why this is ~80 lines rather than a filesystem
|
|
15
|
+
* channel with a poller on each end: in-process, the question is one promise.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { Type } from "@sinclair/typebox";
|
|
20
|
+
import { sanitizeDisplayText, truncateCodePoints } from "./ui/safe-text.js";
|
|
21
|
+
|
|
22
|
+
/** Longest question a child may put to the parent, in characters. */
|
|
23
|
+
const MAX_QUESTION = 2_000;
|
|
24
|
+
/** Longest option label offered in a choice prompt. */
|
|
25
|
+
const MAX_OPTION = 200;
|
|
26
|
+
/** Most options a child may offer, so the picker stays usable. */
|
|
27
|
+
const MAX_OPTIONS = 8;
|
|
28
|
+
|
|
29
|
+
export interface SupervisorAsk {
|
|
30
|
+
/** Prompt the human with a free-text question; undefined = dismissed. */
|
|
31
|
+
input(title: string, placeholder?: string): Promise<string | undefined>;
|
|
32
|
+
/** Prompt the human to pick one option; undefined = dismissed. */
|
|
33
|
+
select(title: string, options: string[]): Promise<string | undefined>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SupervisorToolContext {
|
|
37
|
+
/** The parent's UI. Omitted when there is no human to ask. */
|
|
38
|
+
ask?: SupervisorAsk;
|
|
39
|
+
/** Display name of the asking agent, for the prompt title. */
|
|
40
|
+
agentLabel: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function textResult(text: string, isError = false) {
|
|
44
|
+
return { content: [{ type: "text" as const, text }], isError, details: {} };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build the `contact_supervisor` tool, or nothing when no human can answer.
|
|
49
|
+
*
|
|
50
|
+
* Returning an empty array rather than a tool that always fails is deliberate,
|
|
51
|
+
* and matches how nested tools are handled: injecting a tool whose every call
|
|
52
|
+
* is an error spends context to teach the model an affordance it does not have.
|
|
53
|
+
* Headless and RPC sessions therefore see no such tool at all.
|
|
54
|
+
*/
|
|
55
|
+
export function createSupervisorTool(context: SupervisorToolContext) {
|
|
56
|
+
if (!context.ask) return [];
|
|
57
|
+
const ask = context.ask;
|
|
58
|
+
|
|
59
|
+
return [
|
|
60
|
+
defineTool({
|
|
61
|
+
name: "contact_supervisor",
|
|
62
|
+
label: "Ask Supervisor",
|
|
63
|
+
description:
|
|
64
|
+
"Ask the human who started you a question and wait for their answer. Use ONLY for a decision you cannot make yourself and cannot defer: an ambiguous requirement where the readings lead to materially different work, a destructive step that needs confirmation, or missing information nothing available to you can supply. It interrupts a person, so prefer stating an assumption and continuing. Returns their answer, or tells you they declined — in which case proceed on your best judgement and say what you assumed.",
|
|
65
|
+
parameters: Type.Object({
|
|
66
|
+
question: Type.String({
|
|
67
|
+
description: "The question, self-contained. The human cannot see your conversation.",
|
|
68
|
+
maxLength: MAX_QUESTION,
|
|
69
|
+
}),
|
|
70
|
+
options: Type.Optional(
|
|
71
|
+
Type.Array(Type.String({ maxLength: MAX_OPTION }), {
|
|
72
|
+
description: `Offer up to ${MAX_OPTIONS} concrete choices instead of free text. Prefer this when the answers are known.`,
|
|
73
|
+
maxItems: MAX_OPTIONS,
|
|
74
|
+
}),
|
|
75
|
+
),
|
|
76
|
+
}),
|
|
77
|
+
execute: async (_toolCallId, params) => {
|
|
78
|
+
// Both the question and any option labels are model-authored text about
|
|
79
|
+
// to be drawn into the user's terminal, so they are sanitized and
|
|
80
|
+
// bounded here — the same treatment any other child-supplied string
|
|
81
|
+
// gets before it reaches a UI surface.
|
|
82
|
+
const question = truncateCodePoints(sanitizeDisplayText(params.question), MAX_QUESTION, "…").trim();
|
|
83
|
+
if (!question) return textResult("Ask a non-empty question.", true);
|
|
84
|
+
|
|
85
|
+
const title = `${context.agentLabel} asks`;
|
|
86
|
+
const options = (params.options ?? [])
|
|
87
|
+
.map((option) => truncateCodePoints(sanitizeDisplayText(option), MAX_OPTION, "…").trim())
|
|
88
|
+
.filter((option) => option.length > 0)
|
|
89
|
+
.slice(0, MAX_OPTIONS);
|
|
90
|
+
|
|
91
|
+
let answer: string | undefined;
|
|
92
|
+
try {
|
|
93
|
+
answer =
|
|
94
|
+
options.length > 0
|
|
95
|
+
? await ask.select(`${title}: ${question}`, options)
|
|
96
|
+
: await ask.input(title, question);
|
|
97
|
+
} catch (error: unknown) {
|
|
98
|
+
// A dialog that cannot open must not fail the child's whole run; it
|
|
99
|
+
// is told nobody answered and carries on under its own judgement.
|
|
100
|
+
return textResult(
|
|
101
|
+
`Could not reach the supervisor (${error instanceof Error ? error.message : String(error)}). Proceed on your best judgement and state the assumption you made.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const reply = answer?.trim();
|
|
106
|
+
if (!reply) {
|
|
107
|
+
return textResult(
|
|
108
|
+
"The supervisor did not answer. Proceed on your best judgement and state the assumption you made.",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return textResult(`Supervisor answered: ${reply}`);
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
];
|
|
115
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -30,13 +30,32 @@ export const DEFAULT_AGENT_NAMES = ["general-purpose", "Explore", "Plan"] as con
|
|
|
30
30
|
/** Memory scope for persistent agent memory. */
|
|
31
31
|
export type MemoryScope = "user" | "project" | "local";
|
|
32
32
|
|
|
33
|
-
/** Isolation mode for agent execution. */
|
|
34
|
-
export type IsolationMode = "worktree";
|
|
33
|
+
/** Isolation mode for agent execution. "off" explicitly opts out of worktree isolation. */
|
|
34
|
+
export type IsolationMode = "worktree" | "off";
|
|
35
35
|
|
|
36
36
|
/** Unified agent configuration — used for both default and user-defined agents. */
|
|
37
37
|
export interface AgentConfig {
|
|
38
38
|
name: string;
|
|
39
39
|
displayName?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Badge colour for this agent, as a Claude Code palette name or `#RRGGBB`.
|
|
42
|
+
* Anything else renders without a badge rather than failing the load — a
|
|
43
|
+
* colour typo must not cost the user their agent.
|
|
44
|
+
*/
|
|
45
|
+
color?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Tools whose every call needs the user to agree first (`ask_tools:`). The
|
|
48
|
+
* third answer between `tools:` and `disallowed_tools:`, for tools that are
|
|
49
|
+
* usually fine and occasionally not. Approval comes from the human, never
|
|
50
|
+
* from a model — see `ask-tools.ts`.
|
|
51
|
+
*/
|
|
52
|
+
askTools?: string[];
|
|
53
|
+
/**
|
|
54
|
+
* Shell command run by the HOST after this agent finishes, whose pass/fail is
|
|
55
|
+
* appended to its result (`gate:`). The one acceptance signal the agent
|
|
56
|
+
* cannot author — see `gate.ts`.
|
|
57
|
+
*/
|
|
58
|
+
gate?: string;
|
|
40
59
|
description: string;
|
|
41
60
|
builtinToolNames?: string[];
|
|
42
61
|
/** Raw `ext:` selector entries from the `tools:` CSV, e.g. ["ext:foo", "ext:bar/x"].
|
|
@@ -69,6 +88,14 @@ export interface AgentConfig {
|
|
|
69
88
|
/** Programmatic-only, for the same reason as `model` above. */
|
|
70
89
|
thinking?: ThinkingLevel;
|
|
71
90
|
maxTurns?: number;
|
|
91
|
+
/**
|
|
92
|
+
* Token budget for one run of this agent (`max_tokens`). `0`/omitted =
|
|
93
|
+
* unlimited, matching `maxTurns`. Bounds what a single turn can spend, which
|
|
94
|
+
* a turn count cannot: one turn can burn an arbitrary number of tokens.
|
|
95
|
+
*/
|
|
96
|
+
maxTokens?: number;
|
|
97
|
+
/** Tool-call budget for one run of this agent (`max_tool_calls`). `0`/omitted = unlimited. */
|
|
98
|
+
maxToolCalls?: number;
|
|
72
99
|
/** Persist this subagent as a normal pi session instead of keeping it in memory only. */
|
|
73
100
|
persistSession?: boolean;
|
|
74
101
|
/** Write the subagent's .output transcript. Defaults to true; false suppresses only that transcript. */
|
|
@@ -133,6 +160,17 @@ export interface AgentRecord {
|
|
|
133
160
|
outputFile?: string;
|
|
134
161
|
/** Cleanup function for the output file stream subscription. */
|
|
135
162
|
outputCleanup?: () => void;
|
|
163
|
+
/**
|
|
164
|
+
* Session file path when the agent's conversation is persisted to disk
|
|
165
|
+
* (session_file in the agent definition or pi's session persistence). Set at
|
|
166
|
+
* spawn so an evicted record can be reopened as a resumable entry.
|
|
167
|
+
*/
|
|
168
|
+
sessionFile?: string;
|
|
169
|
+
/**
|
|
170
|
+
* Resumable mention handle, when the agent was addressable by `@handle`.
|
|
171
|
+
* Only top-level agents carry one.
|
|
172
|
+
*/
|
|
173
|
+
handle?: string;
|
|
136
174
|
/**
|
|
137
175
|
* Lifetime usage breakdown, accumulated via `message_end` events. Survives
|
|
138
176
|
* compaction. Total = input + output + cacheWrite (cacheRead deliberately
|
|
@@ -179,6 +217,25 @@ export interface AgentRecord {
|
|
|
179
217
|
detached?: boolean;
|
|
180
218
|
}
|
|
181
219
|
|
|
220
|
+
/**
|
|
221
|
+
* What survives a record's eviction so `@handle` keeps working. The live record
|
|
222
|
+
* is discarded after the cleanup timer, but the pi session it wrote is still on
|
|
223
|
+
* disk, and this is the little that is needed to find and describe it again.
|
|
224
|
+
*
|
|
225
|
+
* Named `ResumableAgentEntry` (not "tombstone") because this codebase already
|
|
226
|
+
* uses `ManagedSpawnTombstone` for the managed-spawn idempotency record — two
|
|
227
|
+
* different concepts must not share a name.
|
|
228
|
+
*/
|
|
229
|
+
export interface ResumableAgentEntry {
|
|
230
|
+
handle: string;
|
|
231
|
+
id: string;
|
|
232
|
+
type: SubagentType;
|
|
233
|
+
description: string;
|
|
234
|
+
/** Always set — a record with no session file is never indexed. */
|
|
235
|
+
sessionFile: string;
|
|
236
|
+
completedAt: number;
|
|
237
|
+
}
|
|
238
|
+
|
|
182
239
|
export interface AgentInvocation {
|
|
183
240
|
/** Short display name, e.g. "haiku" — only set when different from parent. */
|
|
184
241
|
modelName?: string;
|