@herbertgao/pi-subagents 0.18.5 → 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.
@@ -61,7 +61,7 @@ export function findAgentFile(
61
61
  }
62
62
 
63
63
  /**
64
- * Find the file behind a *loaded* agent, preferring the path the loader
64
+ * Find the *editable* file behind a loaded agent, preferring the path the loader
65
65
  * actually read (`AgentConfig.sourcePath`) over the `<type>.md` guess.
66
66
  *
67
67
  * An agent's type comes from its frontmatter `name:` now, so the two can
@@ -73,13 +73,22 @@ export function findAgentFile(
73
73
  *
74
74
  * The probe stays as the fallback: a built-in that was never ejected has no
75
75
  * `sourcePath`, and a path can go stale between a load and this call.
76
+ *
77
+ * **A package agent has no editable file.** Its `sourcePath` points inside pi's
78
+ * install root, where an edit is lost on the next `pi update` and a delete is
79
+ * undone by the next `pi install`. Passing `source` lets that rule live here
80
+ * rather than at each `/agents` call site: the package path is ignored, and the
81
+ * name probe runs instead — which finds the local stub shadowing it, if one
82
+ * exists, and otherwise nothing. Callers then take their no-file branch, which
83
+ * is the Eject-or-shadow behaviour a not-yet-ejected builtin already gets.
76
84
  */
