@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,114 @@
1
+ /**
2
+ * ask-tools.ts — `ask_tools:`, the third answer between allow and deny.
3
+ *
4
+ * `tools:` and `disallowed_tools:` are static: a tool is available for the whole
5
+ * run or never. That forces a bad choice for the tools that are usually fine and
6
+ * occasionally not — grant `bash` and hope, or withhold it and cripple the
7
+ * agent. `ask_tools:` names the tools whose every call needs a person to agree.
8
+ *
9
+ * The approver is the HUMAN, deliberately. Upstream projects put an LLM in this
10
+ * seat because their subagents run headless in another process and cannot reach
11
+ * a person; ours share the parent's `ExtensionContext`, so a real approver is
12
+ * one dialog away. Asking a model whether a model should be allowed to do
13
+ * something is a security regression wherever a person is reachable, so this
14
+ * module contains no arbitrator — only the rule vocabulary and a prompt.
15
+ */
16
+
17
+ import { sanitizeDisplayText, truncateCodePoints } from "./ui/safe-text.js";
18
+
19
+ /** Longest tool-argument preview shown in the approval prompt. */
20
+ const MAX_PREVIEW = 300;
21
+
22
+ export interface AskGateDecision {
23
+ block: true;
24
+ reason: string;
25
+ }
26
+
27
+ export interface AskGateContext {
28
+ /** Tool names requiring approval. Matched case-insensitively. */
29
+ askTools: readonly string[];
30
+ /** Prompt the human. Omitted when no human can be reached. */
31
+ confirm?: (title: string, message: string) => Promise<boolean>;
32
+ /** Display name of the agent asking, for the prompt. */
33
+ agentLabel: string;
34
+ }
35
+
36
+ /**
37
+ * Build the per-call approval gate, or `undefined` when nothing needs asking.
38
+ *
39
+ * Returns a function that resolves to a block decision when the call must not
40
+ * proceed, and `undefined` when it may.
41
+ */
42
+ export function createAskGate(
43
+ context: AskGateContext,
44
+ ): ((toolName: string, input: unknown) => Promise<AskGateDecision | undefined>) | undefined {
45
+ const gated = new Set(context.askTools.map((name) => name.trim().toLowerCase()).filter(Boolean));
46
+ if (gated.size === 0) return undefined;
47
+
48
+ /** Tools the user has approved for the rest of this run. */
49
+ const approvedForRun = new Set<string>();
50
+
51
+ return async (toolName, input) => {
52
+ const key = toolName.toLowerCase();
53
+ if (!gated.has(key)) return undefined;
54
+ if (approvedForRun.has(key)) return undefined;
55
+
56
+ // No approver, no approval. Failing OPEN here would silently delete the
57
+ // rule the user wrote — the one case where it matters most is the one where
58
+ // nobody is watching. A headless run of an agent with `ask_tools:` is
59
+ // therefore refused, with a reason that says how to fix it.
60
+ if (!context.confirm) {
61
+ return {
62
+ block: true,
63
+ reason:
64
+ `Tool "${toolName}" requires approval (ask_tools), and there is no interactive session to approve it. ` +
65
+ "Run this agent interactively, or move the tool to `tools:`/`disallowed_tools:` to decide it statically.",
66
+ };
67
+ }
68
+
69
+ let approved: boolean;
70
+ try {
71
+ approved = await context.confirm(
72
+ `${context.agentLabel} wants to use ${toolName}`,
73
+ `${describeInput(input)}\n\nAllow this call?`,
74
+ );
75
+ } catch {
76
+ // A prompt that cannot be shown is not an approval.
77
+ return {
78
+ block: true,
79
+ reason: `Tool "${toolName}" requires approval (ask_tools) and the prompt could not be shown.`,
80
+ };
81
+ }
82
+
83
+ if (!approved) {
84
+ return {
85
+ block: true,
86
+ reason: `The user declined the "${toolName}" call. Do not retry it; continue without that tool or explain what you cannot do.`,
87
+ };
88
+ }
89
+ // Approved for the remainder of the run rather than for this call alone:
90
+ // re-asking on every call of a tool the user just allowed trains them to
91
+ // approve without reading, which is how an approval prompt stops working.
92
+ approvedForRun.add(key);
93
+ return undefined;
94
+ };
95
+ }
96
+
97
+ /**
98
+ * One-line, bounded, inert rendering of a tool call's arguments.
99
+ *
100
+ * The user is being asked to approve a specific call, so the arguments are the
101
+ * whole point — but they are model-authored and about to be drawn into a
102
+ * terminal, so they are sanitized before truncation, never after.
103
+ */
104
+ function describeInput(input: unknown): string {
105
+ if (input === undefined || input === null) return "(no arguments)";
106
+ let rendered: string;
107
+ try {
108
+ rendered = typeof input === "string" ? input : JSON.stringify(input);
109
+ } catch {
110
+ return "(arguments could not be displayed)";
111
+ }
112
+ if (!rendered) return "(no arguments)";
113
+ return truncateCodePoints(sanitizeDisplayText(rendered).replace(/\s+/g, " ").trim(), MAX_PREVIEW, "…");
114
+ }
@@ -41,6 +41,7 @@ export interface SpawnCapable {
41
41
  abort(id: string): boolean;
42
42
  abortOwned?(id: string, owner: AgentOwner): boolean;
43
43
  quiesceOwned?(runId: string, agentIds: string[], timeoutMs: number, owners?: AgentOwner[]): Promise<{ settled: boolean; pending: string[] }>;
44
+ reconcileManaged?(spawnKey: string, owner: AgentOwner): ManagedSpawnResult | undefined;
44
45
  }
