@herbertgao/pi-subagents 0.19.0 → 0.22.0-local.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.
@@ -14,9 +14,13 @@ import {
14
14
  type AgentSession,
15
15
  type AgentSessionEvent,
16
16
  createAgentSession,
17
+ createCodemodeExtension,
18
+ createMcpExtension,
19
+ createToolSearchExtension,
17
20
  DefaultResourceLoader,
18
21
  type ExtensionAPI,
19
22
  getAgentDir,
23
+ type InlineExtension,
20
24
  SessionManager,
21
25
  SettingsManager,
22
26
  } from "@earendil-works/pi-coding-agent"
@@ -101,6 +105,8 @@ export function extensionCanonicalName(extPath: string): string {
101
105
  * co-located file to `pi-subagents`.
102
106
  */
103
107
  function extensionPackageName(extPath: string): string | undefined {
108
+ // `builtin:<name>` names no file; climbing from it would read the process cwd's manifest.
109
+ if (extPath.startsWith("builtin:")) return undefined
104
110
  const entry = resolve(extPath)
105
111
  let dir = dirname(extPath)
106
112
  for (;;) {
@@ -269,6 +275,8 @@ export function installExtensionToolScope(
269
275
  * seeded from `toolNames`.
270
276
  */
271
277
  readmitToolNames: Set<string>
278
+ /** Receives the scope check the child's nested-call gate extension applies. */
279
+ nestedCallGate: NestedCallGate
272
280
  },
273
281
  ): void {
274
282
  const {
@@ -278,6 +286,7 @@ export function installExtensionToolScope(
278
286
  extNames,
279
287
  narrowing,
280
288
  readmitToolNames,
289
+ nestedCallGate,
281
290
  } = ctx
282
291
 
283
292
  // The names allowed right now. Mirrors the `ext:` opt-in flip: when any `ext:`
@@ -308,13 +317,36 @@ export function installExtensionToolScope(
308
317
  return keep
309
318
  }
310
319
 
320
+ // Names this agent selects explicitly: its built-ins, injected tools, and
321
+ // `ext:foo/bar` narrowings. pi activates a named tool even when it is
322
+ // `defaultActive: false`, as `--tools` does.
323
+ const named = new Set([
324
+ ...toolNames,
325
+ ...readmitToolNames,
326
+ ...[...narrowing.values()].flatMap((s) => [...s]),
327
+ ])
328
+
329
+ // Activate what pi itself would: only tools declared to the model (`direct`/
330
+ // `model-only` exposure), and of those only named ones or ones not opted out
331
+ // with `defaultActive: false`. `codemode`/`deferred` tools (pi's MCP tools by
332
+ // default) stay undeclared until their extension or `tool_search` activates
333
+ // them — so an already-active tool is kept, or turn_end would undo that.
311
334
  const renarrow = () => {
312
335
  const allowed = inScope()
336
+ const current = session.getActiveToolNames()
337
+ const active = new Set(current)
313
338
  const next = session
314
339
  .getAllTools()
340
+ .filter(({ name, exposure = "direct" }) => {
341
+ if (!allowed.has(name)) return false
342
+ if (active.has(name)) return true
343
+ if (exposure !== "direct" && exposure !== "model-only") return false
344
+ return (
345
+ named.has(name) ||
346
+ session.getToolDefinition(name)?.defaultActive !== false
347
+ )
348
+ })
315
349
  .map((t) => t.name)
316
- .filter((n) => allowed.has(n))
317
- const current = session.getActiveToolNames()
318
350
  // setActiveToolsByName unconditionally rebuilds the system prompt, so skip
319
351
  // the no-op that steady-state turns would otherwise pay for every turn.
320
352
  if (
@@ -333,6 +365,8 @@ export function installExtensionToolScope(
333
365
  if (event.type === "turn_end") renarrow()
334
366
  })
335
367
 
368
+ nestedCallGate.allows = (name) => inScope().has(name)
369
+
336
370
  const priorBeforeToolCall = session.agent.beforeToolCall
337
371
  session.agent.beforeToolCall = async (context, signal) => {
338
372
  if (!inScope().has(context.toolCall.name)) {
@@ -345,6 +379,35 @@ export function installExtensionToolScope(
345
379
  }
346
380
  }
347
381
 
382
+ /** Set once the child's tool scope is installed; until then the gate admits everything. */
383
+ type NestedCallGate = { allows?: (name: string) => boolean }
384
+
385
+ /**
386
+ * Inline extension that applies the subagent's tool scope to calls a tool makes
387
+ * through `ctx.executeTool()` — codemode scripts calling MCP tools. pi runs those
388
+ * past extensions' `tool_call` handlers only, never `agent.beforeToolCall`, and
389
+ * every registered `codemode`/`deferred` tool is callable there whether or not
390
+ * it is in scope.
391
+ */
392
+ const SCOPE_GATE_NAME = "pi-subagents-scope"
393
+
394
+ function scopeGateExtension(gate: NestedCallGate): InlineExtension {
395
+ return {
396
+ name: SCOPE_GATE_NAME,
397
+ hidden: true,
398
+ factory: (ext) => {
399
+ ext.on("tool_call", (event) =>
400
+ gate.allows?.(event.toolName) === false
401
+ ? {
402
+ block: true,
403
+ reason: `Tool "${event.toolName}" is not available to this subagent.`,
404
+ }
405
+ : undefined,
406
+ )
407
+ },
408
+ }
409
+ }
410
+
348
411
  /** Default max turns. undefined = unlimited (no turn limit). */
349
412
  let defaultMaxTurns: number | undefined
350
413
 
@@ -459,6 +522,7 @@ export interface RunOptions {
459
522
  model?: Model<any>
460
523
  maxTurns?: number
461
524
  signal?: AbortSignal
525
+ /** Applies only when the agent file leaves `isolated:` unset — frontmatter is authoritative. */
462
526
  isolated?: boolean
463
527
  inheritContext?: boolean
464
528
  thinkingLevel?: ThinkingLevel
@@ -708,14 +772,15 @@ export async function runAgent(
708
772
  if (options.worktreeBase) extras.worktreeBase = options.worktreeBase
709
773
  if (options.workflow && !options.structuredOutput) extras.workflowChild = true
710
774
 
775
+ // Resolved here, not by each caller: mention and RPC spawns pass no `isolated`,
776
+ // and the agent file's value must hold on every path.
777
+ const isolated = agentConfig?.isolated ?? options.isolated
711
778
  // Resolve extensions/skills: isolated overrides to false
712
- const extensions = options.isolated ? false : config.extensions
779
+ const extensions = isolated ? false : config.extensions
713
780
  // Nulling excludes under isolated also suppresses the orphaned-exclude warning —
714
781
  // isolation is an intentional override, not a misconfiguration.
715
- const excludeExtensions = options.isolated
716
- ? undefined
717
- : config.excludeExtensions
718
- const skills = options.isolated ? false : config.skills
782
+ const excludeExtensions = isolated ? undefined : config.excludeExtensions
783
+ const skills = isolated ? false : config.skills
719
784
 
720
785
  // Skill preloading: when skills is string[], preload their content into prompt
721
786
  if (Array.isArray(skills)) {
@@ -808,7 +873,7 @@ export async function runAgent(
808
873
  // which extensions load. `ext:foo` against an extension that `extensions:` excluded
809
874
  // is an orphan and warns after reload. `isolated` means no extension tools at all.
810
875
  const { extNames, narrowing } = parseExtSelectors(
811
- options.isolated ? [] : (agentConfig?.extSelectors ?? []),
876
+ isolated ? [] : (agentConfig?.extSelectors ?? []),
812
877
  )
813
878
  const noExtensions = extensions === false
814
879
 
@@ -847,6 +912,7 @@ export async function runAgent(
847
912
  return {
848
913
  ...base,
849
914
  extensions: base.extensions.filter((e) => {
915
+ if (e.path === `<inline:${SCOPE_GATE_NAME}>`) return true // ours, not the agent's
850
916
  const canons = extensionCanonicalNames(e.path)
851
917
  if (canons.some((n) => excludeNames.has(n))) return false // exclude wins
852
918
  return loadAll || canons.some((n) => keepNames.has(n))
@@ -854,7 +920,68 @@ export async function runAgent(
854
920
  }
855
921
  }
856
922
 
923
+ // The child inherits the parent's project-trust decision. pi's SettingsManager
924
+ // defaults to trusted, so without this a subagent in a project the user declined
925
+ // to trust would load its `.pi` extensions and settings, and pi's built-in MCP
926
+ // would start the stdio servers in its `.pi/mcp.json`.
927
+ const projectTrusted = ctx.isProjectTrusted()
928
+ const settingsManager = SettingsManager.create(configCwd, agentDir, {
929
+ projectTrusted,
930
+ })
931
+ // That trust covers the parent's project (`configCwd`), not a directory a spawn
932
+ // was sent into. The session answers `ctx.isProjectTrusted()` for its own cwd,
933
+ // which pi's MCP uses to read `<cwd>/.pi/mcp.json` — so a cross-directory session
934
+ // keeps the parent project's settings but reports its cwd as untrusted. The
935
+ // loader keeps the trusted manager: it loads the parent project's extensions.
936
+ const sessionSettingsManager =
937
+ resolve(effectiveCwd) === resolve(configCwd)
938
+ ? settingsManager
939
+ : Object.assign(
940
+ SettingsManager.create(configCwd, agentDir, { projectTrusted }),
941
+ {
942
+ isProjectTrusted: () => false,
943
+ },
944
+ )
945
+ const nestedCallGate: NestedCallGate = {}
946
+
857
947
  const loader = new DefaultResourceLoader({
948
+ settingsManager,
949
+ // pi's CLI hands its built-in extensions to the loader it builds; this one starts
950
+ // with none. Forwarded as `builtin:` resources they load as in the main session —
951
+ // default-on, disabled by `-builtin:<name>` in settings — and the per-agent
952
+ // allowlist filters them like any other extension. pi's `llama.cpp` built-in is
953
+ // CLI-only (not exported). Fresh factories per spawn: MCP state lives in the closure.
954
+ extensionFactories: [
955
+ {
956
+ name: "codemode",
957
+ factory: createCodemodeExtension(),
958
+ builtin: true,
959
+ replaceable: true,
960
+ },
961
+ {
962
+ name: "tool-search",
963
+ factory: createToolSearchExtension(),
964
+ builtin: true,
965
+ replaceable: true,
966
+ },
967
+ {
968
+ name: "mcp",
969
+ factory: createMcpExtension(),
970
+ builtin: true,
971
+ replaceable: true,
972
+ },
973
+ // Under `noExtensions` the static allowlist gates the registry itself.
974
+ ...(noExtensions ? [] : [scopeGateExtension(nestedCallGate)]),
975
+ ],
976
+ // Mirrors pi's CLI: `--no-mcp` keeps MCP out of subagents too, and `--no-extensions`
977
+ // keeps all the built-ins out. Discovered extensions still follow `extensions:`.
978
+ disabledBuiltinExtensions: process.argv.some(
979
+ (a) => a === "--no-extensions" || a === "-ne",
980
+ )
981
+ ? ["codemode", "tool-search", "mcp"]
982
+ : process.argv.includes("--no-mcp")
983
+ ? ["mcp"]
984
+ : undefined,
858
985
  cwd: configCwd,
859
986
  agentDir,
860
987
  noExtensions,
@@ -967,7 +1094,7 @@ export async function runAgent(
967
1094
  ? options.nestedRuntime
968
1095
  : undefined
969
1096
  const nestedTools =
970
- agentConfig?.allowedSubagents && nestedRuntime && !options.isolated
1097
+ agentConfig?.allowedSubagents && nestedRuntime && !isolated
971
1098
  ? createNestedSubagentTools({
972
1099
  manager: nestedRuntime.manager,
973
1100
  pi: options.pi,
@@ -1075,7 +1202,6 @@ export async function runAgent(
1075
1202
  sessionExcludeTools = [...denyTools]
1076
1203
  }
1077
1204
 
1078
- const settingsManager = SettingsManager.create(configCwd, agentDir)
1079
1205
  const configuredSessionDir = resolveConfiguredSessionDir(
1080
1206
  agentConfig?.sessionDir,
1081
1207
  effectiveCwd,
@@ -1125,7 +1251,7 @@ export async function runAgent(
1125
1251
  cwd: effectiveCwd,
1126
1252
  agentDir,
1127
1253
  sessionManager,
1128
- settingsManager,
1254
+ settingsManager: sessionSettingsManager,
1129
1255
  modelRegistry: ctx.modelRegistry,
1130
1256
  // `as never` is what keeps this assignable across the supported Pi range:
1131
1257
  // pre-0.80.8 the field exists only via the `modelRuntime?: unknown` shim
@@ -1184,6 +1310,7 @@ export async function runAgent(
1184
1310
  extNames,
1185
1311
  narrowing,
1186
1312
  readmitToolNames,
1313
+ nestedCallGate,
1187
1314
  })
1188
1315
  }
1189
1316
 
@@ -1267,7 +1394,10 @@ export async function runAgent(
1267
1394
  const startLen = session.messages.length
1268
1395
  let structuredRetried = false
1269
1396
  try {
1270
- await session.prompt(effectivePrompt)
1397
+ // A signal that fired before this point never calls the listener above, and
1398
+ // pi's `session.abort()` is a no-op until a run is active — so a child whose
1399
+ // parent is already gone is not prompted at all.
1400
+ if (options.signal?.aborted !== true) await session.prompt(effectivePrompt)
1271
1401
 
1272
1402
  // One more prompt when a schema was asked for and nothing usable came back
1273
1403
  // — the model answered in prose, or only ever called the tool invalidly.
@@ -1374,7 +1504,8 @@ export async function resumeAgent(
1374
1504
  : () => {}
1375
1505
 
1376
1506
  try {
1377
- await session.prompt(prompt)
1507
+ // Already aborted: not prompted, as in `runAgent`.
1508
+ if (options.signal?.aborted !== true) await session.prompt(prompt)
1378
1509
  } finally {
1379
1510
  collector.unsubscribe()
1380
1511
  unsubEvents()
@@ -0,0 +1,78 @@
1
+ /**
2
+ * agent-source-badge.ts — How `/agents → Agent types` shows where an agent came
3
+ * from: the per-row badge, the legend that explains the badges actually on
4
+ * screen, and the description line under the highlighted row.
5
+ *
6
+ * These live outside src/index.ts for the same reason as agent-file-toggle.ts:
7
+ * the `/agents` handler is a large closure reached only through
8
+ * `registerCommand`, which every test mocks, so nothing inside it can be
9
+ * asserted on directly.
10
+ *
11
+ * The badges are deliberately terse — the list is one row per agent and the
12
+ * model already occupies the right-hand column. Anything wordier than a glyph
13
+ * (the package's name, in particular) goes on the description line, which
14
+ * `SettingsList` renders for the highlighted row only.
15
+ */
16
+
17
+ import type { AgentConfig } from "./types.js"
18
+
19
+ /**
20
+ * The badge column: a two-cell glyph plus a space, so every label starts at the
21
+ * same offset whether or not the agent is badged.
22
+ *
23
+ * Built-in defaults are unmarked. They are the majority of the list, and a
24
+ * badge on them would make the three that matter — the agents the user or a
25
+ * package put there — harder to pick out, not easier.
26
+ */
27
+ export function sourceIndicator(cfg: AgentConfig | undefined): string {
28
+ const disabled = cfg?.enabled === false
29
+ if (cfg?.source === "project") return disabled ? "✕• " : "• "
30
+ if (cfg?.source === "global") return disabled ? "✕◦ " : "◦ "
31
+ if (cfg?.source === "package") return disabled ? "✕▪ " : "▪ "
32
+ if (disabled) return "✕ "
33
+ return " "
34
+ }
35
+
36
+ /**
37
+ * The legend line, naming only the badges the given roster actually renders.
38
+ *
39
+ * Keyed on what `sourceIndicator` would emit, so the two cannot drift into a
40
+ * glyph with no key (which is what `▪` was) or a key for a glyph that is not on
41
+ * screen. A defaults-only roster returns "", and the caller omits the line.
42
+ *
43
+ * `source` is undefined for built-in defaults — the `"default"` member of the
44
+ * union is declared but never assigned, since default-agents.ts sets only
45
+ * `isDefault` — so they contribute no key, matching their blank badge.
46
+ */
47
+ export function sourceLegend(
48
+ cfgs: readonly (AgentConfig | undefined)[],
49
+ ): string {
50
+ const parts: string[] = []
51
+ if (cfgs.some((c) => c?.source === "project")) parts.push("• = project")
52
+ if (cfgs.some((c) => c?.source === "global")) parts.push("◦ = global")
53
+ if (cfgs.some((c) => c?.source === "package")) parts.push("▪ = package")
54
+ if (cfgs.some((c) => c?.enabled === false)) parts.push("✕ = disabled")
55
+ return parts.join(" ")
56
+ }
57
+
58
+ /**
59
+ * The description line under the highlighted row.
60
+ *
61
+ * `packageName` is the one piece of provenance a badge cannot carry: with two
62
+ * agent-providing packages installed, `▪` says only that *some* package won.
63
+ * It is prefixed rather than appended so it stays visible when a long
64
+ * description wraps, and it prefixes "(disabled)" too — a disabled package
65
+ * agent is exactly the case where you want to know whose it was.
66
+ *
67
+ * Falls back to the agent's own name when it has no description, which is what
68
+ * a hand-written agent file without one already showed.
69
+ */
70
+ export function rowDescription(
71
+ name: string,
72
+ cfg: AgentConfig | undefined,
73
+ packageName?: string,
74
+ ): string {
75
+ const base =
76
+ cfg?.enabled === false ? "(disabled)" : (cfg?.description ?? name)
77
+ return packageName ? `${packageName} · ${base}` : base
78
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * completion-outbox.ts — When background completions reach the parent.
3
+ *
4
+ * Claude Code queues each completion and, after every tool batch, hands the
5
+ * model everything that queued up in one go: a busy parent sees what finished
6
+ * during the batch before its next model call, and nothing waits for a sibling.
7
+ * pi's steering queue would split those into one model call each under its
8
+ * default `one-at-a-time` mode, so the outbox holds them and sends ONE message
9
+ * at the boundary instead.
10
+ *
11
+ * - Parent busy: held until `turn_end`, which pi emits after the tool batch and
12
+ * before it reads the steering queue.
13
+ * - Parent idle: sent after a short hold, so near-simultaneous completions
14
+ * share a turn and `get_subagent_result` still gets a moment to consume one.
15
+ * - Parent turn aborted (Esc) or failed: held through it — pi clears its own
16
+ * queues on Esc — and sent once the run has ended, starting a turn. Claude
17
+ * Code keeps an agent's completion queued through Esc the same way and runs
18
+ * it as soon as the interrupted turn stops.
19
+ */
20
+
21
+ import type { NotificationDetails } from "./types.js"
22
+
23
+ /** How long an idle parent's notification is held before it is sent. */
24
+ export const NOTIFICATION_HOLD_MS = 200
25
+
26
+ export interface OutboxMessage {
27
+ content: string
28
+ details: NotificationDetails
29
+ }
30
+
31
+ /**
32
+ * A notification for one or more agents, built when they finished: a resume
33
+ * before the send must not rewrite it. Reading an agent's result cancels its
34
+ * id, and an item left with none is dropped; `build` gets the ids still pending.
35
+ */
36
+ export interface OutboxItem {
37
+ ids: string[]
38
+ build(ids: readonly string[]): OutboxMessage
39
+ }
40
+
41
+ export class CompletionOutbox {
42
+ private items: OutboxItem[] = []
43
+ private timer: ReturnType<typeof setTimeout> | undefined
44
+
45
+ constructor(
46
+ /** Delivers one message as a steer that triggers a turn when the parent is idle. */
47
+ private send: (message: OutboxMessage) => void,
48
+ /** Whether the parent has a run in flight, so a `turn_end` is coming. */
49
+ private parentRunning: () => boolean,
50
+ ) {}
51
+
52
+ /** A newer completion of the same agent (a resume) replaces its pending one. */
53
+ add(item: OutboxItem): void {
54
+ for (const id of item.ids) this.cancel(id)
55
+ this.items.push(item)
56
+ if (!this.parentRunning()) this.arm()
57
+ }
58
+
59
+ /** The parent finished a turn: its tool batch is done, the next model call is not. */
60
+ onTurnEnd(stopReason: string | undefined): void {
61
+ // An aborted turn ends its run; a failed one may be retried. Either way
62
+ // what is held waits for the next boundary or the run's end — sent now,
63
+ // it would sit in a queue pi is about to clear.
64
+ if (stopReason !== "aborted" && stopReason !== "error") this.flush()
65
+ }
66
+
67
+ /** The agent's result was read: it must not also be notified. */
68
+ cancel(id: string): void {
69
+ for (const item of this.items) item.ids = item.ids.filter((i) => i !== id)
70
+ this.items = this.items.filter((item) => item.ids.length > 0)
71
+ }
72
+
73
+ /**
74
+ * Completions that landed after the run's last `turn_end` would otherwise wait for the next run.
75
+ * Called at `agent_end` and again at `agent_settled`: one can land while the ending run is still open.
76
+ */
77
+ onAgentEnd(): void {
78
+ if (this.items.length > 0) this.arm()
79
+ }
80
+
81
+ dispose(): void {
82
+ if (this.timer) clearTimeout(this.timer)
83
+ this.timer = undefined
84
+ this.items = []
85
+ }
86
+
87
+ /** Not reset by later items: the first completion is never pushed back. */
88
+ private arm(): void {
89
+ this.timer ??= setTimeout(() => {
90
+ this.timer = undefined
91
+ this.flush()
92
+ }, NOTIFICATION_HOLD_MS)
93
+ }
94
+
95
+ private flush(): void {
96
+ if (this.timer) clearTimeout(this.timer)
97
+ this.timer = undefined
98
+ const items = this.items
99
+ this.items = []
100
+ const messages = items.map((item) => item.build(item.ids))
101
+ if (messages.length === 0) return
102
+ // A send from a timer can outlive the session it was meant for (pi -p
103
+ // tearing down): pi throws on the stale context, and nothing is left to tell.
104
+ try {
105
+ this.send(combine(messages))
106
+ } catch {
107
+ /* stale context */
108
+ }
109
+ }
110
+ }
111
+
112
+ /** One message stays as it was built; several become one message with every agent's details. */
113
+ function combine(messages: OutboxMessage[]): OutboxMessage {
114
+ if (messages.length === 1) return messages[0]
115
+ const [first, ...rest] = messages.flatMap(
116
+ ({ details: { others, ...own } }) => [own, ...(others ?? [])],
117
+ )
118
+ return {
119
+ content: `${messages.length} background tasks finished.\n\n${messages.map((m) => m.content).join("\n\n")}`,
120
+ details: { ...first, others: rest },
121
+ }
122
+ }