@tintinweb/pi-subagents 0.9.0 → 0.10.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.
@@ -2,8 +2,10 @@
2
2
  * agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
3
3
  */
4
4
 
5
+ import { homedir } from "node:os";
6
+ import { basename, dirname, isAbsolute, resolve } from "node:path";
5
7
  import type { Model } from "@earendil-works/pi-ai";
6
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import type { ExtensionContext, LoadExtensionsResult } from "@earendil-works/pi-coding-agent";
7
9
  import {
8
10
  type AgentSession,
9
11
  type AgentSessionEvent,
@@ -14,7 +16,7 @@ import {
14
16
  SessionManager,
15
17
  SettingsManager,
16
18
  } from "@earendil-works/pi-coding-agent";
17
- import { getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
19
+ import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
18
20
  import { buildParentContext, extractText } from "./context.js";
19
21
  import { DEFAULT_AGENTS } from "./default-agents.js";
20
22
  import { detectEnv } from "./env.js";
@@ -23,8 +25,114 @@ import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
23
25
  import { preloadSkills } from "./skill-loader.js";
24
26
  import type { SubagentType, ThinkingLevel } from "./types.js";
25
27
 
28
+ /**
29
+ * Tool names registered by THIS extension. Single source of truth so the
30
+ * registration sites (index.ts) and the subagent exclusion list below can't
31
+ * drift apart. These are our own tools, not pi built-ins, so they can't be
32
+ * derived from pi — but they only need defining once.
33
+ */
34
+ export const SUBAGENT_TOOL_NAMES = {
35
+ AGENT: "Agent",
36
+ GET_RESULT: "get_subagent_result",
37
+ STEER: "steer_subagent",
38
+ } as const;
39
+
26
40
  /** Names of tools registered by this extension that subagents must NOT inherit. */
27
- const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"];
41
+ const EXCLUDED_TOOL_NAMES: string[] = Object.values(SUBAGENT_TOOL_NAMES);
42
+
43
+ /**
44
+ * Canonical name of an extension for `extensions: [...]` allowlist matching.
45
+ * Lowercased — extension names match case-insensitively so `extensions: [Mcp]`
46
+ * resolves the same as `[mcp]`. Tool names within `ext:foo/bar` are not affected.
47
+ * Directory extensions (`foo/index.ts`) resolve to the parent directory name;
48
+ * single-file extensions to the basename minus `.ts`/`.js`.
49
+ */
50
+ export function extensionCanonicalName(extPath: string): string {
51
+ const base = basename(extPath);
52
+ const name = base === "index.ts" || base === "index.js"
53
+ ? basename(dirname(extPath))
54
+ : base.replace(/\.(ts|js)$/, "");
55
+ return name.toLowerCase();
56
+ }
57
+
58
+ /**
59
+ * Classify `extensions: string[]` frontmatter entries for the loader-level filter.
60
+ *
61
+ * An entry is a PATH iff it contains a path separator or starts with `~`; otherwise
62
+ * it is a NAME. `"*"` sets the wildcard flag (keep all default-discovered extensions).
63
+ *
64
+ * Path entries are resolved (`~` expanded, made absolute against `cwd`) into `paths`
65
+ * — and their canonical name is also added to `names`. The loader override matches
66
+ * everything by canonical name, so path-loaded extensions are matched via their name
67
+ * rather than their post-staging `Extension.path`.
68
+ */
69
+ export function parseExtensionsSpec(
70
+ entries: string[],
71
+ cwd: string,
72
+ ): { names: Set<string>; paths: string[]; wildcard: boolean } {
73
+ const names = new Set<string>();
74
+ const paths: string[] = [];
75
+ let wildcard = false;
76
+ for (const entry of entries) {
77
+ if (!entry) continue;
78
+ if (entry === "*") {
79
+ wildcard = true;
80
+ continue;
81
+ }
82
+ const isPathEntry = entry.includes("/") || entry.includes("\\") || entry.startsWith("~");
83
+ if (!isPathEntry) {
84
+ names.add(entry.toLowerCase());
85
+ continue;
86
+ }
87
+ let p = entry;
88
+ if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) {
89
+ p = homedir() + p.slice(1);
90
+ }
91
+ const abs = isAbsolute(p) ? p : resolve(cwd, p);
92
+ paths.push(abs);
93
+ names.add(extensionCanonicalName(abs));
94
+ }
95
+ return { names, paths, wildcard };
96
+ }
97
+
98
+ /**
99
+ * Parse raw `ext:` selector strings (from the `tools:` CSV) into the set of
100
+ * extension names to keep loaded and a per-extension tool-narrowing map.
101
+ *
102
+ * `ext:foo` → `extNames` has `foo`, no narrowing entry (all of foo's tools).
103
+ * `ext:foo/bar` → `extNames` has `foo`, `narrowing.foo` has `bar` (only `bar`).
104
+ * A name lands in `narrowing` only when a `/tool` form is seen, so a bare
105
+ * `ext:foo` alongside `ext:foo/bar` leaves narrowing in effect (narrowing wins).
106
+ * The split is on the first `/`; extension canonical names never contain `/`.
107
+ */
108
+ export function parseExtSelectors(entries: string[]): {
109
+ extNames: Set<string>;
110
+ narrowing: Map<string, Set<string>>;
111
+ } {
112
+ const extNames = new Set<string>();
113
+ const narrowing = new Map<string, Set<string>>();
114
+ for (const raw of entries) {
115
+ if (!raw) continue;
116
+ const body = raw.slice("ext:".length);
117
+ const slash = body.indexOf("/");
118
+ // Extension name matches case-insensitively (matches the loader-side canonical
119
+ // name). Tool names are case-preserved — they're matched against pi-mono's
120
+ // registered identifiers, which are case-sensitive.
121
+ const name = (slash === -1 ? body : body.slice(0, slash)).trim().toLowerCase();
122
+ if (!name) continue;
123
+ extNames.add(name);
124
+ if (slash === -1) continue;
125
+ const tool = body.slice(slash + 1).trim();
126
+ if (!tool) continue;
127
+ let set = narrowing.get(name);
128
+ if (!set) {
129
+ set = new Set();
130
+ narrowing.set(name, set);
131
+ }
132
+ set.add(tool);
133
+ }
134
+ return { extNames, narrowing };
135
+ }
28
136
 
29
137
  /** Default max turns. undefined = unlimited (no turn limit). */
30
138
  let defaultMaxTurns: number | undefined;
@@ -239,16 +347,51 @@ export async function runAgent(
239
347
 
240
348
  const agentDir = getAgentDir();
241
349
 
242
- // Load extensions/skills: true or string[] → load; false → don't.
350
+ // Extension loading:
351
+ // - true → all default-discovered extensions
352
+ // - false → none (noExtensions)
353
+ // - string[] → loader-level allowlist. Bare names keep the matching
354
+ // default-discovered extension; path entries load that extension fresh;
355
+ // "*" keeps all default-discovered extensions. Excluded extensions never
356
+ // bind handlers or register tools (their factory still runs once).
357
+ //
243
358
  // Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md — upstream's
244
359
  // buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
245
360
  // would defeat prompt_mode: replace and isolated: true. Parent context, if
246
361
  // wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt
247
362
  // is embedded in systemPromptOverride) or inherit_context (conversation).
363
+ // `ext:` selectors from the `tools:` CSV narrow which extension tools surface to
364
+ // the LLM. They do NOT control loading — `extensions:` is the sole authority for
365
+ // which extensions load. `ext:foo` against an extension that `extensions:` excluded
366
+ // is an orphan and warns after reload. `isolated` means no extension tools at all.
367
+ const { extNames, narrowing } = parseExtSelectors(
368
+ options.isolated ? [] : (agentConfig?.extSelectors ?? []),
369
+ );
370
+ const noExtensions = extensions === false;
371
+
372
+ const extensionsSpec = Array.isArray(extensions)
373
+ ? parseExtensionsSpec(extensions, effectiveCwd)
374
+ : undefined;
375
+ const keepNames = extensionsSpec?.names ?? new Set<string>();
376
+ // The override filters loaded extensions down to `keepNames`. It's only needed
377
+ // when we're neither loading everything (`extensions: true` or a `"*"` wildcard)
378
+ // nor nothing (`noExtensions`).
379
+ const loadAll = extensions === true || extensionsSpec?.wildcard === true;
380
+ const additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined;
381
+ const extensionsOverride: ((base: LoadExtensionsResult) => LoadExtensionsResult) | undefined =
382
+ loadAll || noExtensions
383
+ ? undefined
384
+ : (base) => ({
385
+ ...base,
386
+ extensions: base.extensions.filter((e) => keepNames.has(extensionCanonicalName(e.path))),
387
+ });
388
+
248
389
  const loader = new DefaultResourceLoader({
249
390
  cwd: effectiveCwd,
250
391
  agentDir,
251
- noExtensions: extensions === false,
392
+ noExtensions,
393
+ additionalExtensionPaths,
394
+ extensionsOverride,
252
395
  noSkills,
253
396
  noPromptTemplates: true,
254
397
  noThemes: true,
@@ -258,6 +401,52 @@ export async function runAgent(
258
401
  });
259
402
  await loader.reload();
260
403
 
404
+ // Plain entries in `tools:` are expected to be built-in names (extension tools
405
+ // go through `ext:`), so an unknown name there is unambiguously a typo. Previously
406
+ // this produced a silently broken agent (#75) — pi-mono accepted the bogus name
407
+ // into the allowlist, then dropped it at registration with no signal back.
408
+ if (agentConfig?.builtinToolNames?.length) {
409
+ const knownBuiltins = new Set(BUILTIN_TOOL_NAMES);
410
+ for (const name of agentConfig.builtinToolNames) {
411
+ if (!knownBuiltins.has(name)) {
412
+ options.onToolActivity?.({
413
+ type: "end",
414
+ toolName: `tools-error:tool "${name}" requested by agent "${type}" is not a known built-in`,
415
+ });
416
+ }
417
+ }
418
+ }
419
+
420
+ // A subagent spawns mid-task, so a bad `extensions:`/`ext:` entry warns rather
421
+ // than aborts. Two distinct misconfigurations to catch:
422
+ // - `extensions: [foo]` but no extension named foo was discovered (typo or
423
+ // path that failed to load — path entries fold their canonical name into
424
+ // `keepNames`, so this covers them too).
425
+ // - `tools: ext:foo` but foo isn't in the loaded set (because `extensions:`
426
+ // didn't include it). Since v0.9, `ext:` no longer pulls extensions in;
427
+ // loading is `extensions:`-authoritative.
428
+ if (keepNames.size > 0 || extNames.size > 0) {
429
+ const survivingNames = new Set(
430
+ loader.getExtensions().extensions.map((e) => extensionCanonicalName(e.path)),
431
+ );
432
+ for (const name of keepNames) {
433
+ if (!survivingNames.has(name)) {
434
+ options.onToolActivity?.({
435
+ type: "end",
436
+ toolName: `extension-error:extension "${name}" requested by agent "${type}" was not loaded`,
437
+ });
438
+ }
439
+ }
440
+ for (const name of extNames) {
441
+ if (!survivingNames.has(name)) {
442
+ options.onToolActivity?.({
443
+ type: "end",
444
+ toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (add it to extensions:)`,
445
+ });
446
+ }
447
+ }
448
+ }
449
+
261
450
  // Resolve model: explicit option > config.model > parent model
262
451
  const model = options.model ?? resolveDefaultModel(
263
452
  ctx.model, ctx.modelRegistry, agentConfig?.model,
@@ -266,6 +455,46 @@ export async function runAgent(
266
455
  // Resolve thinking level: explicit option > agent config > undefined (inherit)
267
456
  const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
268
457
 
458
+ const disallowedSet = agentConfig?.disallowedTools
459
+ ? new Set(agentConfig.disallowedTools)
460
+ : undefined;
461
+
462
+ // Enumerate extension-registered tool names from the loaded resource loader.
463
+ // Extensions populate `extension.tools` during `loader.reload()` and the set
464
+ // is stable afterwards — `bindExtensions` does not register new tools.
465
+ //
466
+ // Opt-in flip: when any `ext:` selector is present, extension tools become an
467
+ // explicit allowlist — a loaded extension not named by a selector contributes
468
+ // no tools (its handlers still ran), and `ext:foo/bar` narrows `foo` to `bar`.
469
+ const extensionToolNames: string[] = [];
470
+ if (!noExtensions) {
471
+ const optInActive = extNames.size > 0;
472
+ for (const extension of loader.getExtensions().extensions) {
473
+ const canon = extensionCanonicalName(extension.path);
474
+ if (optInActive && !extNames.has(canon)) continue;
475
+ const narrowed = narrowing.get(canon);
476
+ for (const toolName of extension.tools.keys()) {
477
+ if (narrowed && !narrowed.has(toolName)) continue;
478
+ extensionToolNames.push(toolName);
479
+ }
480
+ }
481
+ }
482
+
483
+ // Build the master tool allowlist applied at session construction.
484
+ // pi-mono's `allowedToolNames` gates BOTH registration and the initial active
485
+ // set, so listing the exact final set here means the session is correctly
486
+ // scoped from the first instant — no post-construction narrowing required.
487
+ const builtinToolNameSet = new Set(toolNames);
488
+ const allowedTools = [...toolNames, ...extensionToolNames].filter((t) => {
489
+ if (EXCLUDED_TOOL_NAMES.includes(t)) return false;
490
+ if (disallowedSet?.has(t)) return false;
491
+ if (builtinToolNameSet.has(t)) return true;
492
+ // Reached only for extension tools. The extension set was already filtered
493
+ // at the loader (extensionsOverride / noExtensions) and at enumeration
494
+ // (`ext:` opt-in flip), so any extension tool in `extensionToolNames` is allowed.
495
+ return !noExtensions;
496
+ });
497
+
269
498
  const sessionOpts: Parameters<typeof createAgentSession>[0] = {
270
499
  cwd: effectiveCwd,
271
500
  agentDir,
@@ -273,7 +502,7 @@ export async function runAgent(
273
502
  settingsManager: SettingsManager.create(effectiveCwd, agentDir),
274
503
  modelRegistry: ctx.modelRegistry,
275
504
  model,
276
- tools: toolNames,
505
+ tools: allowedTools,
277
506
  resourceLoader: loader,
278
507
  };
279
508
  if (thinkingLevel) {
@@ -287,35 +516,10 @@ export async function runAgent(
287
516
  options.agentId ? `${baseSessionName}#${options.agentId.slice(0, 8)}` : baseSessionName,
288
517
  );
289
518
 
290
- // Build disallowed tools set from agent config
291
- const disallowedSet = agentConfig?.disallowedTools
292
- ? new Set(agentConfig.disallowedTools)
293
- : undefined;
294
-
295
- // Filter active tools: remove our own tools to prevent nesting,
296
- // apply extension allowlist if specified, and apply disallowedTools denylist
297
- if (extensions !== false) {
298
- const builtinToolNameSet = new Set(toolNames);
299
- const activeTools = session.getActiveToolNames().filter((t) => {
300
- if (EXCLUDED_TOOL_NAMES.includes(t)) return false;
301
- if (disallowedSet?.has(t)) return false;
302
- if (builtinToolNameSet.has(t)) return true;
303
- if (Array.isArray(extensions)) {
304
- return extensions.some(ext => t.startsWith(ext) || t.includes(ext));
305
- }
306
- return true;
307
- });
308
- session.setActiveToolsByName(activeTools);
309
- } else if (disallowedSet) {
310
- // Even with extensions disabled, apply denylist to built-in tools
311
- const activeTools = session.getActiveToolNames().filter(t => !disallowedSet.has(t));
312
- session.setActiveToolsByName(activeTools);
313
- }
314
-
315
519
  // Bind extensions so that session_start fires and extensions can initialize
316
- // (e.g. loading credentials, setting up state). Placed after tool filtering
317
- // so extension-provided skills/prompts from extendResourcesFromExtensions()
318
- // respect the active tool set. All ExtensionBindings fields are optional.
520
+ // (e.g. loading credentials, setting up state). Tool gating already happened
521
+ // at session construction via the `tools:` allowlist above — no separate
522
+ // post-bind filter is needed. All ExtensionBindings fields are optional.
319
523
  await session.bindExtensions({
320
524
  onError: (err) => {
321
525
  options.onToolActivity?.({
@@ -5,11 +5,21 @@
5
5
  * User agents override defaults with the same name. Disabled agents are kept but excluded from spawning.
6
6
  */
7
7
 
8
+ import { createCodingTools, createReadOnlyTools } from "@earendil-works/pi-coding-agent";
8
9
  import { DEFAULT_AGENTS } from "./default-agents.js";
9
10
  import type { AgentConfig } from "./types.js";
10
11
 
11
- /** All known built-in tool names. */
12
- export const BUILTIN_TOOL_NAMES: string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
12
+ /**
13
+ * All known built-in tool names, derived from pi's own tool factories rather
14
+ * than hardcoded so the set tracks pi-mono if it adds/renames a built-in.
15
+ * `createCodingTools` → read/bash/edit/write; `createReadOnlyTools` →
16
+ * read/grep/find/ls; their de-duplicated union is the 7 built-ins
17
+ * (read, bash, edit, write, grep, find, ls). The `cwd` only binds tool
18
+ * operations we never invoke here — we read each tool's `.name` and discard it.
19
+ */
20
+ export const BUILTIN_TOOL_NAMES: string[] = [
21
+ ...new Set([...createCodingTools("."), ...createReadOnlyTools(".")].map((t) => t.name)),
22
+ ];
13
23
 
14
24
  /** Unified runtime registry of all agents (defaults + user-defined). */
15
25
  const agents = new Map<string, AgentConfig>();
@@ -112,8 +122,9 @@ export function getToolNamesForType(type: string): string[] {
112
122
  const key = resolveKey(type);
113
123
  const raw = key ? agents.get(key) : undefined;
114
124
  const config = raw?.enabled !== false ? raw : undefined;
115
- const names = config?.builtinToolNames?.length ? config.builtinToolNames : [...BUILTIN_TOOL_NAMES];
116
- return names;
125
+ // `undefined` (definition omitted the field) all built-ins; an explicit `[]`
126
+ // (`tools: none` or a `tools:` with only `ext:` entries) → zero built-ins.
127
+ return config?.builtinToolNames ?? [...BUILTIN_TOOL_NAMES];
117
128
  }
118
129
 
119
130
  /** Get config for a type (case-insensitive, returns a SubagentTypeConfig-compatible object). Falls back to general-purpose. */
@@ -50,11 +50,14 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
50
50
 
51
51
  const { frontmatter: fm, body } = parseFrontmatter<Record<string, unknown>>(content);
52
52
 
53
+ const { builtinToolNames, extSelectors } = parseToolsField(fm.tools);
54
+
53
55
  agents.set(name, {
54
56
  name,
55
57
  displayName: str(fm.display_name),
56
58
  description: str(fm.description) ?? name,
57
- builtinToolNames: csvList(fm.tools, BUILTIN_TOOL_NAMES),
59
+ builtinToolNames,
60
+ extSelectors,
58
61
  disallowedTools: csvListOptional(fm.disallowed_tools),
59
62
  extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
60
63
  skills: inheritField(fm.skills ?? fm.inherit_skills),
@@ -107,6 +110,25 @@ function csvList(val: unknown, defaults: string[]): string[] {
107
110
  return parseCsvField(val) ?? [];
108
111
  }
109
112
 
113
+ /**
114
+ * Partition the `tools:` CSV into the built-in tool allowlist and raw `ext:` selectors.
115
+ * `*` (and the case-insensitive alias `all`, for `tools: all`) expands to all
116
+ * built-ins; plain entries are built-in names; `ext:` entries are extension-tool
117
+ * selectors parsed later by the runner. omitted → all built-ins, no selectors.
118
+ * `tools:` present with only `ext:` entries → zero built-ins (use `*`).
119
+ */
120
+ function parseToolsField(val: unknown): { builtinToolNames: string[]; extSelectors: string[] | undefined } {
121
+ const entries = csvList(val, BUILTIN_TOOL_NAMES);
122
+ const isWildcard = (e: string) => e === "*" || e.toLowerCase() === "all";
123
+ const hasWildcard = entries.some(isWildcard);
124
+ const plain = entries.filter(e => !isWildcard(e) && !e.startsWith("ext:"));
125
+ const extEntries = entries.filter(e => e.startsWith("ext:"));
126
+ return {
127
+ builtinToolNames: hasWildcard ? [...new Set([...BUILTIN_TOOL_NAMES, ...plain])] : plain,
128
+ extSelectors: extEntries.length > 0 ? extEntries : undefined,
129
+ };
130
+ }
131
+
110
132
  /**
111
133
  * Parse an optional comma-separated list field.
112
134
  * omitted → undefined; "none"/empty → undefined; csv → listed items.
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@ import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type Exten
16
16
  import { Container, Key, matchesKey, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
17
17
  import { Type } from "@sinclair/typebox";
18
18
  import { AgentManager } from "./agent-manager.js";
19
- import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
19
+ import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
20
20
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getDefaultAgentNames, getUserAgentNames, registerAgents, resolveType } from "./agent-types.js";
21
21
  import { registerRpcHandlers } from "./cross-extension-rpc.js";
22
22
  import { loadCustomAgents } from "./custom-agents.js";
@@ -28,6 +28,7 @@ import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./o
28
28
  import { SubagentScheduler } from "./schedule.js";
29
29
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
30
30
  import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged } from "./settings.js";
31
+ import { getStatusNote } from "./status-note.js";
31
32
  import { type AgentConfig, type AgentInvocation, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js";
32
33
  import {
33
34
  type AgentActivity,
@@ -118,16 +119,6 @@ function getStatusLabel(status: string, error?: string): string {
118
119
  }
119
120
  }
120
121
 
121
- /** Parenthetical status note for completed agent result text. */
122
- function getStatusNote(status: string): string {
123
- switch (status) {
124
- case "aborted": return " (aborted — max turns exceeded, output may be incomplete)";
125
- case "steered": return " (wrapped up — reached turn limit)";
126
- case "stopped": return " (stopped by user)";
127
- default: return "";
128
- }
129
- }
130
-
131
122
  /** Escape XML special characters to prevent injection in structured notifications. */
132
123
  function escapeXml(s: string): string {
133
124
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -154,7 +145,7 @@ function formatTaskNotification(record: AgentRecord, resultMaxLen: number): stri
154
145
  record.toolCallId ? `<tool-use-id>${escapeXml(record.toolCallId)}</tool-use-id>` : null,
155
146
  record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
156
147
  `<status>${escapeXml(status)}</status>`,
157
- `<summary>Agent "${escapeXml(record.description)}" ${record.status}</summary>`,
148
+ `<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}</summary>`,
158
149
  `<result>${escapeXml(resultPreview)}</result>`,
159
150
  `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}<duration_ms>${durationMs}</duration_ms></usage>`,
160
151
  `</task-notification>`,
@@ -651,7 +642,7 @@ export default function (pi: ExtensionAPI) {
651
642
  : "";
652
643
 
653
644
  pi.registerTool(defineTool({
654
- name: "Agent",
645
+ name: SUBAGENT_TOOL_NAMES.AGENT,
655
646
  label: "Agent",
656
647
  description: `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
657
648
 
@@ -695,6 +686,13 @@ Provide clear, detailed prompts so the agent can work autonomously. Brief it lik
695
686
  Terse command-style prompts produce shallow, generic work.
696
687
 
697
688
  **Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.`,
689
+ promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks",
690
+ promptGuidelines: [
691
+ "Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
692
+ "For broad codebase exploration or research, spawn Agent with an appropriate subagent_type (e.g. Explore). Otherwise use direct tools (read, grep, find) when the target is already known.",
693
+ "When an agent runs in the background, you will be notified on completion — do not poll or sleep waiting for it. Continue with other work instead.",
694
+ "Trust but verify: an agent's summary describes intent, not outcome. When an agent writes or edits code, check the actual changes before reporting work as done.",
695
+ ],
698
696
  parameters: Type.Object({
699
697
  prompt: Type.String({
700
698
  description: "The task for the agent to perform.",
@@ -765,7 +763,7 @@ Terse command-style prompts produce shallow, generic work.
765
763
  return new Text(text, 0, 0);
766
764
  }
767
765
 
768
- // Helper: build "haiku · thinking: high · 5≤30 · 3 tool uses · 33.8k tokens" stats string
766
+ // Helper: build "haiku · thinking: high · 5≤30 · 3 tool uses · 33.8k tokens" stats string
769
767
  const stats = (d: AgentDetails) => {
770
768
  const parts: string[] = [];
771
769
  if (d.modelName) parts.push(d.modelName);
@@ -1181,10 +1179,11 @@ Terse command-style prompts produce shallow, generic work.
1181
1179
  // ---- get_subagent_result tool ----
1182
1180
 
1183
1181
  pi.registerTool(defineTool({
1184
- name: "get_subagent_result",
1182
+ name: SUBAGENT_TOOL_NAMES.GET_RESULT,
1185
1183
  label: "Get Agent Result",
1186
1184
  description:
1187
1185
  "Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
1186
+ promptSnippet: "Check status and retrieve results from a background agent",
1188
1187
  parameters: Type.Object({
1189
1188
  agent_id: Type.String({
1190
1189
  description: "The agent ID to check.",
@@ -1228,7 +1227,7 @@ Terse command-style prompts produce shallow, generic work.
1228
1227
 
1229
1228
  let output =
1230
1229
  `Agent: ${record.id}\n` +
1231
- `Type: ${displayName} | Status: ${record.status} | ${statsParts.join(" | ")}\n` +
1230
+ `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
1232
1231
  `Description: ${record.description}\n\n`;
1233
1232
 
1234
1233
  if (record.status === "running") {
@@ -1260,11 +1259,12 @@ Terse command-style prompts produce shallow, generic work.
1260
1259
  // ---- steer_subagent tool ----
1261
1260
 
1262
1261
  pi.registerTool(defineTool({
1263
- name: "steer_subagent",
1262
+ name: SUBAGENT_TOOL_NAMES.STEER,
1264
1263
  label: "Steer Agent",
1265
1264
  description:
1266
1265
  "Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
1267
1266
  "and be injected into its conversation, allowing you to redirect its work mid-run. Only works on running agents.",
1267
+ promptSnippet: "Send a steering message to redirect a running background agent",
1268
1268
  parameters: Type.Object({
1269
1269
  agent_id: Type.String({
1270
1270
  description: "The agent ID to steer (must be currently running).",
@@ -1482,7 +1482,11 @@ Terse command-style prompts produce shallow, generic work.
1482
1482
 
1483
1483
  await ctx.ui.custom<undefined>(
1484
1484
  (tui, theme, _keybindings, done) => {
1485
- return new ConversationViewer(tui, session, record, activity, theme, done);
1485
+ return new ConversationViewer(tui, session, record, activity, theme, done, () => {
1486
+ if (manager.abort(record.id)) {
1487
+ ctx.ui.notify(`Stopped "${record.description}".`, "info");
1488
+ }
1489
+ });
1486
1490
  },
1487
1491
  {
1488
1492
  overlay: true,
package/src/prompts.ts CHANGED
@@ -16,12 +16,15 @@ export interface PromptExtras {
16
16
  * Build the system prompt for an agent from its config.
17
17
  *
18
18
  * - "replace" mode: env header + config.systemPrompt (full control, no parent identity)
19
- * - "append" mode: env header + parent system prompt + sub-agent context + config.systemPrompt
19
+ * - "append" mode: parent system prompt + sub-agent context + env header + config.systemPrompt
20
20
  * - "append" with empty systemPrompt: pure parent clone
21
21
  *
22
- * Both modes prepend an `<active_agent name="${config.name}"/>` tag so downstream
22
+ * Both modes include an `<active_agent name="${config.name}"/>` tag so downstream
23
23
  * extensions (e.g. permission/policy systems) can resolve per-agent policy
24
- * inside the child session by parsing the system prompt.
24
+ * inside the child session by parsing the system prompt. In replace mode the tag
25
+ * is prepended; in append mode it follows the shared inherited content so the
26
+ * parent prompt forms an identical, cacheable byte prefix with the parent
27
+ * session (the LLM's KV cache can then reuse those tokens across every spawn).
25
28
  *
26
29
  * @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
27
30
  * @param extras Optional extra sections to inject (memory, preloaded skills).
@@ -72,7 +75,12 @@ You are operating as a sub-agent invoked to handle a specific task.
72
75
  ? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
73
76
  : "";
74
77
 
75
- return activeAgentTag + envBlock + "\n\n<inherited_system_prompt>\n" + identity + "\n</inherited_system_prompt>\n\n" + bridge + customSection + extrasSuffix;
78
+ // Place shared/stable content first so the LLM's KV cache can reuse the
79
+ // inherited prefix across all subagent invocations. The parent prompt is
80
+ // placed verbatim (no wrapper tag) so it forms an identical byte prefix
81
+ // with the parent session, maximising KV cache hits. The <active_agent>
82
+ // tag and env block vary per call and are placed after the cached prefix.
83
+ return identity + "\n\n" + bridge + "\n\n" + activeAgentTag + envBlock + customSection + extrasSuffix;
76
84
  }
77
85
 
78
86
  // "replace" mode — env header + the config's full system prompt
@@ -0,0 +1,25 @@
1
+ /**
2
+ * status-note.ts — Parenthetical status note appended to agent result text.
3
+ */
4
+
5
+ /**
6
+ * Explicit parenthetical note for a non-normal terminal outcome, so the parent
7
+ * agent can't mistake partial output for a completed result. Empty string for a
8
+ * clean completion (and any unknown/non-terminal status).
9
+ *
10
+ * `stopped` (a human aborted it) is deliberately distinct from `aborted` (the
11
+ * turn limit was hit) — the parent should treat human intervention differently
12
+ * from a budget cutoff.
13
+ */
14
+ export function getStatusNote(status: string): string {
15
+ switch (status) {
16
+ case "stopped":
17
+ return " (STOPPED BY THE USER before completion — output is partial; the task was NOT finished)";
18
+ case "aborted":
19
+ return " (aborted — hit the turn limit before completion; output may be incomplete)";
20
+ case "steered":
21
+ return " (wrapped up at the turn limit — output may be partial)";
22
+ default:
23
+ return "";
24
+ }
25
+ }
package/src/types.ts CHANGED
@@ -26,6 +26,9 @@ export interface AgentConfig {
26
26
  displayName?: string;
27
27
  description: string;
28
28
  builtinToolNames?: string[];
29
+ /** Raw `ext:` selector entries from the `tools:` CSV, e.g. ["ext:foo", "ext:bar/x"].
30
+ * Presence of any entry flips extension tools to an explicit allowlist. */
31
+ extSelectors?: string[];
29
32
  /** Tool denylist — these tools are removed even if `builtinToolNames` or extensions include them. */
30
33
  disallowedTools?: string[];
31
34
  /** true = inherit all, string[] = only listed, false = none */
@@ -100,12 +100,12 @@ export function formatTokens(count: number): string {
100
100
  /**
101
101
  * Token count with optional context-fill % and compaction-count annotations.
102
102
  * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
103
- * Compaction count rendered as `↻N` in dim.
103
+ * Compaction count rendered as `⇊N` in dim.
104
104
  *
105
105
  * "12.3k token" — no annotations
106
106
  * "12.3k token (45%)" — percent only
107
- * "12.3k token (2)" — compactions only (e.g. right after compact)
108
- * "12.3k token (45% · 2)" — both
107
+ * "12.3k token (2)" — compactions only (e.g. right after compact)
108
+ * "12.3k token (45% · 2)" — both
109
109
  */
110
110
  export function formatSessionTokens(
111
111
  tokens: number,
@@ -120,15 +120,15 @@ export function formatSessionTokens(
120
120
  annot.push(theme.fg(color, `${Math.round(percent)}%`));
121
121
  }
122
122
  if (compactions > 0) {
123
- annot.push(theme.fg("dim", `↻${compactions}`));
123
+ annot.push(theme.fg("dim", `⇊${compactions}`));
124
124
  }
125
125
  if (annot.length === 0) return tokenStr;
126
126
  return `${tokenStr} (${annot.join(" · ")})`;
127
127
  }
128
128
 
129
- /** Format turn count with optional max limit: "5≤30" or "5". */
129
+ /** Format turn count with optional max limit: "5≤30" or "5". */
130
130
  export function formatTurns(turnCount: number, maxTurns?: number | null): string {
131
- return maxTurns != null ? `⟳${turnCount}≤${maxTurns}` : `⟳${turnCount}`;
131
+ return maxTurns != null ? `↻${turnCount}≤${maxTurns}` : `↻${turnCount}`;
132
132
  }
133
133
 
134
134
  /** Format milliseconds as human-readable duration. */