45
46
 
46
47
  export interface RpcDeps {
@@ -56,6 +57,7 @@ export interface RpcHandle {
56
57
  unsubStop: () => void;
57
58
  unsubStopOwned: () => void;
58
59
  unsubSpawnManaged: () => void;
60
+ unsubReconcile: () => void;
59
61
  unsubQuiesce: () => void;
60
62
  }
61
63
 
@@ -157,7 +159,7 @@ function handleRpc(
157
159
  }
158
160
 
159
161
  /**
160
- * Register ping, legacy spawn/stop, and managed workflow spawn handlers.
162
+ * Register ping, legacy spawn/stop, and managed workflow spawn/reconciliation handlers.
161
163
  * Returns unsubscribe functions for cleanup.
162
164
  */
163
165
  export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
@@ -204,9 +206,8 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
204
206
  if (!ctx) throw new Error("No active session");
205
207
  if (!manager.spawnManaged) throw new Error("Managed spawn is unavailable");
206
208
  const request = validateManagedSpawnRequest(params);
207
- // The wire request deliberately carries no execution policy. The production
208
- // wrapper resolves it from the agent configuration; direct fixtures receive
209
- // an explicit empty policy so the manager seam remains total.
209
+ // Policy hints are validated at the protocol boundary, then resolved by the
210
+ // production wrapper and AgentManager so the peer never bypasses local policy.
210
211
  const result = manager.spawnManaged(pi, ctx, request, {}) as ManagedSpawnResult | string;
211
212
  // Keep the additive handler tolerant of an older in-process manager fixture
212
213
  // while protocol-v3 managers return the richer state snapshot.
@@ -214,6 +215,16 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
214
215
  return result;
215
216
  });
216
217
 
