@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/CHANGELOG.md +14 -0
- package/package.json +8 -4
- package/src/agent-color.ts +72 -67
- package/src/agent-file-toggle.ts +255 -0
- package/src/agent-manager.ts +192 -3
- package/src/agent-runner.ts +18 -3
- package/src/index.ts +322 -148
- package/src/nested-tools.ts +26 -35
- package/src/output-file.ts +24 -1
- package/src/prompts.ts +15 -1
- package/src/ui/agent-widget.ts +24 -2
- package/src/ui/schedule-menu.ts +9 -8
- package/src/ui/select-item.ts +48 -0
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
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
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
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
2620
|
-
if (modelChoice === "haiku")
|
|
2621
|
-
|
|
2622
|
-
else if (modelChoice === "
|
|
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
|
-
|
|
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
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
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
|
-
|
|
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",
|