@signalridge/pi-subagents 1.5.0 → 1.7.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.
@@ -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;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * agent-mention.ts — what `@` can address, and the suggestions pi renders for it.
3
+ *
4
+ * A subagent is addressable whether or not it is currently running: a live
5
+ * record is messaged or resumed, an evicted one whose session is still on disk
6
+ * is reopened, and an agent *type* with no instance at all is started. That is
7
+ * the point of the handle — `@explore` means the Explore agent, not "the
8
+ * Explore process that happens to exist right now" — so the roster below unions
9
+ * all three, and the dispatcher and the popup read the same list.
10
+ *
11
+ * pi's `CombinedAutocompleteProvider` already owns `@`, where it means "attach a
12
+ * file". Extensions can wrap it (`ctx.ui.addAutocompleteProvider`), so this
13
+ * provider answers the `@` tokens that name an agent and delegates every other
14
+ * one — including all of `applyCompletion`, whose `@`-branch already inserts
15
+ * `item.value` plus a trailing space, which is exactly what a handle needs.
16
+ *
17
+ * Matching is case-insensitive prefix (not fuzzy), and when any agent matches,
18
+ * files are dropped from the list rather than mixed in — an `@name` that names
19
+ * an agent is never also a path.
20
+ *
21
+ * Every string that reaches a row passes through `sanitizeDisplayText` first:
22
+ * an agent description comes from a `.pi/agents/*.md` file that may not be
23
+ * trustworthy, and the popup draws into the user's terminal.
24
+ */
25
+
26
+ import type { AutocompleteItem, AutocompleteProvider, AutocompleteSuggestions } from "@earendil-works/pi-tui";
27
+ import type { AgentManager } from "../agent-manager.js";
28
+ import { handleBase, MENTION_TRIGGER } from "../mention.js";
29
+ import type { AgentRecordSnapshot, ResumableAgentEntry } from "../types.js";
30
+ import { sanitizeDisplayText, truncateCodePoints } from "./safe-text.js";
31
+
32
+ /**
33
+ * One thing `@` can address, and what sending to it will do. `typeLabel` is the
34
+ * agent's `display_name`, resolved by the caller: this module stays independent
35
+ * of the type registry, but the popup must agree with FleetView and the widget,
36
+ * which both render the label rather than the raw type.
37
+ */
38
+ export type MentionTarget =
39
+ | { kind: "record"; handle: string; record: AgentRecordSnapshot; typeLabel: string }
40
+ | { kind: "resumable"; handle: string; entry: ResumableAgentEntry; typeLabel: string }
41
+ | { kind: "type"; handle: string; type: string; description: string };
42
+
43
+ /** The registry facts the roster needs, so it stays independent of agent-types. */
44
+ export type TypeInfo = { name: string; description: string };
45
+
46
+ /**
47
+ * Everything `@` can reach, in the order the popup lists it: steerable agents
48
+ * first, then the other live ones earliest-launched, then evicted conversations
49
+ * that can be reopened, then agent types with no live instance. A type whose
50
+ * handle a record already holds is omitted — that name addresses the existing
51
+ * agent, which is what makes `@explore` mean "message the one that's running"
52
+ * and only otherwise "start one".
53
+ */
54
+ export function mentionRoster(
55
+ manager: AgentManager,
56
+ types: readonly TypeInfo[],
57
+ // Identity by default: a caller with no registry to consult gets the raw
58
+ // type, which is also what the config lookup falls back to when no label is set.
59
+ displayNameOf: (type: string) => string = (type) => type,
60
+ ): MentionTarget[] {
61
+ const isLive = (r: AgentRecordSnapshot) => r.status === "running" || r.status === "queued";
62
+ const records = manager
63
+ .listAgents()
64
+ .filter((r) => r.handle !== undefined && r.parentAgentId === undefined)
65
+ .sort((a, b) => Number(isLive(b)) - Number(isLive(a)) || a.startedAt - b.startedAt);
66
+
67
+ const taken = new Set<string>();
68
+ const targets: MentionTarget[] = [];
69
+
70
+ for (const record of records) {
71
+ const handle = record.handle as string;
72
+ taken.add(handle.toLowerCase());
73
+ targets.push({ kind: "record", handle, record, typeLabel: displayNameOf(record.type) });
74
+ }
75
+
76
+ // Then agents that are gone but whose conversation can be reopened. After the
77
+ // live ones: a running agent is the likelier target, and this keeps the
78
+ // ordering "what exists now, then what can be brought back, then what can be
79
+ // started".
80
+ for (const entry of manager.listResumable()) {
81
+ if (taken.has(entry.handle.toLowerCase())) continue;
82
+ taken.add(entry.handle.toLowerCase());
83
+ targets.push({ kind: "resumable", handle: entry.handle, entry, typeLabel: displayNameOf(entry.type) });
84
+ }
85
+
86
+ for (const type of types) {
87
+ const handle = handleBase(type.name);
88
+ if (taken.has(handle)) continue;
89
+ taken.add(handle);
90
+ targets.push({ kind: "type", handle, type: type.name, description: type.description });
91
+ }
92
+ return targets;
93
+ }
94
+
95
+ export function createMentionProvider(
96
+ current: AutocompleteProvider,
97
+ roster: () => MentionTarget[],
98
+ isEnabled: () => boolean,
99
+ ): AutocompleteProvider {
100
+ return {
101
+ // Only `@` — the contract is "characters that should naturally trigger THIS
102
+ // provider", and pi unions each wrapper's own set onto the outermost one
103
+ // itself, so re-declaring the wrapped provider's characters here would both
104
+ // misreport us and duplicate that.
105
+ triggerCharacters: ["@"],
106
+
107
+ async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
108
+ const items = isEnabled() ? mentionItems(roster(), lines[cursorLine] ?? "", cursorCol) : null;
109
+ if (items) return items;
110
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
111
+ },
112
+
113
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
114
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
115
+ },
116
+
117
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
118
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
119
+ },
120
+ };
121
+ }
122
+
123
+ /** Suggestions for the `@…` token under the cursor, or null when it names no agent. */
124
+ export function mentionItems(
125
+ roster: readonly MentionTarget[],
126
+ line: string,
127
+ cursorCol: number,
128
+ ): AutocompleteSuggestions | null {
129
+ const match = MENTION_TRIGGER.exec(line.slice(0, cursorCol));
130
+ if (!match) return null;
131
+
132
+ const typed = match[2].toLowerCase();
133
+ const items: AutocompleteItem[] = [];
134
+ for (const target of roster) {
135
+ if (!target.handle.toLowerCase().startsWith(typed)) continue;
136
+ items.push({ value: `@${target.handle}`, label: `@${target.handle}`, description: describeTarget(target) });
137
+ }
138
+ return items.length > 0 ? { items, prefix: `@${match[2]}` } : null;
139
+ }
140
+
141
+ /** Name the action that will actually happen, so the list never mispromises. */
142
+ function describeTarget(target: MentionTarget): string {
143
+ if (target.kind === "type") return `start agent · ${summarize(target.description)}`;
144
+ if (target.kind === "resumable") {
145
+ // No status: the record is gone, and "completed" would imply one is still
146
+ // being tracked. The type carries the identity the handle may not.
147
+ return `resume · ${sanitizeDisplayText(target.typeLabel)} · ${summarize(target.entry.description)}`;
148
+ }
149
+ const { status, description } = target.record;
150
+ const action = status === "running" || status === "queued" ? "send message" : "resume";
151
+ return `${action} · ${status} · ${summarize(description)}`;
152
+ }
153
+
154
+ /**
155
+ * First sentence of a description, sanitized then clipped — agent descriptions
156
+ * run to paragraphs, and they come from files this extension did not write.
157
+ * Sanitize BEFORE truncating: cutting first can sever an escape sequence and
158
+ * leave a live introducer behind.
159
+ */
160
+ function summarize(description: string): string {
161
+ const first = (description.match(/^.*?[.!?](?=\s|$)/s)?.[0] ?? description).replace(/\s+/g, " ").trim();
162
+ return truncateCodePoints(sanitizeDisplayText(first), 60, "…");
163
+ }
@@ -7,11 +7,12 @@
7
7
 
