@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/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 { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
- import { dirname, join } from "node:path";
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
- const VALID_THINKING_LEVELS: ReadonlySet<string> = new Set([
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
- "inherit",
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
- function validWorkflowModelReference(value: unknown): value is string {
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 (!validWorkflowModelReference(raw.model) || !validThinkingLevel(raw.thinking)) return undefined;
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 (!validWorkflowModelReference(raw.model) || !validThinkingLevel(raw.thinking)) return undefined;
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
- writeFileSync(path, JSON.stringify(sanitize(s), null, 2), "utf-8");
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;