218
+ const unsubReconcile = handleRpc(events, "subagents:rpc:reconcile-managed", (params) => {
219
+ if (!manager.reconcileManaged) throw new Error("Managed reconciliation is unavailable");
220
+ rejectUnknownKeys(params, new Set(["requestId", "spawnKey", "owner"]), "managed reconciliation request");
221
+ const spawnKey = boundedString(params.spawnKey, "spawnKey", 256);
222
+ const owner = validateManagedOwner(params.owner, true);
223
+ const result = manager.reconcileManaged(spawnKey, owner);
224
+ if (!result) throw new Error("Managed spawn key not found or owner mismatch");
225
+ return result;
226
+ });
227
+
217
228
  const unsubStop = handleRpc(events, "subagents:rpc:stop", (params) => {
218
229
  const agentId = boundedString(params.agentId, "agentId", 128);
219
230
  if (!manager.abort(agentId)) throw new Error("Agent not found");
@@ -254,5 +265,5 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
254
265
  return manager.quiesceOwned(runId, agentIds, rawTimeout, owners);
255
266
  });
256
267
 
257
- return { unsubPing, unsubSpawn, unsubStop, unsubStopOwned, unsubSpawnManaged, unsubQuiesce };
268
+ return { unsubPing, unsubSpawn, unsubStop, unsubStopOwned, unsubSpawnManaged, unsubQuiesce, unsubReconcile };
258
269
  }
@@ -26,6 +26,12 @@ import { sanitizeDisplayText } from "./ui/safe-text.js";
26
26
  type WarningSink = (message: string, key?: string) => void;
27
27
  type SkippedAgent = { name: string; priority: number };
28
28
 
29
+ /**
30
+ * Colon is reserved for plugin-scoped identifiers (extension-owned agent
31
+ * types). A file may not claim one; see the `name:` guard in `loadFromDir`.
32
+ */
33
+ const RESERVED_IN_TYPE = ":";
34
+
29
35
  /** Normalize a discovery root so aliases and symlinked worktree paths share state. */