8
8
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
9
9
  import { type Component, Input, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
10
+ import { renderAgentName } from "../agent-color.js";
10
11
  import { extractText } from "../context.js";
11
12
  import type { AgentRecordSnapshot } from "../types.js";
12
13
  import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
13
14
  import type { Theme } from "./agent-display.js";
14
- import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-display.js";
15
+ import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getPromptModeLabel } from "./agent-display.js";
15
16
  import { PREVIEW_SCAN_LIMIT, safeTerminalText, sanitizeDisplayText, truncateCodePoints } from "./safe-text.js";
16
17
  import { getAgentStatusColor, getAgentStatusLabel, getAgentStatusMark } from "./status-label.js";
17
18
  import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
@@ -182,12 +183,12 @@ export class ConversationViewer implements Component {
182
183
  // agent's conversation ends and the parent's resumes. An inner rule would be
183
184
  // a third horizontal line competing with the two that mark the boundary, and
184
185
  // a four-sided box would cost two columns on every row for the same job.
185
- const rule = () => th.fg("border", "─".repeat(Math.max(0, width)));
186
+ const rule = () => th.fg("borderAccent", "─".repeat(Math.max(0, width)));
186
187
  const hrMid = row("");
187
188
 
188
189
  lines.push(rule());
189
190
  lines.push(row(th.bold("Agent conversation")));
190
- const name = getDisplayName(this.record.type);
191
+ const name = renderAgentName(this.record.type, th, { bold: true });
191
192
  const modeLabel = getPromptModeLabel(this.record.type);
192
193
  const modeTag = modeLabel ? th.fg("dim", `mode ${modeLabel}`) : undefined;
193
194
  const statusText = getAgentStatusLabel(this.record.status);
@@ -205,7 +206,7 @@ export class ConversationViewer implements Component {
205
206
 
206
207
  const headerParts = [
207
208
  th.fg(statusColor, `${getAgentStatusMark(this.record.status)} ${statusText}`),
208
- th.bold(name),
209
+ name,
209
210
  modeTag,
210
211
  th.fg("muted", sanitizeDisplayText(this.record.description)),
211
212
  fgPreservingNestedStyles(th, "dim", headerStats.join(" · ")),
@@ -12,10 +12,12 @@
12
12
  */
13
13
 
14
14
  import { Editor, isKeyRelease, Key, matchesKey, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
+ import { borderedComponent } from "@signalridge/pi-ui";
16
+ import { renderAgentName } from "../agent-color.js";
15
17
  import type { AgentManager } from "../agent-manager.js";
16
18
  import type { AgentRecord } from "../types.js";
17
19
  import { getLifetimeTotal } from "../usage.js";
18
- import { type AgentActivity, getDisplayName, type Theme } from "./agent-display.js";
20
+ import { type AgentActivity, type Theme } from "./agent-display.js";
19
21
  import { ConversationViewer, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
20
22
  import { sanitizeDisplayText } from "./safe-text.js";
21
23
  import { getAgentStatusColor, getAgentStatusLabel, getAgentStatusMark } from "./status-label.js";
@@ -301,7 +303,7 @@ export class FleetList {
301
303
  void this.ui.custom<undefined>(
302
304
  (tui, theme, keybindings, done) => {
303
305
  this.viewerClose = () => done(undefined);
304
- return new ConversationViewer(
306
+ return borderedComponent(new ConversationViewer(
305
307
  tui,
306
308
  session,
307
309
  record,
@@ -314,7 +316,7 @@ export class FleetList {
314
316
  keybindings,
315
317
  (message: string) => this.manager.steer(record.id, message),
316
318
  () => this.manager.getRecord?.(record.id),
317
- );
319
+ ), (text) => theme.fg("borderAccent", text));
318
320
  },
319
321
  {
320
322
  overlay: true,
@@ -379,11 +381,11 @@ export class FleetList {
379
381
 
380
382
  private renderAgentRow(rosterIndex: number, sel: number, record: AgentRecord, width: number, theme: Theme): string {
381
383
  const selected = rosterIndex === sel;
382
- const name = getDisplayName(record.type);
383
384
  const status = getAgentStatusLabel(record.status);
384
- const nameText = selected
385
- ? theme.bold(theme.fg("accent", name))
386
- : theme.fg("muted", name);
385
+ const nameText = renderAgentName(record.type, theme, {
386
+ fallbackColor: selected ? "accent" : "muted",
387
+ bold: selected,
388
+ });
387
389
  const description = sanitizeDisplayText(record.description);
388
390
  const descriptionText = selected ? theme.fg("accent", description) : description;
389
391
  const semanticStatusColor = getAgentStatusColor(record.status);