77
85
  export function locateAgentFile(
78
86
  name: string,
79
87
  sourcePath: string | undefined,
80
88
  cwd: string = process.cwd(),
89
+ source?: AgentConfig["source"],
81
90
  ): { path: string; location: AgentFileLocation } | undefined {
82
- if (sourcePath && existsSync(sourcePath)) {
91
+ if (source !== "package" && sourcePath && existsSync(sourcePath)) {
83
92
  return { path: sourcePath, location: classifyAgentDir(sourcePath, cwd) }
84
93
  }
85
94
  return findAgentFile(name, cwd)
@@ -87,9 +96,11 @@ export function locateAgentFile(
87
96
 
88
97
  /**
89
98
  * Which discovery location a loaded agent's file came from. Only ever names
90
- * a directory in a confirmation prompt, so an unrecognized parent — which
91
- * loadCustomAgents cannot currently produce — reports as personal rather than
92
- * widening the type for a case that has no better answer.
99
+ * a directory in a confirmation prompt, so an unrecognized parent reports as
100
+ * personal rather than widening the type for a case that has no better answer.
101
+ *
102
+ * Package-provided paths never reach here: `locateAgentFile` drops them above,
103
+ * because none of the three locations this classifies is where they live.
93
104
  */
94
105
  function classifyAgentDir(path: string, cwd: string): AgentFileLocation {
95
106
  if (path.startsWith(projectAgentsDir(cwd) + sep)) return "project"
@@ -251,11 +262,29 @@ ${input.systemPrompt}
251
262
  `
252
263
  }
253
264
 
254
- /** Render a built-in tool list as a `tools:` frontmatter value. */
255
- function formatToolsField(tools: string[] | undefined): string {
256
- if (tools === undefined) return "all"
257
- if (tools.length === 0) return "none"
258
- return tools.join(", ")
265
+ /**
266
+ * Rebuild the `tools:` CSV from the two halves the loader split it into.
267
+ *
268
+ * `extSelectors` has to come back with the built-ins: `parseToolsField` splits
269
+ * `tools: read, ext:mcp/github` into `["read"]` and `["ext:mcp/github"]`, so
270
+ * writing only the first half hands the ejected copy a *narrower* toolset than
271
+ * the file it came from. That was invisible while Eject only ever ran on
272
+ * built-in defaults, which carry no selectors; a package agent can.
273
+ *
274
+ * Zero built-ins with selectors present is not `none` — `none` means no tools at
275
+ * all, while `ext:` entries alone mean "extension tools only", which the loader
276
+ * reads back from a `tools:` line carrying just those entries.
277
+ */
278
+ function formatToolsField(
279
+ tools: string[] | undefined,
280
+ extSelectors?: string[],
281
+ ): string {
282
+ const selectors = extSelectors ?? []
283
+ if (tools === undefined)
284
+ return selectors.length > 0 ? ["*", ...selectors].join(", ") : "all"
285
+ if (tools.length === 0)
286
+ return selectors.length > 0 ? selectors.join(", ") : "none"
287
+ return [...tools, ...selectors].join(", ")
259
288
  }
260
289
 
261
290
  /** Serialize an AgentConfig to a full .md file (frontmatter + system prompt) for eject. */
@@ -267,7 +296,9 @@ export function serializeAgentFile(cfg: AgentConfig): string {
267
296
  // Absent means "all built-ins"; an EMPTY list means explicitly zero. Writing
268
297
  // `all` for both would hand a deliberately tool-less agent the whole toolbox
269
298
  // the first time it is ejected.
270
- fmFields.push(`tools: ${formatToolsField(cfg.builtinToolNames)}`)
299
+ fmFields.push(
300
+ `tools: ${formatToolsField(cfg.builtinToolNames, cfg.extSelectors)}`,
301
+ )
271
302
  if (cfg.model) fmFields.push(`model: ${cfg.model}`)
272
303
  if (cfg.thinking) fmFields.push(`thinking: ${cfg.thinking}`)
273
304
  if (cfg.maxTurns) fmFields.push(`max_turns: ${cfg.maxTurns}`)
@@ -290,15 +321,21 @@ export function serializeAgentFile(cfg: AgentConfig): string {
290
321
  if (cfg.inheritContext) fmFields.push("inherit_context: true")
291
322
  // Both cases, not just `true`: with `backgroundByDefault` on, omitting the
292
323
  // field means background, so `false` is the only way to pin an agent file to
293
- // foreground and is no longer interchangeable with absence. No caller can
294
- // reach it yet — Eject only handles built-in defaults, which omit the field —
295
- // so this keeps the writer symmetric with the loader, nothing more.
324
+ // foreground and is no longer interchangeable with absence. Reachable since
325
+ // package agents became ejectable — a built-in default omits the field.
296
326
  if (cfg.runInBackground !== undefined)
297
327
  fmFields.push(`run_in_background: ${cfg.runInBackground}`)
298
328
  if (cfg.outputTranscript === false) fmFields.push("output_transcript: false")
299
329
  if (cfg.isolated) fmFields.push("isolated: true")
300
330
  if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`)
301
331
  if (cfg.isolation) fmFields.push(`isolation: ${cfg.isolation}`)
332
+ // Also only reachable through a package agent: no built-in default sets
333
+ // either. Omitting them would have the ejected copy silently stop persisting
334
+ // its session, or write it somewhere else.
335
+ if (cfg.persistSession !== undefined)
336
+ fmFields.push(`persist_session: ${cfg.persistSession}`)
337
+ if (cfg.sessionDir)
338
+ fmFields.push(`session_dir: ${JSON.stringify(cfg.sessionDir)}`)
302
339
 
303
340
  return `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`
304
341
  }
@@ -24,6 +24,7 @@ import type {
24
24
  ExtensionContext,
25
25
  } from "@earendil-works/pi-coding-agent"
26
26
  import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js"
27
+ import { getAgentConfig } from "./agent-types.js"
27
28
  import { assignHandle, handleBase } from "./mention.js"
28
29
  import { describeModel } from "./model-resolver.js"
29
30
  import type {
@@ -32,6 +33,7 @@ import type {
32
33
  AgentTombstone,
33
34
  IsolationMode,
34
35
  MentionResolution,
36
+ Pool,
35
37
  SubagentType,
36
38
  ThinkingLevel,
37
39
  } from "./types.js"
@@ -191,9 +193,6 @@ function occupiesForegroundSlot(
191
193
  return !!record.blocking && isTopLevelAgent(record)
192
194
  }
193
195
 
194
- /** Which concurrency pool a spawn is charged to, if any. */
195
- type Pool = "background" | "foreground"
196
-
197
196
  interface SpawnArgs {
198
197
  pi: ExtensionAPI
199
198
  ctx: ExtensionContext
@@ -658,10 +657,9 @@ export class AgentManager {
658
657
  * an aborted signal, so a `spawnAndWait` on it would wait forever — pi has no
659
658
  * tool-execution timeout to bail it out.
660
659
  *
661
- * The listener is left in place when the agent starts. `startAgent` adds its
662
- * own, so both fire on a later abort, but `abort()` on an already-stopped
663
- * record is a no-op — so detaching would only be tidiness, and tidiness the
664
- * `abortAll`/`dispose` paths could not offer anyway.
660
+ * The listener is removable (`record.detachParentAbort`): `startAgent`
661
+ * replaces it with its own, and `sendToBackground` must be able to take it
662
+ * off, or the caller that armed it could stop a run it no longer owns.
665
663
  */
666
664
  private armQueuedAbort(id: string, signal?: AbortSignal): boolean {
667
665
  if (signal === undefined) return true
@@ -673,10 +671,39 @@ export class AgentManager {
673
671
  }
674
672
  return false
675
673
  }
676
- signal.addEventListener("abort", () => this.abort(id), { once: true })
674
+ const record = this.agents.get(id)
675
+ if (record) this.armParentAbort(record, signal)
677
676
  return true
678
677
  }
679
678
 
679
+ /**
680
+ * Stop `record` when its caller's `signal` aborts, replacing any listener an
681
+ * earlier stage armed — at most one is live per record, so one call to
682
+ * `detachParentAbort` cuts the run loose from its caller.
683
+ */
684
+ private armParentAbort(
685
+ record: AgentRecord,
686
+ signal: AbortSignal | undefined,
687
+ ): void {
688
+ this.detachParentAbort(record)
689
+ if (signal === undefined) return
690
+ const onParentAbort = () => this.abort(record.id)
691
+ signal.addEventListener("abort", onParentAbort, { once: true })
692
+ record.detachParentAbort = () =>
693
+ signal.removeEventListener("abort", onParentAbort)
694
+ }
695
+
696
+ private detachParentAbort(record: AgentRecord): void {
697
+ record.detachParentAbort?.()
698
+ record.detachParentAbort = undefined
699
+ }
700
+
701
+ /** Add `delta` to the running count of `pool`; no pool, no count. */
702
+ private chargePool(pool: Pool | undefined, delta: 1 | -1): void {
703
+ if (pool === "background") this.runningBackground += delta
704
+ else if (pool === "foreground") this.runningForeground += delta
705
+ }
706
+
680
707
  /**
681
708
  * Kick off an agent's startup and register it under `startups`. The returned
682
709
  * promise never rejects — the failure is delivered through `awaitStartup`,
@@ -762,23 +789,19 @@ export class AgentManager {
762
789
  // still copying its repo. Claiming "running" here also keeps abort() and
763
790
  // abortAll() able to reach an agent whose worktree is still being created.
764
791
  //
765
- // The pool is resolved ONCE, here, and carried to `settleRun` below:
792
+ // The pool is resolved ONCE, here, and recorded for `settleRun`:
766
793
  // `poolFor` reads `maxConcurrentForeground`, which the user can change from
767
794
  // `/agents → Settings` mid-run, so recomputing it at settle time would
768
795
  // decrement a pool this run never charged (counter underflow, limit
769
796
  // silently lifted) or skip the decrement for one it did (leaked slot —
770
- // every later blocking spawn queues forever). The two startup exits below
771
- // never reach `settleRun`, so they hand the slot back themselves.
772
- const pool = this.poolFor(record)
773
- const releaseSlot = () => {
774
- if (pool === "background") this.runningBackground--
775
- else if (pool === "foreground") this.runningForeground--
776
- }
797
+ // every later blocking spawn queues forever). Only `sendToBackground`
798
+ // changes it afterwards, moving the counters with it. The two startup exits
799
+ // below never reach `settleRun`, so they hand the slot back themselves.
800
+ record.chargedPool = this.poolFor(record)
777
801
  record.status = "running"
778
802
  record.startedAt = Date.now()
779
803
  record.startGate = undefined
780
- if (pool === "background") this.runningBackground++
781
- else if (pool === "foreground") this.runningForeground++
804
+ this.chargePool(record.chargedPool, 1)
782
805
 
783
806
  // Worktree isolation: try to create a temporary git worktree. Strict —
784
807
  // fail loud if not possible (no silent fallback to main tree). Done BEFORE
@@ -786,11 +809,19 @@ export class AgentManager {
786
809
  // The project switch is enforced here as well as at the tool boundary
787
810
  // because cross-extension RPC forwards its options unvalidated — a schema
788
811
  // that omits the field can't stop a caller that never saw the schema.
812
+ // The agent file's `isolation:` is resolved here too, not by each caller:
813
+ // mentions pass none, and RPC forwards only its caller's value. Workflow
814
+ // children are the exception, as in Claude Code, whose workflow agent()
815
+ // reads only the script's option and ignores the definition's.
816
+ const isolation =
817
+ options.workflowId !== undefined
818
+ ? options.isolation
819
+ : (getAgentConfig(type)?.isolation ?? options.isolation)
789
820
  let worktreeCwd: string | undefined
790
- if (options.isolation === "worktree" && isWorktreeIsolationEnabled()) {
821
+ if (isolation === "worktree" && isWorktreeIsolationEnabled()) {
791
822
  const wt = await createWorktree(pi, baseCwd, id)
792
823
  if (!wt) {
793
- releaseSlot()
824
+ this.chargePool(record.chargedPool, -1)
794
825
  throw new Error(
795
826
  'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
796
827
  "Initialize git and commit at least once, or omit `isolation`.",
@@ -805,6 +836,10 @@ export class AgentManager {
805
836
  // subdirectory, silently dropping extensions/skills.
806
837
  worktreeCwd = customCwd !== undefined ? wt.workPath : wt.path
807
838
  this.worktreeRepos.add(baseCwd)
839
+ // The widget's "worktree" tag reads this; only callers that resolve the
840
+ // agent file themselves seed it.
841
+ record.invocation ??= {}
842
+ record.invocation.isolation = "worktree"
808
843
 
809
844
  // No longer "running" means a stop landed while the copy was being made
810
845
  // (abort(), abortAll()) — a window that did not exist when creation was
@@ -812,7 +847,7 @@ export class AgentManager {
812
847
  // burn tokens on work nobody is waiting for: discard the fresh (and by
813
848
  // definition unchanged) worktree instead.
814
849
  if (record.status !== "running") {
815
- releaseSlot()
850
+ this.chargePool(record.chargedPool, -1)
816
851
  record.worktreeResult = await cleanupWorktree(
817
852
  pi,
818
853
  baseCwd,
@@ -829,24 +864,13 @@ export class AgentManager {
829
864
 
830
865
  this.onStart?.(record)
831
866
 
832
- // Wire parent abort signal to stop the subagent when the parent is interrupted
833
- let detachParentSignal: (() => void) | undefined
834
- if (options.signal) {
835
- // A queued spawn can start minutes after the caller handed us its signal,
836
- // by which time it may already be aborted — and `addEventListener` would
837
- // never fire, leaving a child the parent can no longer reach.
838
- if (options.signal.aborted) this.abort(id)
839
- else {
840
- const onParentAbort = () => this.abort(id)
841
- options.signal.addEventListener("abort", onParentAbort, { once: true })
842
- detachParentSignal = () =>
843
- options.signal!.removeEventListener("abort", onParentAbort)
844
- }
845
- }
846
- const detach = () => {
847
- detachParentSignal?.()
848
- detachParentSignal = undefined
849
- }
867
+ // Wire parent abort signal to stop the subagent when the parent is interrupted.
868
+ // A queued spawn can start minutes after the caller handed us its signal,
869
+ // by which time it may already be aborted — and `addEventListener` would
870
+ // never fire, leaving a child the parent can no longer reach.
871
+ if (options.signal?.aborted) this.abort(id)
872
+ else this.armParentAbort(record, options.signal)
873
+ const detach = () => this.detachParentAbort(record)
850
874
 
851
875
  const promise = runAgent(ctx, type, prompt, {
852
876
  pi,
@@ -1019,7 +1043,7 @@ export class AgentManager {
1019
1043
 
1020
1044
  this.abortOwnedChildren(id)
1021
1045
 
1022
- this.settleRun(record, true, pool)
1046
+ this.settleRun(record, true)
1023
1047
  return responseText
1024
1048
  },
1025
1049
  )
@@ -1061,7 +1085,7 @@ export class AgentManager {
1061
1085
 
1062
1086
  this.abortOwnedChildren(id)
1063
1087
 
1064
- this.settleRun(record, false, pool)
1088
+ this.settleRun(record, false)
1065
1089
  return ""
1066
1090
  })
1067
1091
 
@@ -1089,18 +1113,19 @@ export class AgentManager {
1089
1113
  *
1090
1114
  * @param guardCallback swallow a throwing `onComplete` (the success path does;
1091
1115
  * the error path historically did not, and keeps not doing so).
1092
- * @param pool the pool this run was CHARGED TO at start time — passed in, not
1093
- * recomputed, so a mid-run change to `maxConcurrentForeground` can't make
1094
- * the release disagree with the acquire.
1116
+ *
1117
+ * Releases `record.chargedPool` — the pool the run was charged to at start
1118
+ * (or moved to by `sendToBackground`), never a recomputed one, so a mid-run
1119
+ * change to `maxConcurrentForeground` can't make the release disagree with
1120
+ * the acquire.
1095
1121
  */
1096
- private settleRun(
1097
- record: AgentRecord,
1098
- guardCallback: boolean,
1099
- pool: Pool | undefined,
1100
- ): void {
1122
+ private settleRun(record: AgentRecord, guardCallback: boolean): void {
1101
1123
  if (!record.isBackground) record.resultConsumed = true
1102
- if (pool === "background") this.runningBackground--
1103
- else if (pool === "foreground") this.runningForeground--
1124
+ // Nobody waits on a settled run; a later foreground resume must not look
1125
+ // like a spawn `sendToBackground` could hand off.
1126
+ record.blocking = false
1127
+ const pool = record.chargedPool
1128
+ this.chargePool(pool, -1)
1104
1129
 
1105
1130
  if (guardCallback) {
1106
1131
  try {
@@ -1222,10 +1247,10 @@ export class AgentManager {
1222
1247
  // The run promise only exists once startup is past its awaited repo copy —
1223
1248
  // without this the call would return before the agent had started at all.
1224
1249
  // A startup failure (strict worktree isolation) rejects here, which is what
1225
- // the immediate path owes its caller: pi only marks a tool result failed
1226
- // when `execute` throws. A queued spawn's failure landed on the record
1227
- // instead (nobody was awaiting `startups` at drain time) and is rethrown
1228
- // below, so the contract is the same either way.
1250
+ // the immediate path owes its caller: the Agent tool reports failure by
1251
+ // letting this throw out of `execute`. A queued spawn's failure landed on
1252
+ // the record instead (nobody was awaiting `startups` at drain time) and is
1253
+ // rethrown below, so the contract is the same either way.
1229
1254
  await this.awaitStartup(id)
1230
1255
 
1231
1256
  // undefined when it was aborted while queued, or stopped mid-copy, and so
@@ -1243,6 +1268,49 @@ export class AgentManager {
1243
1268
  return { id, record }
1244
1269
  }
1245
1270
 
1271
+ /**
1272
+ * Hand a live run to the background without restarting it. The session,
1273
+ * promise, in-flight turn, transcript and worktree all continue under the
1274
+ * same id; what changes is who answers for it. The caller's parent-abort
1275
+ * listener comes off (its Esc no longer reaches the run — `abort()` and
1276
+ * `/agents` still do), the slot moves to the background pool, and the run
1277
+ * then settles through the ordinary background path, since `settleRun`
1278
+ * reads the live `isBackground` and `chargedPool`.
1279
+ *
1280
+ * Releasing the caller is the caller's job — the `Agent` tool races its own
1281
+ * wake-up against `spawnAndWait`. This only refuses what it can see: a run
1282
+ * nobody is blocking on (a background or detached spawn, one already handed
1283
+ * off, or a foreground RESUME — `blocking` is cleared when the spawned run
1284
+ * settles, and a resume never charged the slot this would move), and one
1285
+ * that reports through something other than the session (a nested child, a
1286
+ * workflow's agent).
1287
+ *
1288
+ * The background slot is taken even when the pool is full: suspending a
1289
+ * live run is not an option, so it runs over the limit and queued
1290
+ * background work waits for the count to drop, as with `bypassQueue`.
1291
+ */
1292
+ sendToBackground(id: string): boolean {
1293
+ const record = this.agents.get(id)
1294
+ if (
1295
+ !record?.blocking ||
1296
+ !isTopLevelAgent(record) ||
1297
+ record.status !== "running" ||
1298
+ !record.promise
1299
+ )
1300
+ return false
1301
+
1302
+ this.detachParentAbort(record)
1303
+ this.chargePool(record.chargedPool, -1)
1304
+ record.chargedPool = "background"
1305
+ this.chargePool(record.chargedPool, 1)
1306
+ record.blocking = false
1307
+ record.isBackground = true
1308
+ record.resultConsumed = false
1309
+ // A freed foreground slot may have unblocked a queued blocking spawn.
1310
+ this.drainQueue()
1311
+ return true
1312
+ }
1313
+
1246
1314
  /**
1247
1315
  * Resume an existing agent session with a new prompt.
1248
1316
  */