@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/agent-tiers.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { type Api, clampThinkingLevel, getSupportedThinkingLevels, type Model } from "@earendil-works/pi-ai";
|
|
27
27
|
import { type ModelRegistry, resolveModel } from "./model-resolver.js";
|
|
28
|
-
import type
|
|
28
|
+
import { type AgentTierProfile, type AgentTiersSettings, TIER_THINKING_LEVELS, type TierThinking } from "./settings.js";
|
|
29
29
|
import type { AgentConfig, ThinkingLevel } from "./types.js";
|
|
30
30
|
|
|
31
31
|
/** `provider/id`, or undefined when no model was selected. */
|
|
@@ -36,14 +36,82 @@ function effectiveModelId(model: Model<Api> | undefined): string | undefined {
|
|
|
36
36
|
/** Longest accepted tier key. Long enough for any real name, short enough to render. */
|
|
37
37
|
export const MAX_AGENT_TIER_KEY_LENGTH = 64;
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The tier every fresh install gets: a cheap, low-thinking tier named `fast`.
|
|
41
|
+
*
|
|
42
|
+
* Explore — the highest-frequency built-in spawn — points its `tier:` at it, so
|
|
43
|
+
* read-only search does not silently inherit the parent session's most
|
|
44
|
+
* expensive model on a machine that never configured `agentTiers`. This is the
|
|
45
|
+
* tier strategy, not a per-agent vendor pin: the shipped profile is
|
|
46
|
+
* provider-neutral (`inherit` model, low thinking), and any user who defines
|
|
47
|
+
* `fast` in `subagents.json` replaces it wholesale.
|
|
48
|
+
*
|
|
49
|
+
* It is not shown as an available tier until settings are loaded; the merge in
|
|
50
|
+
* `setAgentTiersSettings` is where a worktree that explicitly disables/renames
|
|
51
|
+
* `fast` can win.
|
|
52
|
+
*/
|
|
53
|
+
const SHIPPED_FAST_PROFILE: AgentTierProfile = {
|
|
54
|
+
model: "inherit",
|
|
55
|
+
thinking: "low",
|
|
56
|
+
description: "Fast, low-cost tier for cheap read-only work (shipped default)",
|
|
57
|
+
};
|
|
40
58
|
|
|
59
|
+
export const SHIPPED_AGENT_TIER_PROFILES: Readonly<Record<string, AgentTierProfile>> = {
|
|
60
|
+
fast: SHIPPED_FAST_PROFILE,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
let agentTiersSettings: AgentTiersSettings = {}; // effective view (shipped tiers merged)
|
|
64
|
+
let agentTiersConfigured: AgentTiersSettings = {}; // exactly what the user configured
|
|
65
|
+
|
|
66
|
+
/** Effective catalogue: shipped tiers merged under any user configuration. */
|
|
41
67
|
export function getAgentTiersSettings(): AgentTiersSettings {
|
|
42
68
|
return structuredClone(agentTiersSettings);
|
|
43
69
|
}
|
|
44
70
|
|
|
71
|
+
/** The raw user configuration, without shipped tiers — what snapshotSettings writes back. */
|
|
72
|
+
export function getAgentTiersConfiguredSettings(): AgentTiersSettings {
|
|
73
|
+
return structuredClone(agentTiersConfigured);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Exactly-equal profile? Used to strip untouched shipped tiers from the configured view. */
|
|
77
|
+
function sameProfile(a: AgentTierProfile, b: AgentTierProfile): boolean {
|
|
78
|
+
return a.model === b.model && a.thinking === b.thinking && (a.description ?? "") === (b.description ?? "");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Install the effective tier catalogue.
|
|
83
|
+
*
|
|
84
|
+
* The shipped `fast` tier is merged in unless the caller already defined it or
|
|
85
|
+
* explicitly blocked it — a user catalogue wins over the shipped default, and a
|
|
86
|
+
* tombstone means "do not substitute", which applies to shipped defaults too.
|
|
87
|
+
*
|
|
88
|
+
* The configured view is derived from the same input by stripping profiles that
|
|
89
|
+
* exactly equal a shipped default, so the UI can operate on the effective view
|
|
90
|
+
* and send it back without materializing untouched shipped tiers into
|
|
91
|
+
* `subagents.json`. Editing a shipped tier (changing its model, thinking, or
|
|
92
|
+
* description) makes it a user-owned profile and it is then persisted; deleting
|
|
93
|
+
* one leaves its tombstone, which persists.
|
|
94
|
+
*/
|
|
45
95
|
export function setAgentTiersSettings(settings: AgentTiersSettings): void {
|
|
46
|
-
|
|
96
|
+
const effective = structuredClone(settings);
|
|
97
|
+
const profiles = { ...(effective.profiles ?? {}) };
|
|
98
|
+
const blocked = new Set<string>(effective.blockedProfiles ?? []);
|
|
99
|
+
|
|
100
|
+
const configuredProfiles: Record<string, AgentTierProfile> = {};
|
|
101
|
+
for (const [key, profile] of Object.entries(profiles)) {
|
|
102
|
+
const shipped = SHIPPED_AGENT_TIER_PROFILES[key];
|
|
103
|
+
if (!blocked.has(key) && shipped !== undefined && sameProfile(profile, shipped)) continue;
|
|
104
|
+
configuredProfiles[key] = profile;
|
|
105
|
+
}
|
|
106
|
+
for (const [key, profile] of Object.entries(SHIPPED_AGENT_TIER_PROFILES)) {
|
|
107
|
+
if (!blocked.has(key) && profiles[key] === undefined) profiles[key] = profile;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
agentTiersSettings = { ...effective, profiles };
|
|
111
|
+
const configured: AgentTiersSettings = { ...effective };
|
|
112
|
+
if (Object.keys(configuredProfiles).length > 0) configured.profiles = configuredProfiles;
|
|
113
|
+
else delete configured.profiles;
|
|
114
|
+
agentTiersConfigured = configured;
|
|
47
115
|
}
|
|
48
116
|
|
|
49
117
|
/**
|
|
@@ -111,15 +179,128 @@ export class AgentTierError extends Error {
|
|
|
111
179
|
}
|
|
112
180
|
}
|
|
113
181
|
|
|
114
|
-
|
|
182
|
+
/** Every defined tier key, sorted — the catalogue as the UI and the host see it. */
|
|
183
|
+
export function listAgentTierKeys(settings: AgentTiersSettings): string[] {
|
|
115
184
|
return Object.keys(settings.profiles ?? {}).sort((a, b) => a.localeCompare(b));
|
|
116
185
|
}
|
|
117
186
|
|
|
118
187
|
function tierKeyList(settings: AgentTiersSettings): string {
|
|
119
|
-
const keys =
|
|
188
|
+
const keys = listAgentTierKeys(settings);
|
|
120
189
|
return keys.length > 0 ? keys.join(", ") : "(none configured)";
|
|
121
190
|
}
|
|
122
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Tier edits, as pure settings-to-settings functions.
|
|
194
|
+
*
|
|
195
|
+
* The `/agents → Model tiers` menu is the only caller, but the rules it has to
|
|
196
|
+
* obey are policy, not presentation — retiring a tombstone, not leaving
|
|
197
|
+
* `defaultTier` pointing at a tier that no longer exists — so they live here
|
|
198
|
+
* with the resolver that enforces the other half of the same invariants.
|
|
199
|
+
*
|
|
200
|
+
* Each returns a fresh object and omits empty containers, so a catalogue edited
|
|
201
|
+
* back down to nothing serializes as nothing rather than as empty braces.
|
|
202
|
+
*/
|
|
203
|
+
function withoutBlocked(blocked: string[] | undefined, key: string): string[] | undefined {
|
|
204
|
+
const rest = (blocked ?? []).filter((k) => k !== key);
|
|
205
|
+
return rest.length > 0 ? rest : undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function compactTierSettings(settings: AgentTiersSettings): AgentTiersSettings {
|
|
209
|
+
const out: AgentTiersSettings = {};
|
|
210
|
+
if (settings.defaultTier !== undefined) out.defaultTier = settings.defaultTier;
|
|
211
|
+
if (settings.profiles && Object.keys(settings.profiles).length > 0) out.profiles = settings.profiles;
|
|
212
|
+
if (settings.blockedProfiles && settings.blockedProfiles.length > 0) {
|
|
213
|
+
out.blockedProfiles = settings.blockedProfiles;
|
|
214
|
+
}
|
|
215
|
+
if (settings.blockedDefaultTier) out.blockedDefaultTier = true;
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Define or replace one tier.
|
|
221
|
+
*
|
|
222
|
+
* Writing a valid profile retires that key's tombstone: the tombstone exists to
|
|
223
|
+
* stop a malformed entry from silently resolving to some other model, and an
|
|
224
|
+
* explicit definition is the fix it was waiting for.
|
|
225
|
+
*/
|
|
226
|
+
export function upsertAgentTierProfile(
|
|
227
|
+
settings: AgentTiersSettings,
|
|
228
|
+
key: string,
|
|
229
|
+
profile: AgentTierProfile,
|
|
230
|
+
): AgentTiersSettings {
|
|
231
|
+
return compactTierSettings({
|
|
232
|
+
...settings,
|
|
233
|
+
profiles: { ...settings.profiles, [key]: profile },
|
|
234
|
+
blockedProfiles: withoutBlocked(settings.blockedProfiles, key),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Delete one tier.
|
|
240
|
+
*
|
|
241
|
+
* A `defaultTier` pointing at it is cleared in the same step. Leaving it would
|
|
242
|
+
* turn every later spawn that names no tier into a hard refusal, which is a
|
|
243
|
+
* strange thing to get from deleting a tier you had stopped using.
|
|
244
|
+
*
|
|
245
|
+
* Deleting a shipped tier (the default `fast`) tombstones it instead of just
|
|
246
|
+
* dropping it: the shipped merge in `setAgentTiersSettings` would otherwise
|
|
247
|
+
* silently re-add it on the next load, and a user who deletes it means it. The
|
|
248
|
+
* tombstone says "do not substitute", which is exactly the semantics the load
|
|
249
|
+
* path already honors for malformed profiles. Explore still names `fast` in its
|
|
250
|
+
* frontmatter, so the spawn refusal then says so loudly until the agent file or
|
|
251
|
+
* the tier is fixed.
|
|
252
|
+
*/
|
|
253
|
+
export function removeAgentTierProfile(settings: AgentTiersSettings, key: string): AgentTiersSettings {
|
|
254
|
+
const { [key]: _removed, ...profiles } = settings.profiles ?? {};
|
|
255
|
+
const shipped = Object.hasOwn(SHIPPED_AGENT_TIER_PROFILES, key);
|
|
256
|
+
let blocked = withoutBlocked(settings.blockedProfiles, key);
|
|
257
|
+
if (shipped) blocked = [...(blocked ?? []), key];
|
|
258
|
+
return compactTierSettings({
|
|
259
|
+
...settings,
|
|
260
|
+
profiles,
|
|
261
|
+
blockedProfiles: blocked,
|
|
262
|
+
...(settings.defaultTier === key ? { defaultTier: undefined } : {}),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The thinking values a tier may usefully store for one model reference.
|
|
268
|
+
*
|
|
269
|
+
* Asked of the model rather than read off a fixed list, because `resolveAgentTier`
|
|
270
|
+
* clamps an unsupported level at spawn time: a menu offering a level destined to
|
|
271
|
+
* be silently lowered would be a menu that lies. `inherit` is always offerable —
|
|
272
|
+
* it defers to the parent session, which this model has no say over.
|
|
273
|
+
*
|
|
274
|
+
* A reference of `inherit`, or one this machine cannot resolve, yields the full
|
|
275
|
+
* static list: the model is not knowable here, and refusing to let the user
|
|
276
|
+
* configure a tier for a provider they have not authed yet would make the menu
|
|
277
|
+
* weaker than hand-editing the file.
|
|
278
|
+
*/
|
|
279
|
+
export function offerableTierThinking(
|
|
280
|
+
modelRef: string,
|
|
281
|
+
registry: ModelRegistry<Model<Api>>,
|
|
282
|
+
): TierThinking[] {
|
|
283
|
+
if (modelRef === "inherit") return [...TIER_THINKING_LEVELS];
|
|
284
|
+
const resolved = resolveModel(modelRef, registry);
|
|
285
|
+
if (typeof resolved === "string") return [...TIER_THINKING_LEVELS];
|
|
286
|
+
const supported = new Set<string>(getSupportedThinkingLevels(resolved));
|
|
287
|
+
return TIER_THINKING_LEVELS.filter(level => level === "inherit" || supported.has(level));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Set or clear the default tier.
|
|
292
|
+
*
|
|
293
|
+
* Always clears `blockedDefaultTier`: that tombstone describes the malformed
|
|
294
|
+
* value this call is replacing, and keeping it would make the resolver refuse
|
|
295
|
+
* the choice the user just made explicitly.
|
|
296
|
+
*/
|
|
297
|
+
export function setDefaultAgentTier(
|
|
298
|
+
settings: AgentTiersSettings,
|
|
299
|
+
key: string | undefined,
|
|
300
|
+
): AgentTiersSettings {
|
|
301
|
+
return compactTierSettings({ ...settings, defaultTier: key, blockedDefaultTier: false });
|
|
302
|
+
}
|
|
303
|
+
|
|
123
304
|
/**
|
|
124
305
|
* Which tier applies, and where it came from.
|
|
125
306
|
*
|
|
@@ -285,7 +466,7 @@ export function findUnknownAgentTierReferences(
|
|
|
285
466
|
* models and thinking levels appear — nothing here reads credentials.
|
|
286
467
|
*/
|
|
287
468
|
export function buildAgentTierListText(settings: AgentTiersSettings = agentTiersSettings): string {
|
|
288
|
-
const keys =
|
|
469
|
+
const keys = listAgentTierKeys(settings);
|
|
289
470
|
if (keys.length === 0) return "";
|
|
290
471
|
|
|
291
472
|
const entries = keys.map((key) => {
|
|
@@ -304,7 +485,7 @@ export function buildAgentTierListText(settings: AgentTiersSettings = agentTiers
|
|
|
304
485
|
|
|
305
486
|
/** One line per tier, for the compact tool description. */
|
|
306
487
|
export function buildCompactAgentTierListText(settings: AgentTiersSettings = agentTiersSettings): string {
|
|
307
|
-
const keys =
|
|
488
|
+
const keys = listAgentTierKeys(settings);
|
|
308
489
|
if (keys.length === 0) return "";
|
|
309
490
|
|
|
310
491
|
const entries = keys.map((key) => {
|
|
@@ -323,7 +504,7 @@ export function getDefaultAgentTierText(settings: AgentTiersSettings = agentTier
|
|
|
323
504
|
|
|
324
505
|
/** Description for the `tier` parameter, naming the keys this workspace defines. */
|
|
325
506
|
export function buildAgentTierParameterDescription(settings: AgentTiersSettings = agentTiersSettings): string {
|
|
326
|
-
const keys =
|
|
507
|
+
const keys = listAgentTierKeys(settings);
|
|
327
508
|
const available = keys.length > 0 ? keys.join(", ") : "none configured";
|
|
328
509
|
const fallback =
|
|
329
510
|
settings.defaultTier !== undefined
|
package/src/agent-types.ts
CHANGED
|
@@ -295,6 +295,7 @@ export function getToolNamesForType(type: string): string[] {
|
|
|
295
295
|
/** Get config for a type (case-insensitive, returns a SubagentTypeConfig-compatible object). Falls back to general-purpose. */
|
|
296
296
|
export function getConfig(type: string): {
|
|
297
297
|
displayName: string;
|
|
298
|
+
color?: string;
|
|
298
299
|
description: string;
|
|
299
300
|
builtinToolNames: string[];
|
|
300
301
|
extensions: true | string[] | false;
|
|
@@ -307,6 +308,7 @@ export function getConfig(type: string): {
|
|
|
307
308
|
if (config && config.enabled !== false) {
|
|
308
309
|
return {
|
|
309
310
|
displayName: config.displayName ?? config.name,
|
|
311
|
+
color: config.color,
|
|
310
312
|
description: config.description,
|
|
311
313
|
builtinToolNames: config.builtinToolNames ?? BUILTIN_TOOL_NAMES,
|
|
312
314
|
extensions: config.extensions,
|
|
@@ -321,6 +323,7 @@ export function getConfig(type: string): {
|
|
|
321
323
|
if (gp && gp.enabled !== false) {
|
|
322
324
|
return {
|
|
323
325
|
displayName: gp.displayName ?? gp.name,
|
|
326
|
+
color: gp.color,
|
|
324
327
|
description: gp.description,
|
|
325
328
|
builtinToolNames: gp.builtinToolNames ?? BUILTIN_TOOL_NAMES,
|
|
326
329
|
extensions: gp.extensions,
|
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
|