@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.
- package/CHANGELOG.md +44 -0
- package/README.md +7 -4
- package/package.json +7 -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 +313 -21
- package/src/agent-tiers.ts +82 -3
- 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 +3165 -1195
- package/src/mention-clone.ts +196 -0
- package/src/mention.ts +141 -0
- package/src/output-file.ts +23 -1
- package/src/settings.ts +159 -3
- 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 +5 -4
- package/src/ui/fleet-list.ts +9 -7
- package/src/worktree.ts +128 -648
package/src/ask-tools.ts
ADDED
|
@@ -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
|
-
//
|
|
208
|
-
// wrapper
|
|
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
|
}
|
package/src/custom-agents.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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),
|
package/src/default-agents.ts
CHANGED
|
@@ -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
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
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
|