@herbertgao/pi-subagents 0.15.2 → 0.15.4

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/src/index.ts CHANGED
@@ -31,7 +31,17 @@ import {
31
31
  } from "@earendil-works/pi-tui"
32
32
  import { Type } from "@sinclair/typebox"
33
33
  import { abortable } from "./abortable.js"
34
- import { renderAgentName } from "./agent-color.js"
34
+ import { hasAgentBadge, renderAgentName } from "./agent-color.js"
35
+ import {
36
+ buildNewAgentFile,
37
+ disableInContent,
38
+ enableInContent,
39
+ findAgentFile,
40
+ isEmptyStub,
41
+ personalAgentsDir,
42
+ projectAgentsDir,
43
+ serializeAgentFile,
44
+ } from "./agent-file-toggle.js"
35
45
  import { AgentManager } from "./agent-manager.js"
36
46
  import {
37
47
  getAgentConversation,
@@ -74,6 +84,7 @@ import {
74
84
  import { getMaxSubagentDepth, setMaxSubagentDepth } from "./nested-tools.js"
75
85
  import {
76
86
  createOutputFilePath,
87
+ ensureOutputFile,
77
88
  getOutputTranscriptDefault,
78
89
  setOutputTranscriptDefault,
79
90
  streamToOutputFile,
@@ -121,6 +132,7 @@ import {
121
132
  } from "./ui/agent-widget.js"
122
133
  import { FleetList, type FleetUICtx } from "./ui/fleet-list.js"
123
134
  import { showSchedulesMenu } from "./ui/schedule-menu.js"
135
+ import { selectItem } from "./ui/select-item.js"
124
136
  import {
125
137
  addUsage,
126
138
  getLifetimeTotal,
@@ -362,6 +374,43 @@ function buildNotificationDetails(
362
374
  }
363
375
  }
364
376
 
377
+ /**
378
+ * Format an agent's tool scope for the Agent tool description.
379
+ *
380
+ * This suffix describes BUILT-IN scope only — extension tools are resolved when
381
+ * the agent runs (extensions can register asynchronously), so they cannot be
382
+ * enumerated while the description is being built. That is why an agent with
383
+ * `tools: "*, ext:mcp/search"` renders "*" and always has.
384
+ *
385
+ * Two distinctions matter, both of them capability claims the orchestrator acts on:
386
+ *
387
+ * - absent vs empty. `builtinToolNames: undefined` means the agent never narrowed
388
+ * its tools (the shipped defaults); `[]` is what `tools: none` and an `ext:`-only
389
+ * `tools:` parse to, and the runtime really does hand those agents no built-ins.
390
+ * Rendering both "*" tells the orchestrator a tool-less agent can run `bash`.
391
+ * - empty-with-extensions vs empty-without. Zero built-ins does NOT imply zero
392
+ * tools: `tools: none` alongside `extensions:` still surfaces every extension
393
+ * tool (see test/fixtures/.pi/agents/tools-none.md, which expects three). Calling
394
+ * that "none" understates the agent instead of overstating it — better, but still
395
+ * wrong, and it would route work away from the only agent able to do it. "none"
396
+ * is therefore reserved for agents that genuinely can call nothing: `isolated`
397
+ * agents and those with `extensions: false`.
398
+ */
399
+ export function formatToolsSuffix(cfg: AgentConfig | undefined): string {
400
+ const tools = cfg?.builtinToolNames
401
+ if (!tools) return "*"
402
+ if (tools.length === 0) {
403
+ // `isolated` overrides extensions to false in the runner, so both mean the
404
+ // agent has no extension tools either — and then it truly has nothing.
405
+ const noExtensionTools = cfg?.isolated === true || cfg?.extensions === false
406
+ return noExtensionTools ? "none" : "no built-ins, extension tools only"
407
+ }
408
+ const isFullSet =
409
+ tools.length === BUILTIN_TOOL_NAMES.length &&
410
+ BUILTIN_TOOL_NAMES.every((t) => tools.includes(t))
411
+ return isFullSet ? "*" : tools.join(", ")
412
+ }
413
+
365
414
  export default function (pi: ExtensionAPI) {
366
415
  // Child AgentSessions load normal extensions. Re-entering this extension there
367
416
  // would create another manager and leak handlers. Nested orchestration is
@@ -640,6 +689,14 @@ export default function (pi: ExtensionAPI) {
640
689
  undefined,
641
690
  (record) => {
642
691
  if (record.parentAgentId) return
692
+ // Agent-tool spawns refresh these surfaces in their tool handler, but RPC
693
+ // and scheduler spawns enter through the manager directly.
694
+ if (currentCtx?.hasUI) {
695
+ widget.ensureTimer()
696
+ widget.update()
697
+ fleet.ensureTimer()
698
+ fleet.update()
699
+ }
643
700
  // Emit started event when agent transitions to running (including from queue)
644
701
  pi.events.emit("subagents:started", {
645
702
  id: record.id,
@@ -752,6 +809,10 @@ export default function (pi: ExtensionAPI) {
752
809
  // bound session_start, so a filtered-out activation never advertises (#142).
753
810
  pi.on("session_start", async (_event, ctx) => {
754
811
  currentCtx = ctx
812
+ if (ctx.hasUI) {
813
+ widget.setUICtx(ctx.ui)
814
+ fleet.setUICtx(ctx.ui as any)
815
+ }
755
816
  manager.clearCompleted(true)
756
817
  // Guard mirrors the `!scheduler.isActive()` pattern below: session_start
757
818
  // fires once per activation, but a double-bind must not leak listeners.
@@ -935,16 +996,6 @@ export default function (pi: ExtensionAPI) {
935
996
  widget.onTurnStart()
936
997
  })
937
998
 
938
- /** Format an agent's tool scope: "*" when it has all built-ins, else a comma-separated list. */
939
- const formatToolsSuffix = (cfg: AgentConfig | undefined): string => {
940
- const tools = cfg?.builtinToolNames
941
- if (!tools || tools.length === 0) return "*"
942
- const isFullSet =
943
- tools.length === BUILTIN_TOOL_NAMES.length &&
944
- BUILTIN_TOOL_NAMES.every((t) => tools.includes(t))
945
- return isFullSet ? "*" : tools.join(", ")
946
- }
947
-
948
999
  /** Build the full type list text dynamically from available agents only. */
949
1000
  const buildTypeListText = () => {
950
1001
  const available = getAvailableTypes()
@@ -1195,7 +1246,7 @@ Terse command-style prompts produce shallow, generic work.
1195
1246
  resume: Type.Optional(
1196
1247
  Type.String({
1197
1248
  description:
1198
- "Optional agent ID to resume from. Continues from previous context.",
1249
+ "Optional agent ID to resume from. Continues from previous context. Combine with run_in_background to resume detached and be notified on completion. An agent can only be resumed once its current run has finished — use steer_subagent to reach one mid-run.",
1199
1250
  }),
1200
1251
  ),
1201
1252
  isolated: Type.Optional(
@@ -1222,31 +1273,45 @@ Terse command-style prompts produce shallow, generic work.
1222
1273
  // ---- Custom rendering: Claude Code style ----
1223
1274
 
1224
1275
  renderCall(args, theme, context) {
1225
- let restoreBackground = theme.getBgAnsi("toolSuccessBg")
1226
- if (context.isPartial)
1227
- restoreBackground = theme.getBgAnsi("toolPendingBg")
1228
- else if (context.isError)
1229
- restoreBackground = theme.getBgAnsi("toolErrorBg")
1230
-
1276
+ // A badge closes its own background, which would clear the tool block's row tint
1277
+ // for the rest of the line, so the badge restores it. The tint is opened here too:
1278
+ // the TUI's Box paints it, but HTML export takes it from CSS, and restoring a
1279
+ // background the line never opened is what banded the export before. The line is
1280
+ // deliberately left open — Box.applyBackgroundToLine pads to width and *then*
1281
+ // wraps, so closing here would leave that padding untinted, and HTML export closes
1282
+ // any open span per line anyway. No badge means no tint, so an uncolored agent
1283
+ // renders exactly the line it always did.
1284
+ const rowBackground = hasAgentBadge(args.subagent_type)
1285
+ ? theme.getBgAnsi(
1286
+ context.isPartial
1287
+ ? "toolPendingBg"
1288
+ : context.isError
1289
+ ? "toolErrorBg"
1290
+ : "toolSuccessBg",
1291
+ )
1292
+ : ""
1231
1293
  const desc = args.description ?? ""
1232
1294
  const name = renderAgentName(args.subagent_type, theme, {
1233
1295
  fallbackColor: "toolTitle",
1234
- restoreBackground,
1296
+ restoreBackground: rowBackground,
1235
1297
  bold: true,
1236
1298
  })
1237
1299
  return new Text(
1238
- "▸ " + name + (desc ? " " + theme.fg("muted", desc) : ""),
1300
+ rowBackground +
1301
+ "▸ " +
1302
+ name +
1303
+ (desc ? " " + theme.fg("muted", desc) : ""),
1239
1304
  0,
1240
1305
  0,
1241
1306
  )
1242
1307
  },
1243
1308
 
1244
- renderResult(result, { expanded, isPartial }, theme) {
1309
+ renderResult(result, { expanded, isPartial }, theme, renderContext) {
1310
+ const resultText =
1311
+ result.content[0]?.type === "text" ? result.content[0].text : ""
1245
1312
  const details = result.details as AgentDetails | undefined
1246
- if (!details) {
1247
- const text =
1248
- result.content[0]?.type === "text" ? result.content[0].text : ""
1249
- return new Text(text, 0, 0)
1313
+ if (renderContext?.isError || !details?.status) {
1314
+ return new Text(resultText, 0, 0)
1250
1315
  }
1251
1316
 
1252
1317
  // Helper: build "haiku · thinking: high · ↻5≤30 · 3 tool uses · 33.8k tokens" stats string
@@ -1301,8 +1366,6 @@ Terse command-style prompts produce shallow, generic work.
1301
1366
  line += " " + theme.fg("dim", "·") + " " + theme.fg("dim", duration)
1302
1367
 
1303
1368
  if (expanded) {
1304
- const resultText =
1305
- result.content[0]?.type === "text" ? result.content[0].text : ""
1306
1369
  if (resultText) {
1307
1370
  const lines = resultText.split("\n").slice(0, 50)
1308
1371
  for (const l of lines) {
@@ -1332,6 +1395,10 @@ Terse command-style prompts produce shallow, generic work.
1332
1395
  return new Text(line, 0, 0)
1333
1396
  }
1334
1397
 
1398
+ if (details.status !== "error" && details.status !== "aborted") {
1399
+ return new Text(resultText, 0, 0)
1400
+ }
1401
+
1335
1402
  // ---- Error / Aborted (hard max_turns) ----
1336
1403
  const s = stats(details)
1337
1404
  let line = theme.fg("error", "✗") + (s ? " " + s : "")
@@ -1553,6 +1620,144 @@ Terse command-style prompts produce shallow, generic work.
1553
1620
  `Agent "${params.resume}" has no active session to resume.`,
1554
1621
  )
1555
1622
  }
1623
+
1624
+ // Background resume: detached run that notifies on completion, mirroring
1625
+ // a background spawn. Previously run_in_background was silently ignored
1626
+ // on resume (this branch returned before the background branch below),
1627
+ // so a resumed agent always blocked the main loop until it finished.
1628
+ if (runInBackground) {
1629
+ const id = existing.id
1630
+ // A detached resume hands control back while the record stays
1631
+ // "running", so nothing stops the model from resuming the same agent
1632
+ // again mid-run. manager.resume() refuses that (it would orphan the
1633
+ // live run's abort controller); say why here, where the model can act
1634
+ // on it, instead of letting it read as a generic failure.
1635
+ if (existing.status === "running" || existing.status === "queued") {
1636
+ return textResult(
1637
+ `Agent "${params.resume}" is still ${existing.status} — it can only be resumed once its current run finishes.\n` +
1638
+ `Use steer_subagent to send it a message mid-run, or get_subagent_result to wait for it.`,
1639
+ )
1640
+ }
1641
+
1642
+ const joinMode = resolveJoinMode(defaultJoinMode, true)
1643
+ existing.toolCallId = toolCallId
1644
+ if (joinMode) existing.joinMode = joinMode
1645
+ // Reuse the agent's transcript rather than starting a fresh one: the
1646
+ // path is deterministic per agent+session, so writing an initial entry
1647
+ // would truncate the previous run's turns (see ensureOutputFile).
1648
+ if (existing.outputFile) {
1649
+ // Preserve the original transcript path across parent session
1650
+ // switches; records intentionally survive those switches for
1651
+ // resume. Only create a path for an older run that never wrote
1652
+ // one and is now being resumed with transcripts enabled.
1653
+ ensureOutputFile(existing.outputFile)
1654
+ } else if (outputTranscript) {
1655
+ existing.outputFile = createOutputFilePath(
1656
+ ctx.cwd,
1657
+ id,
1658
+ ctx.sessionManager.getSessionId(),
1659
+ )
1660
+ ensureOutputFile(existing.outputFile)
1661
+ }
1662
+ // Anchor streaming past the turns already on disk, captured BEFORE the
1663
+ // run starts. The resumed prompt lands as an ordinary user message at
1664
+ // this index, so it is written exactly once.
1665
+ const transcriptAnchor = existing.session.messages.length
1666
+
1667
+ const { state: bgState, callbacks: bgCallbacks } =
1668
+ createActivityTracker(effectiveMaxTurns)
1669
+ // resumeAgent has no onSessionCreated — the session predates this run —
1670
+ // so seed it directly, or the widget shows no context % for the agent.
1671
+ bgState.session = existing.session
1672
+
1673
+ // No `signal`: a background spawn deliberately omits it, and a detached
1674
+ // resume must behave the same. Passing it would abort this agent when
1675
+ // the parent turn is interrupted (user Esc), while agents started with
1676
+ // run_in_background in that same turn keep going.
1677
+ const record = await manager.resume(
1678
+ params.resume,
1679
+ params.prompt,
1680
+ undefined,
1681
+ {
1682
+ isBackground: true,
1683
+ onToolActivity: bgCallbacks.onToolActivity,
1684
+ onTurnEnd: bgCallbacks.onTurnEnd,
1685
+ onAssistantUsage: bgCallbacks.onAssistantUsage,
1686
+ // Fires when the run actually starts — immediately, or on queue
1687
+ // drain. Wiring it here (rather than after resume() returns) means a
1688
+ // resume stopped while still queued never started streaming, so
1689
+ // there is no subscription left behind for a later run to trip over.
1690
+ onStarted: () => {
1691
+ const rec = manager.getRecord(id)
1692
+ if (rec?.session && rec.outputFile) {
1693
+ rec.outputCleanup = streamToOutputFile(
1694
+ rec.session,
1695
+ rec.outputFile,
1696
+ id,
1697
+ ctx.cwd,
1698
+ transcriptAnchor,
1699
+ )
1700
+ }
1701
+ },
1702
+ },
1703
+ )
1704
+ if (!record) {
1705
+ return textResult(`Failed to resume agent "${params.resume}".`)
1706
+ }
1707
+
1708
+ if (joinMode != null && joinMode !== "async") {
1709
+ currentBatchAgents.push({ id, joinMode })
1710
+ if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer)
1711
+ batchFinalizeTimer = setTimeout(finalizeBatch, 100)
1712
+ }
1713
+
1714
+ agentActivity.set(id, bgState)
1715
+ // This agent already finished once, so the widget holds a finished-age
1716
+ // for it that is past the linger limit — without clearing it, the
1717
+ // resumed run's ✓/✗ line never renders and the agent just vanishes.
1718
+ widget.markRunning(id)
1719
+ widget.ensureTimer()
1720
+ widget.update()
1721
+ fleet.ensureTimer()
1722
+ fleet.update()
1723
+
1724
+ // Resume ignores subagent_type (the record keeps the type it was
1725
+ // spawned with), so report the record's own identity — a "created"
1726
+ // event carrying the caller's type would re-register the agent under
1727
+ // the wrong one in cross-extension mirrors keyed by id.
1728
+ pi.events.emit("subagents:created", {
1729
+ id,
1730
+ type: existing.type,
1731
+ description: existing.description,
1732
+ isBackground: true,
1733
+ })
1734
+
1735
+ const isQueued = record.status === "queued"
1736
+ return textResult(
1737
+ `Agent ${isQueued ? "queued" : "resumed"} in background.\n` +
1738
+ `Agent ID: ${id}\n` +
1739
+ `Type: ${existing.type}\n` +
1740
+ (record.outputFile
1741
+ ? `Output file: ${record.outputFile}\n`
1742
+ : "") +
1743
+ (isQueued
1744
+ ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n`
1745
+ : "") +
1746
+ `\nYou will be notified when this agent completes.\n` +
1747
+ `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.`,
1748
+ {
1749
+ ...detailBase,
1750
+ subagentType: existing.type,
1751
+ displayName: existing.type,
1752
+ toolUses: record.toolUses,
1753
+ tokens: "",
1754
+ durationMs: 0,
1755
+ status: "background" as const,
1756
+ agentId: id,
1757
+ },
1758
+ )
1759
+ }
1760
+
1556
1761
  const record = await manager.resume(
1557
1762
  params.resume,
1558
1763
  params.prompt,
@@ -1598,23 +1803,22 @@ Terse command-style prompts produce shallow, generic work.
1598
1803
  }
1599
1804
  }
1600
1805
 
1601
- try {
1602
- id = manager.spawn(pi, ctx, subagentType, params.prompt, {
1603
- description: params.description,
1604
- model,
1605
- maxTurns: effectiveMaxTurns,
1606
- isolated,
1607
- inheritContext,
1608
- thinkingLevel: thinking,
1609
- isBackground: true,
1610
- isolation,
1611
- invocation: agentInvocation,
1612
- rootSessionId: ctx.sessionManager.getSessionId(),
1613
- ...bgCallbacks,
1614
- })
1615
- } catch (err) {
1616
- return textResult(err instanceof Error ? err.message : String(err))
1617
- }
1806
+ // A throw here means the agent never started. Let it out: Pi marks a
1807
+ // tool call failed only when execute throws, while a returned message
1808
+ // reads to the model as a subagent that ran and reported this.
1809
+ id = manager.spawn(pi, ctx, subagentType, params.prompt, {
1810
+ description: params.description,
1811
+ model,
1812
+ maxTurns: effectiveMaxTurns,
1813
+ isolated,
1814
+ inheritContext,
1815
+ thinkingLevel: thinking,
1816
+ isBackground: true,
1817
+ isolation,
1818
+ invocation: agentInvocation,
1819
+ rootSessionId: ctx.sessionManager.getSessionId(),
1820
+ ...bgCallbacks,
1821
+ })
1618
1822
 
1619
1823
  // Set output file + join mode synchronously after spawn, before the
1620
1824
  // event loop yields — onSessionCreated is async so this is safe.
@@ -1774,18 +1978,15 @@ Terse command-style prompts produce shallow, generic work.
1774
1978
  },
1775
1979
  )
1776
1980
  record = fgResult.record
1777
- } catch (err) {
1981
+ } finally {
1982
+ // A startup throw propagates as a failed tool call without leaving
1983
+ // the spinner running or a finished agent in the widget.
1778
1984
  clearInterval(spinnerInterval)
1779
- return textResult(err instanceof Error ? err.message : String(err))
1780
- }
1781
-
1782
- clearInterval(spinnerInterval)
1783
-
1784
- // Clean up foreground agent from widget
1785
- if (fgId) {
1786
- agentActivity.delete(fgId)
1787
- widget.markFinished(fgId)
1788
- fleet.onAgentFinished(fgId)
1985
+ if (fgId) {
1986
+ agentActivity.delete(fgId)
1987
+ widget.markFinished(fgId)
1988
+ fleet.onAgentFinished(fgId)
1989
+ }
1789
1990
  }
1790
1991
 
1791
1992
  // Get final token count
@@ -1994,27 +2195,9 @@ Terse command-style prompts produce shallow, generic work.
1994
2195
 
1995
2196
  // ---- /agents interactive menu ----
1996
2197
 
1997
- const projectAgentsDir = () => join(process.cwd(), ".pi", "agents")
1998
- const workspaceAgentsDir = () => join(process.cwd(), ".agents", "agents")
1999
- const personalAgentsDir = () => join(getAgentDir(), "agents")
2000
-
2001
- /** Find the file path of a custom agent by name, in discovery-precedence order (project, workspace, then global). */
2002
- function findAgentFile(
2003
- name: string,
2004
- ):
2005
- | { path: string; location: "project" | "workspace" | "personal" }
2006
- | undefined {
2007
- const projectPath = join(projectAgentsDir(), `${name}.md`)
2008
- if (existsSync(projectPath))
2009
- return { path: projectPath, location: "project" }
2010
- const workspacePath = join(workspaceAgentsDir(), `${name}.md`)
2011
- if (existsSync(workspacePath))
2012
- return { path: workspacePath, location: "workspace" }
2013
- const personalPath = join(personalAgentsDir(), `${name}.md`)
2014
- if (existsSync(personalPath))
2015
- return { path: personalPath, location: "personal" }
2016
- return undefined
2017
- }
2198
+ // Directory resolution and the frontmatter edits live in agent-file-toggle.ts
2199
+ // so they are reachable from tests — this command handler is only registered
2200
+ // through `registerCommand`, which every test mocks.
2018
2201
 
2019
2202
  function getModelLabel(type: string, registry?: ModelRegistry): string {
2020
2203
  const cfg = getAgentConfig(type)
@@ -2191,19 +2374,15 @@ Terse command-style prompts produce shallow, generic work.
2191
2374
  return
2192
2375
  }
2193
2376
 
2194
- const options = agents.map((a) => {
2377
+ // Numbered + item-paired. Two same-type agents spawned together with the
2378
+ // same description render identically here, and resolving the choice by
2379
+ // string match would open whichever came first.
2380
+ const record = await selectItem(ctx.ui, "Running agents", agents, (a) => {
2195
2381
  const dn = getDisplayName(a.type)
2196
2382
  const dur = formatDuration(a.startedAt, a.completedAt)
2197
2383
  return `${dn} (${a.description}) · ${a.toolUses} tools · ${a.status} · ${dur}`
2198
2384
  })
2199
-
2200
- const choice = await ctx.ui.select("Running agents", options)
2201
- if (!choice) return
2202
-
2203
- // Find the selected agent by matching the option index
2204
- const idx = options.indexOf(choice)
2205
- if (idx < 0) return
2206
- const record = agents[idx]
2385
+ if (!record) return
2207
2386
 
2208
2387
  await viewAgentConversation(ctx, record)
2209
2388
  // Back-navigation: re-show the list
@@ -2353,40 +2532,7 @@ Terse command-style prompts produce shallow, generic work.
2353
2532
  if (!overwrite) return
2354
2533
  }
2355
2534
 
2356
- // Build the .md file content
2357
- const fmFields: string[] = []
2358
- fmFields.push(`description: ${JSON.stringify(cfg.description)}`)
2359
- if (cfg.displayName) fmFields.push(`display_name: ${cfg.displayName}`)
2360
- if (cfg.color) fmFields.push(`color: ${JSON.stringify(cfg.color)}`)
2361
- fmFields.push(`tools: ${cfg.builtinToolNames?.join(", ") || "all"}`)
2362
- if (cfg.model) fmFields.push(`model: ${cfg.model}`)
2363
- if (cfg.thinking) fmFields.push(`thinking: ${cfg.thinking}`)
2364
- if (cfg.maxTurns) fmFields.push(`max_turns: ${cfg.maxTurns}`)
2365
- if (cfg.allowedSubagents !== undefined) {
2366
- fmFields.push(
2367
- `allowed_subagents: ${cfg.allowedSubagents === "all" ? "all" : cfg.allowedSubagents.join(", ")}`,
2368
- )
2369
- }
2370
- fmFields.push(`prompt_mode: ${cfg.promptMode}`)
2371
- if (cfg.extensions === false) fmFields.push("extensions: false")
2372
- else if (Array.isArray(cfg.extensions))
2373
- fmFields.push(`extensions: ${cfg.extensions.join(", ")}`)
2374
- if (cfg.excludeExtensions?.length)
2375
- fmFields.push(`exclude_extensions: ${cfg.excludeExtensions.join(", ")}`)
2376
- if (cfg.skills === false) fmFields.push("skills: false")
2377
- else if (Array.isArray(cfg.skills))
2378
- fmFields.push(`skills: ${cfg.skills.join(", ")}`)
2379
- if (cfg.disallowedTools?.length)
2380
- fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`)
2381
- if (cfg.inheritContext) fmFields.push("inherit_context: true")
2382
- if (cfg.runInBackground) fmFields.push("run_in_background: true")
2383
- if (cfg.outputTranscript === false)
2384
- fmFields.push("output_transcript: false")
2385
- if (cfg.isolated) fmFields.push("isolated: true")
2386
- if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`)
2387
- if (cfg.isolation) fmFields.push(`isolation: ${cfg.isolation}`)
2388
-
2389
- const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`
2535
+ const content = serializeAgentFile(cfg)
2390
2536
 
2391
2537
  const { writeFileSync } = await import("node:fs")
2392
2538
  writeFileSync(targetPath, content, "utf-8")
@@ -2400,11 +2546,20 @@ Terse command-style prompts produce shallow, generic work.
2400
2546
  if (file) {
2401
2547
  // Existing file — set enabled: false in frontmatter (idempotent)
2402
2548
  const content = readFileSync(file.path, "utf-8")
2403
- if (content.includes("\nenabled: false\n")) {
2549
+ const { content: updated, outcome } = disableInContent(content)
2550
+ if (outcome === "already-disabled") {
2404
2551
  ctx.ui.notify(`${name} is already disabled.`, "info")
2405
2552
  return
2406
2553
  }
2407
- const updated = content.replace(/^---\n/, "---\nenabled: false\n")
2554
+ if (outcome === "no-frontmatter") {
2555
+ // Nothing to edit — say so rather than rewriting the file unchanged and
2556
+ // reporting success for a change that never happened.
2557
+ ctx.ui.notify(
2558
+ `Cannot disable ${name}: ${file.path} has no frontmatter block.`,
2559
+ "error",
2560
+ )
2561
+ return
2562
+ }
2408
2563
  const { writeFileSync } = await import("node:fs")
2409
2564
  writeFileSync(file.path, updated, "utf-8")
2410
2565
  reloadCustomAgents()
@@ -2437,11 +2592,17 @@ Terse command-style prompts produce shallow, generic work.
2437
2592
  if (!file) return
2438
2593
 
2439
2594
  const content = readFileSync(file.path, "utf-8")
2440
- const updated = content.replace(/^(---\n)enabled: false\n/, "$1")
2595
+ const { content: updated, changed } = enableInContent(content)
2596
+ if (!changed && !isEmptyStub(updated)) {
2597
+ // The file carries no `enabled: false` to remove, so it was never disabled
2598
+ // by us — reporting success here would hide a no-op.
2599
+ ctx.ui.notify(`${name} is not disabled in ${file.path}.`, "info")
2600
+ return
2601
+ }
2441
2602
  const { writeFileSync } = await import("node:fs")
2442
2603
 
2443
2604
  // If the file was just a stub ("---\n---\n"), delete it to restore the built-in default
2444
- if (updated.trim() === "---\n---" || updated.trim() === "---\n---\n") {
2605
+ if (isEmptyStub(updated)) {
2445
2606
  unlinkSync(file.path)
2446
2607
  reloadCustomAgents()
2447
2608
  ctx.ui.notify(`Enabled ${name} (removed ${file.path})`, "info")
@@ -2509,7 +2670,7 @@ The file format is a markdown file with YAML frontmatter and a system prompt bod
2509
2670
  ---
2510
2671
  name: <optional UI display name; Claude Code-compatible alias for display_name>
2511
2672
  description: <one-line description shown in UI>
2512
- color: <optional name badge color: red, blue, green, yellow, purple, orange, pink, cyan, an Agency Agents alias, or quoted "#RRGGBB">
2673
+ color: <optional agent name badge color: red, blue, green, yellow, purple, orange, pink, cyan, an Agency Agents alias, or quoted "#RRGGBB">
2513
2674
  tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
2514
2675
  model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5". Omit to inherit parent model>
2515
2676
  thinking: <optional thinking level: ${THINKING_LEVELS.join(", ")}. Omit to inherit>
@@ -2616,16 +2777,12 @@ Write the file using the write tool. Only write the file, nothing else.`
2616
2777
  ])
2617
2778
  if (!modelChoice) return
2618
2779
 
2619
- let modelLine = ""
2620
- if (modelChoice === "haiku")
2621
- modelLine = "\nmodel: anthropic/claude-haiku-4-5"
2622
- else if (modelChoice === "sonnet")
2623
- modelLine = "\nmodel: anthropic/claude-sonnet-4-6"
2624
- else if (modelChoice === "opus")
2625
- modelLine = "\nmodel: anthropic/claude-opus-4-6"
2780
+ let model: string | undefined
2781
+ if (modelChoice === "haiku") model = "anthropic/claude-haiku-4-5"
2782
+ else if (modelChoice === "sonnet") model = "anthropic/claude-sonnet-4-6"
2783
+ else if (modelChoice === "opus") model = "anthropic/claude-opus-4-6"
2626
2784
  else if (modelChoice === "custom...") {
2627
- const customModel = await ctx.ui.input("Model (provider/modelId)")
2628
- if (customModel) modelLine = `\nmodel: ${customModel}`
2785
+ model = (await ctx.ui.input("Model (provider/modelId)")) || undefined
2629
2786
  }
2630
2787
 
2631
2788
  // 5. Thinking
@@ -2636,23 +2793,17 @@ Write the file using the write tool. Only write the file, nothing else.`
2636
2793
  ])
2637
2794
  if (!thinkingChoice) return
2638
2795
 
2639
- let thinkingLine = ""
2640
- if (thinkingChoice !== "inherit")
2641
- thinkingLine = `\nthinking: ${thinkingChoice}`
2642
-
2643
2796
  // 6. System prompt
2644
2797
  const systemPrompt = await ctx.ui.editor("System prompt", "")
2645
2798
  if (systemPrompt === undefined) return
2646
2799
 
2647
- // Build the file
2648
- const content = `---
2649
- description: ${description}
2650
- tools: ${tools}${modelLine}${thinkingLine}
2651
- prompt_mode: replace
2652
- ---
2653
-
2654
- ${systemPrompt}
2655
- `
2800
+ const content = buildNewAgentFile({
2801
+ description,
2802
+ tools,
2803
+ model,
2804
+ thinking: thinkingChoice === "inherit" ? undefined : thinkingChoice,
2805
+ systemPrompt,
2806
+ })
2656
2807
 
2657
2808
  mkdirSync(targetDir, { recursive: true })
2658
2809
  const targetPath = join(targetDir, `${name}.md`)
@@ -2671,7 +2822,16 @@ ${systemPrompt}
2671
2822
  ctx.ui.notify(`Created ${targetPath}`, "info")
2672
2823
  }
2673
2824
 
2674
- function snapshotSettings(): SubagentsSettings {
2825
+ /**
2826
+ * Every settings mutation writes this WHOLE object back to disk, so a field
2827
+ * missing here is erased from the user's subagents.json the next time they
2828
+ * toggle something unrelated. `SubagentsSettings` has every field optional,
2829
+ * so a `: SubagentsSettings` return annotation would let a newly-added setting
2830
+ * be forgotten here and still type-check. `satisfies` instead: it still checks
2831
+ * each value's type and rejects a mistyped key, but leaves the return type
2832
+ * inferred so `_NoMissingSettingsKeys` below can check completeness.
2833
+ */
2834
+ function snapshotSettings() {
2675
2835
  return {
2676
2836
  maxConcurrent: manager.getMaxConcurrent(),
2677
2837
  // 0 = unlimited — per SubagentsSettings.defaultMaxTurns docstring and
@@ -2693,9 +2853,23 @@ ${systemPrompt}
2693
2853
  // explicit configuration — which then fails loudly if general-purpose later
2694
2854
  // goes away. undefined is dropped by JSON.stringify.
2695
2855
  fallbackSubagent: getFallbackSubagent(),
2696
- }
2856
+ } satisfies SubagentsSettings
2697
2857
  }
2698
2858
 
2859
+ // Compile-time completeness guard for snapshotSettings(). If a field is added
2860
+ // to SubagentsSettings and not mirrored above, this Exclude is non-empty and
2861
+ // fails to satisfy `never` — turning a silent settings-erasure bug into a
2862
+ // typecheck error. `npm run typecheck` runs in CI.
2863
+ type _NoMissingSettingsKeys =
2864
+ Exclude<
2865
+ keyof SubagentsSettings,
2866
+ keyof ReturnType<typeof snapshotSettings>
2867
+ > extends never
2868
+ ? true
2869
+ : ["snapshotSettings() is missing a SubagentsSettings key"]
2870
+ const _settingsSnapshotIsComplete: _NoMissingSettingsKeys = true
2871
+ void _settingsSnapshotIsComplete
2872
+
2699
2873
  const NUMERIC_IDS = new Set([
2700
2874
  "maxConcurrent",
2701
2875
  "defaultMaxTurns",