30
36
  function normalizeDiscoveryRoot(cwd: string): string {
31
37
  const absolute = resolve(cwd);
@@ -71,6 +77,27 @@ function warningReasonKey(reason: string): string {
71
77
  return reason.split(":", 1)[0] ?? reason;
72
78
  }
73
79
 
80
+ /** Best-effort extraction of `name:` from raw frontmatter when strict parsing failed. */
81
+ function peekDeclaredName(path: string): string | undefined {
82
+ try {
83
+ const raw = readFileSync(path, "utf-8");
84
+ const fmMatch = /^---\s*\n([\s\S]*?)\n---/m.exec(raw);
85
+ if (!fmMatch) return undefined;
86
+ const block = fmMatch[1];
87
+ // Match `name:` with optional quotes, trimming inline comments.
88
+ const nameMatch = /^\s*name\s*:\s*["']?([^"'\n#]+?)["']?\s*(?:#.*)?$/m.exec(block);
89
+ if (!nameMatch) return undefined;
90
+ const declared = nameMatch[1].trim();
91
+ // Guard `||` semantics: empty/whitespace falls back to filename.
92
+ if (!declared) return undefined;
93
+ // Colon is plugin-scope reserved; such files never enter skip override logic.
94
+ if (declared.includes(RESERVED_IN_TYPE)) return undefined;
95
+ return declared;
96
+ } catch {
97
+ return undefined;
98
+ }
99
+ }
100
+
74
101
  const MAX_WARNING_ROOTS = 64;
75
102
  const warningCache = new Map<string, Set<string>>();
76
103
 
@@ -154,30 +181,68 @@ function loadFromDir(
154
181
  }
155
182
 
156
183
  for (const file of files) {
157
- const name = basename(file, ".md");
184
+ const filenameType = basename(file, ".md");
185
+
158
186
  const path = join(dir, file);
187
+
159
188
  const parsed = readAgentFile(path, strict, warn);
160
189
  if (!parsed) {
161
- skipped.push({ name, priority });
190
+ // Even a malformed file may have declared `name:`; when it does, the
191
+ // skip must be filed under that name, not the filename, so a higher-
192
+ // priority higher file declaring the same name still suppresses the
193
+ // warning (B1#1). Best-effort regex — the authoritative parse already
194
+ // failed, so this is only for the override warning key.
195
+ const peeked = peekDeclaredName(path);
196
+ const skipName = peeked !== undefined ? peeked : filenameType;
197
+ skipped.push({ name: skipName, priority });
162
198
  continue;
163
199
  }
164
200
  const { frontmatter: fm, body } = parsed;
165
201
 
202
+ // Claude Code's rule: `name:` IS the agent type, and the filename need not
203
+ // match. Absent, the filename stands in — Claude Code requires the field,
204
+ // but most files here predate it and must keep loading.
205
+ const declared = str(fm.name)?.trim();
206
+ if (declared?.includes(RESERVED_IN_TYPE)) {
207
+ // Refusing beats silently substituting: the file would otherwise load
208
+ // under its filename, so `Agent({subagent_type})` would succeed against
209
+ // an agent whose declared identity nothing honoured.
210
+ warn(
211
+ `Agent file ${path} declares name "${declared}", which contains "${RESERVED_IN_TYPE}" — reserved for `
212
+ + "plugin-scoped identifiers. Rename it, or move the label to `display_name:`. Skipping.",
213
+ `reserved-name:${warningIdentity(path)}`,
214
+ );
215
+ // No skipped entry: this file would have registered under its *declared*
216
+ // name, which nothing else can hold (a colon keeps it out of the
217
+ // registry), so it shadowed nothing. Reporting the filename instead
218
+ // would claim a substitution of an unrelated agent that never happened.
219
+ continue;
220
+ }
221
+ // `||`, not `??`: a quoted empty or all-whitespace `name:` would otherwise
222
+ // register the agent under the empty type — unspawnable, and it takes the
223
+ // filename-derived one down with it.
224
+ const name = declared || filenameType;
225
+
166
226
  const { builtinToolNames, extSelectors } = parseToolsField(fm.tools);
167
227
  warnLegacyModelFields(fm, path, warn);
168
228
 
169
229
  agents.set(name, {
170
230
  name,
171
231
  displayName: label(fm.display_name),
232
+ color: str(fm.color)?.trim(),
172
233
  description: label(fm.description) ?? sanitizeDisplayText(name),
173
234
  builtinToolNames,
174
235
  extSelectors,
175
236
  disallowedTools: csvListOptional(fm.disallowed_tools),
237
+ askTools: csvListOptional(fm.ask_tools),
238
+ gate: str(fm.gate)?.trim(),
176
239
  extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
177
240
  excludeExtensions: csvListOptional(fm.exclude_extensions),
178
241
  skills: inheritField(fm.skills ?? fm.inherit_skills),
179
242
  agentTier: parseTier(fm.tier, path, warn),
180
243
  maxTurns: nonNegativeInt(fm.max_turns),
244
+ maxTokens: nonNegativeInt(fm.max_tokens),
245
+ maxToolCalls: nonNegativeInt(fm.max_tool_calls),
181
246
  persistSession: fm.persist_session != null ? fm.persist_session === true : undefined,
182
247
  outputTranscript: fm.output_transcript != null ? fm.output_transcript !== false : undefined,
183
248
  sessionDir: str(fm.session_dir),
@@ -34,11 +34,12 @@ export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
34
34
  builtinToolNames: READ_ONLY_TOOLS,
35
35
  extensions: true,
36
36
  skills: true,
37
- // No model pin. Which model a subagent runs is the tier catalogue's
38
- // decision; a built-in that pinned one would be the same end-run around it
39
- // that agent frontmatter is no longer allowed to make, and it would name a
40
- // vendor on a machine that may not have it. With no tier configured this
41
- // inherits the parent's model, which is the documented fallback.
37
+ // Runs on the shipped `fast` agent tier (model inherit, low thinking) so
38
+ // read-only search does not inherit the parent session's most expensive
39
+ // model on machines that never configured agentTiers. The tier is the
40
+ // policy: point `fast` at a cheap model in subagents.json and Explore
41
+ // follows without touching agent files.
42
+ agentTier: "fast",
42
43
  systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
43
44
  You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
44
45
  Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools.
package/src/gate.ts ADDED
Binary file