@herbertgao/pi-subagents 0.19.0 → 0.22.0-local.2
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/docs/packages.md +211 -0
- package/docs/rpc.md +61 -63
- package/docs/workflows.md +24 -16
- package/package.json +6 -5
- package/src/agent-color.ts +11 -2
- package/src/agent-file-toggle.ts +51 -14
- package/src/agent-manager.ts +123 -55
- package/src/agent-runner.ts +144 -13
- package/src/agent-source-badge.ts +78 -0
- package/src/completion-outbox.ts +122 -0
- package/src/custom-agents.ts +171 -79
- package/src/index.ts +607 -301
- package/src/package-resources.ts +724 -0
- package/src/schedule.ts +1 -1
- package/src/settings.ts +66 -0
- package/src/types.ts +26 -4
- package/src/ui/agent-widget.ts +5 -2
- package/src/ui/conversation-viewer.ts +355 -33
- package/src/ui/fleet-list.ts +34 -15
- package/src/workflow/host.ts +6 -0
- package/src/workflow/saved.ts +48 -2
package/src/index.ts
CHANGED
|
@@ -28,8 +28,10 @@ import {
|
|
|
28
28
|
} from "@earendil-works/pi-coding-agent"
|
|
29
29
|
import {
|
|
30
30
|
Container,
|
|
31
|
+
isKeyRelease,
|
|
31
32
|
Key,
|
|
32
33
|
matchesKey,
|
|
34
|
+
type ScrollViewScrollbar,
|
|
33
35
|
type SettingItem,
|
|
34
36
|
SettingsList,
|
|
35
37
|
Spacer,
|
|
@@ -62,6 +64,11 @@ import {
|
|
|
62
64
|
setRememberAgents,
|
|
63
65
|
steerAgent,
|
|
64
66
|
} from "./agent-runner.js"
|
|
67
|
+
import {
|
|
68
|
+
rowDescription,
|
|
69
|
+
sourceIndicator,
|
|
70
|
+
sourceLegend,
|
|
71
|
+
} from "./agent-source-badge.js"
|
|
65
72
|
import {
|
|
66
73
|
BUILTIN_TOOL_NAMES,
|
|
67
74
|
getAgentConfig,
|
|
@@ -78,6 +85,7 @@ import {
|
|
|
78
85
|
setFallbackSubagent,
|
|
79
86
|
} from "./agent-types.js"
|
|
80
87
|
import { inChildSessionContext } from "./child-context.js"
|
|
88
|
+
import { CompletionOutbox, NOTIFICATION_HOLD_MS } from "./completion-outbox.js"
|
|
81
89
|
import { type RpcHandle, registerRpcHandlers } from "./cross-extension-rpc.js"
|
|
82
90
|
import { loadCustomAgents } from "./custom-agents.js"
|
|
83
91
|
import { GroupJoinManager } from "./group-join.js"
|
|
@@ -115,6 +123,16 @@ import {
|
|
|
115
123
|
streamToOutputFile,
|
|
116
124
|
writeInitialEntry,
|
|
117
125
|
} from "./output-file.js"
|
|
126
|
+
import {
|
|
127
|
+
getPackageAgentsGate,
|
|
128
|
+
getPackageWorkflowsGate,
|
|
129
|
+
invalidatePackageCache,
|
|
130
|
+
packageNameForPath,
|
|
131
|
+
seedProjectTrust,
|
|
132
|
+
setPackageAgentsGate,
|
|
133
|
+
setPackageWorkflowsGate,
|
|
134
|
+
setProjectTrusted,
|
|
135
|
+
} from "./package-resources.js"
|
|
118
136
|
import { SubagentScheduler } from "./schedule.js"
|
|
119
137
|
import { resolveStorePath, ScheduleStore } from "./schedule-store.js"
|
|
120
138
|
import {
|
|
@@ -163,6 +181,11 @@ import {
|
|
|
163
181
|
type Theme,
|
|
164
182
|
type UICtx,
|
|
165
183
|
} from "./ui/agent-widget.js"
|
|
184
|
+
import {
|
|
185
|
+
ConversationViewer,
|
|
186
|
+
getConversationOverlayOptions,
|
|
187
|
+
type ViewerBackgroundAction,
|
|
188
|
+
} from "./ui/conversation-viewer.js"
|
|
166
189
|
import {
|
|
167
190
|
FleetList,
|
|
168
191
|
type FleetUICtx,
|
|
@@ -256,6 +279,9 @@ export function renderRunningAgentStatus(
|
|
|
256
279
|
return container
|
|
257
280
|
}
|
|
258
281
|
|
|
282
|
+
/** How long a foreground `Agent` call runs before its row offers ctrl+b. */
|
|
283
|
+
const BACKGROUND_HINT_DELAY_MS = 2000
|
|
284
|
+
|
|
259
285
|
/** Format an agent's lifetime token total, or "" when zero. */
|
|
260
286
|
function formatLifetimeTokens(o: { lifetimeUsage: LifetimeUsage }): string {
|
|
261
287
|
const t = getLifetimeTotal(o.lifetimeUsage)
|
|
@@ -630,11 +656,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
630
656
|
"Use the `=` form — the space form consumes the next argument, which would swallow a following prompt.",
|
|
631
657
|
})
|
|
632
658
|
|
|
633
|
-
// Read directly rather than waiting for applyAndEmitLoaded below:
|
|
659
|
+
// Read directly rather than waiting for applyAndEmitLoaded below: these decide
|
|
634
660
|
// the initial load, which happens hundreds of lines before settings are applied.
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
661
|
+
const bootSettings = loadSettings(process.cwd())
|
|
662
|
+
let strictAgentFiles = bootSettings.strictAgentFiles === true
|
|
663
|
+
|
|
664
|
+
// Seed the package gates from the same merged settings, before the first load
|
|
665
|
+
// below reads them. `applyAndEmitLoaded` sets them again through the appliers;
|
|
666
|
+
// doing it here as well is what makes the *initial* registry honour them.
|
|
667
|
+
setPackageAgentsGate(bootSettings.packageAgents)
|
|
668
|
+
setPackageWorkflowsGate(bootSettings.packageWorkflows)
|
|
669
|
+
// And seed project trust from pi's saved decision, because the initial load is
|
|
670
|
+
// the one the `Agent` tool description is built from — see `seedProjectTrust`.
|
|
671
|
+
seedProjectTrust(process.cwd())
|
|
672
|
+
|
|
673
|
+
/** Reload agents from package/global/project agent dirs and merge with defaults (called on init and each Agent invocation). */
|
|
638
674
|
const reloadCustomAgents = (strict = false) => {
|
|
639
675
|
const userAgents = loadCustomAgents(process.cwd(), strict)
|
|
640
676
|
registerAgents(userAgents)
|
|
@@ -706,71 +742,54 @@ export default function (pi: ExtensionAPI) {
|
|
|
706
742
|
}
|
|
707
743
|
const pendingUsage = new PendingUsagePool()
|
|
708
744
|
|
|
709
|
-
// ----
|
|
710
|
-
//
|
|
711
|
-
//
|
|
712
|
-
|
|
713
|
-
|
|
745
|
+
// ---- Completion delivery ----
|
|
746
|
+
// Every completion notification goes through the outbox, which sends what is
|
|
747
|
+
// pending as one message at the parent's next boundary (see
|
|
748
|
+
// completion-outbox.ts). Each is built when the agent finishes, and reading
|
|
749
|
+
// the result (get_subagent_result, rpc consume) cancels it before it is sent.
|
|
750
|
+
const outbox = new CompletionOutbox(
|
|
751
|
+
(message) =>
|
|
752
|
+
pi.sendMessage<NotificationDetails>(
|
|
753
|
+
{
|
|
754
|
+
customType: "subagent-notification",
|
|
755
|
+
content: message.content,
|
|
756
|
+
display: true,
|
|
757
|
+
details: message.details,
|
|
758
|
+
},
|
|
759
|
+
{ deliverAs: "steer", triggerTurn: true },
|
|
760
|
+
),
|
|
761
|
+
() => currentCtx?.signal !== undefined,
|
|
762
|
+
)
|
|
714
763
|
// A queued result wait must observe completion before its held notification
|
|
715
764
|
// can fire, so successful waits can still suppress that redundant nudge.
|
|
716
|
-
const QUEUE_WAIT_POLL_MS = Math.floor(
|
|
717
|
-
|
|
718
|
-
function scheduleNudge(key: string, send: () => void, delay = NUDGE_HOLD_MS) {
|
|
719
|
-
cancelNudge(key)
|
|
720
|
-
pendingNudges.set(
|
|
721
|
-
key,
|
|
722
|
-
setTimeout(() => {
|
|
723
|
-
pendingNudges.delete(key)
|
|
724
|
-
try {
|
|
725
|
-
send()
|
|
726
|
-
} catch {
|
|
727
|
-
/* ignore stale completion side-effect errors */
|
|
728
|
-
}
|
|
729
|
-
}, delay),
|
|
730
|
-
)
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
function cancelNudge(key: string) {
|
|
734
|
-
const timer = pendingNudges.get(key)
|
|
735
|
-
if (timer != null) {
|
|
736
|
-
clearTimeout(timer)
|
|
737
|
-
pendingNudges.delete(key)
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
// ---- Individual nudge helper (async join mode) ----
|
|
742
|
-
function emitIndividualNudge(record: AgentRecord) {
|
|
743
|
-
if (record.resultConsumed) return // re-check at send time
|
|
744
|
-
|
|
745
|
-
const notification = formatTaskNotification(record, 500, showCost)
|
|
746
|
-
const footer = record.outputFile
|
|
747
|
-
? `\nFull transcript available at: ${record.outputFile}`
|
|
748
|
-
: ""
|
|
765
|
+
const QUEUE_WAIT_POLL_MS = Math.floor(NOTIFICATION_HOLD_MS / 4)
|
|
749
766
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
767
|
+
function sendIndividualNudge(record: AgentRecord) {
|
|
768
|
+
agentActivity.delete(record.id)
|
|
769
|
+
widget.markFinished(record.id)
|
|
770
|
+
fleet.onAgentFinished(record.id)
|
|
771
|
+
// Built now, so a resume before the send cannot rewrite it. A record that
|
|
772
|
+
// cannot be formatted is skipped, as a failed send always was.
|
|
773
|
+
try {
|
|
774
|
+
const footer = record.outputFile
|
|
775
|
+
? `\nFull transcript available at: ${record.outputFile}`
|
|
776
|
+
: ""
|
|
777
|
+
const message = {
|
|
778
|
+
content: formatTaskNotification(record, 500, showCost) + footer,
|
|
755
779
|
details: buildNotificationDetails(
|
|
756
780
|
record,
|
|
757
781
|
500,
|
|
758
782
|
agentActivity.get(record.id),
|
|
759
783
|
),
|
|
760
|
-
}
|
|
761
|
-
{
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
function sendIndividualNudge(record: AgentRecord) {
|
|
766
|
-
agentActivity.delete(record.id)
|
|
767
|
-
widget.markFinished(record.id)
|
|
768
|
-
fleet.onAgentFinished(record.id)
|
|
769
|
-
scheduleNudge(record.id, () => emitIndividualNudge(record))
|
|
784
|
+
}
|
|
785
|
+
outbox.add({ ids: [record.id], build: () => message })
|
|
786
|
+
} catch {
|
|
787
|
+
/* ignore stale completion side-effect errors */
|
|
788
|
+
}
|
|
770
789
|
widget.update()
|
|
771
790
|
}
|
|
772
791
|
|
|
773
|
-
// ---- Group join manager ----
|
|
792
|
+
// ---- Group join manager (`group` join mode only) ----
|
|
774
793
|
const groupJoin = new GroupJoinManager((records, partial) => {
|
|
775
794
|
for (const r of records) {
|
|
776
795
|
agentActivity.delete(r.id)
|
|
@@ -778,44 +797,45 @@ export default function (pi: ExtensionAPI) {
|
|
|
778
797
|
fleet.onAgentFinished(r.id)
|
|
779
798
|
}
|
|
780
799
|
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
800
|
+
const members = records
|
|
801
|
+
.filter((r) => !r.resultConsumed)
|
|
802
|
+
.flatMap((r) => {
|
|
803
|
+
try {
|
|
804
|
+
return [
|
|
805
|
+
{
|
|
806
|
+
id: r.id,
|
|
807
|
+
text: formatTaskNotification(r, 300, showCost),
|
|
808
|
+
details: buildNotificationDetails(
|
|
809
|
+
r,
|
|
810
|
+
300,
|
|
811
|
+
agentActivity.get(r.id),
|
|
812
|
+
),
|
|
813
|
+
},
|
|
814
|
+
]
|
|
815
|
+
} catch {
|
|
816
|
+
return [] /* as in sendIndividualNudge */
|
|
817
|
+
}
|
|
818
|
+
})
|
|
819
|
+
if (members.length > 0) {
|
|
820
|
+
outbox.add({
|
|
821
|
+
ids: members.map((m) => m.id),
|
|
822
|
+
build: (ids) => {
|
|
823
|
+
const unread = members.filter((m) => ids.includes(m.id))
|
|
824
|
+
const notifications = unread.map((m) => m.text).join("\n\n")
|
|
825
|
+
const label = partial
|
|
826
|
+
? `${unread.length} agent(s) finished (partial — others still running)`
|
|
827
|
+
: `${unread.length} agent(s) finished`
|
|
828
|
+
|
|
829
|
+
const [first, ...rest] = unread
|
|
830
|
+
const details = first.details
|
|
831
|
+
if (rest.length > 0) details.others = rest.map((m) => m.details)
|
|
832
|
+
return {
|
|
833
|
+
content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
|
|
834
|
+
details,
|
|
835
|
+
}
|
|
815
836
|
},
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
})
|
|
837
|
+
})
|
|
838
|
+
}
|
|
819
839
|
widget.update()
|
|
820
840
|
}, 30_000)
|
|
821
841
|
|
|
@@ -900,9 +920,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
900
920
|
return
|
|
901
921
|
}
|
|
902
922
|
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
if (
|
|
923
|
+
// A `group` spawn whose turn has not ended yet: its group is registered at
|
|
924
|
+
// that turn's end, which picks this completion up retroactively.
|
|
925
|
+
if (turnGroupSpawns.includes(record.id)) {
|
|
906
926
|
widget.update()
|
|
907
927
|
return
|
|
908
928
|
}
|
|
@@ -1118,9 +1138,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
1118
1138
|
// bound session_start, so a filtered-out activation never advertises (#142).
|
|
1119
1139
|
pi.on("session_start", async (_event, ctx) => {
|
|
1120
1140
|
currentCtx = ctx
|
|
1141
|
+
// Pi resolves project trust before the first session starts, so this is the
|
|
1142
|
+
// earliest honest answer. The activation-time load defaulted to untrusted,
|
|
1143
|
+
// so a trusted project's `packages[]` only becomes visible now.
|
|
1144
|
+
setProjectTrusted(ctx.isProjectTrusted?.() === true)
|
|
1145
|
+
// Unconditional, and not folded into the call above: `/reload` and a new or
|
|
1146
|
+
// resumed session all land here, and any of them may follow a `pi install`
|
|
1147
|
+
// that added a package the cached root list predates.
|
|
1148
|
+
invalidatePackageCache()
|
|
1149
|
+
reloadCustomAgents()
|
|
1121
1150
|
if (ctx.hasUI) {
|
|
1122
1151
|
widget.setUICtx(ctx.ui)
|
|
1123
1152
|
fleet.setUICtx(ctx.ui as any)
|
|
1153
|
+
watchHandoffKey(ctx.ui)
|
|
1124
1154
|
}
|
|
1125
1155
|
manager.clearCompleted(true)
|
|
1126
1156
|
// Guard mirrors the `!scheduler.isActive()` pattern below: session_start
|
|
@@ -1147,7 +1177,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1147
1177
|
if (record.status === "running" || record.status === "queued")
|
|
1148
1178
|
return false
|
|
1149
1179
|
record.resultConsumed = true
|
|
1150
|
-
|
|
1180
|
+
outbox.cancel(record.id)
|
|
1151
1181
|
return true
|
|
1152
1182
|
},
|
|
1153
1183
|
},
|
|
@@ -1440,8 +1470,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1440
1470
|
}
|
|
1441
1471
|
|
|
1442
1472
|
try {
|
|
1443
|
-
// Nothing else to pass: runAgent resolves model, thinking
|
|
1444
|
-
// from the agent's own config when the spawn omits them, and the
|
|
1473
|
+
// Nothing else to pass: runAgent resolves model, thinking, max turns and
|
|
1474
|
+
// `isolated` from the agent's own config when the spawn omits them, and the
|
|
1445
1475
|
// manager's onStart/onComplete callbacks own the widget, the fleet list
|
|
1446
1476
|
// and the completion notification — the same contract the scheduler and
|
|
1447
1477
|
// cross-extension RPC spawns run under.
|
|
@@ -1491,9 +1521,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
1491
1521
|
for (const task of workflowTasks.values()) task.abortController.abort()
|
|
1492
1522
|
workflowTasks.clear()
|
|
1493
1523
|
manager.abortAll()
|
|
1494
|
-
|
|
1495
|
-
|
|
1524
|
+
// Activation-owned UI and group state (#256): `session_shutdown` also runs for
|
|
1525
|
+
// reload and session replacement, where the Node process survives, so a live
|
|
1526
|
+
// widget render interval or a pending group-join timeout from this activation
|
|
1527
|
+
// would outlive it and fire into the next session's UI and group state.
|
|
1528
|
+
// `session_before_switch` deliberately keeps both: a vetoed switch continues
|
|
1529
|
+
// this session, where the live UI and group state must survive.
|
|
1530
|
+
groupJoin.dispose()
|
|
1531
|
+
widget.dispose()
|
|
1532
|
+
outbox.dispose()
|
|
1496
1533
|
fleet.dispose()
|
|
1534
|
+
unsubHandoffKey?.()
|
|
1535
|
+
unsubHandoffKey = undefined
|
|
1536
|
+
handoffKeyUi = undefined
|
|
1497
1537
|
// Awaited: it emits `session_shutdown` into every retained child session so
|
|
1498
1538
|
// extensions bound there can release what they armed in `session_start` (#242).
|
|
1499
1539
|
// pi awaits this handler, and the process exits right after — unawaited, those
|
|
@@ -1522,9 +1562,105 @@ export default function (pi: ExtensionAPI) {
|
|
|
1522
1562
|
widget.update()
|
|
1523
1563
|
}
|
|
1524
1564
|
|
|
1565
|
+
/**
|
|
1566
|
+
* Foreground `Agent` calls whose run can be handed to the background, by
|
|
1567
|
+
* agent id. Each entry wakes its blocked tool call, which then returns as a
|
|
1568
|
+
* background spawn would. Only the Agent tool registers here, so a spawn with
|
|
1569
|
+
* no tool call to return — the agent generator, a workflow's agents — never
|
|
1570
|
+
* offers the handoff.
|
|
1571
|
+
*/
|
|
1572
|
+
const foregroundHandoffs = new Map<string, () => void>()
|
|
1573
|
+
|
|
1574
|
+
/** Hand these runs to the background and wake their tool calls; returns how many took. */
|
|
1575
|
+
function sendForegroundToBackground(ids: Iterable<string>): number {
|
|
1576
|
+
let sent = 0
|
|
1577
|
+
for (const id of [...ids]) {
|
|
1578
|
+
const wake = foregroundHandoffs.get(id)
|
|
1579
|
+
if (!wake || !manager.sendToBackground(id)) continue
|
|
1580
|
+
foregroundHandoffs.delete(id)
|
|
1581
|
+
wake()
|
|
1582
|
+
sent++
|
|
1583
|
+
// It is a background agent from here on, so announce it as one — as a
|
|
1584
|
+
// detached resume re-announces an existing id.
|
|
1585
|
+
const record = manager.getRecord(id)
|
|
1586
|
+
if (record) {
|
|
1587
|
+
pi.events.emit("subagents:created", {
|
|
1588
|
+
id,
|
|
1589
|
+
type: record.type,
|
|
1590
|
+
description: record.description,
|
|
1591
|
+
isBackground: true,
|
|
1592
|
+
})
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
if (sent > 0) {
|
|
1596
|
+
widget.ensureTimer()
|
|
1597
|
+
widget.update()
|
|
1598
|
+
fleet.ensureTimer()
|
|
1599
|
+
fleet.update()
|
|
1600
|
+
}
|
|
1601
|
+
return sent
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
/** The conversation viewer's `b`: hand just this run to the background. */
|
|
1605
|
+
function backgroundActionFor(
|
|
1606
|
+
record: AgentRecord,
|
|
1607
|
+
notify: (message: string) => void,
|
|
1608
|
+
): ViewerBackgroundAction {
|
|
1609
|
+
return {
|
|
1610
|
+
// Registered while the run is live, removed the moment its call returns.
|
|
1611
|
+
available: () => foregroundHandoffs.has(record.id),
|
|
1612
|
+
send: () => {
|
|
1613
|
+
if (sendForegroundToBackground([record.id]) > 0)
|
|
1614
|
+
notify(`Sent "${record.description}" to the background.`)
|
|
1615
|
+
},
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
// Claude Code's ctrl+b (`task:background`): hand every running foreground
|
|
1620
|
+
// `Agent` call to the background. Claimed only while one is pending, so the
|
|
1621
|
+
// rest of the time — and whenever the prompt editor is not focused — the key
|
|
1622
|
+
// is still pi's cursor-left.
|
|
1623
|
+
let handoffKeyUi: ExtensionContext["ui"] | undefined
|
|
1624
|
+
let unsubHandoffKey: (() => void) | undefined
|
|
1625
|
+
function watchHandoffKey(ui: ExtensionContext["ui"]): void {
|
|
1626
|
+
if (ui === handoffKeyUi) return
|
|
1627
|
+
unsubHandoffKey?.()
|
|
1628
|
+
handoffKeyUi = ui
|
|
1629
|
+
unsubHandoffKey = ui.onTerminalInput((data) => {
|
|
1630
|
+
// Kitty reports a release too, and matchesKey matches it — act on the
|
|
1631
|
+
// press only, or a refused handoff would be retried by its own release.
|
|
1632
|
+
if (
|
|
1633
|
+
isKeyRelease(data) ||
|
|
1634
|
+
foregroundHandoffs.size === 0 ||
|
|
1635
|
+
!matchesKey(data, "ctrl+b")
|
|
1636
|
+
)
|
|
1637
|
+
return undefined
|
|
1638
|
+
// Listeners run before the focused component, so a dialog or the
|
|
1639
|
+
// viewer's composer would lose its ctrl+b (cursor-left). The viewer has
|
|
1640
|
+
// its own `b`.
|
|
1641
|
+
if (!fleet.editorHasFocus()) return undefined
|
|
1642
|
+
const sent = sendForegroundToBackground(foregroundHandoffs.keys())
|
|
1643
|
+
if (sent > 0)
|
|
1644
|
+
ui.notify(
|
|
1645
|
+
`Sent ${sent === 1 ? "1 agent" : `${sent} agents`} to the background.`,
|
|
1646
|
+
"info",
|
|
1647
|
+
)
|
|
1648
|
+
return { consume: true }
|
|
1649
|
+
})
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* pi's fullscreen scrollbar preference, read from the main session's live,
|
|
1654
|
+
* trust-aware settings and normalized as pi's own `getFullscreenScrollbar()` does.
|
|
1655
|
+
*/
|
|
1656
|
+
function getFullscreenScrollbar(): ScrollViewScrollbar {
|
|
1657
|
+
const mode = pi.getSettings().fullscreenScrollbar
|
|
1658
|
+
return mode === "always" || mode === "hidden" ? mode : "auto"
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1525
1661
|
// Claude Code-style FleetView: navigable list of main + subagents below the editor.
|
|
1526
|
-
// The
|
|
1527
|
-
// one opened from `/agents`: same
|
|
1662
|
+
// The trailing arguments keep a conversation overlay opened here identical to
|
|
1663
|
+
// one opened from `/agents`: same settings on the way in, same persist out.
|
|
1528
1664
|
const fleet = new FleetList(
|
|
1529
1665
|
manager,
|
|
1530
1666
|
agentActivity,
|
|
@@ -1537,6 +1673,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1537
1673
|
// narrows its structural UI surface to the optional warning context.
|
|
1538
1674
|
currentCtx as unknown as ExtensionCommandContext | undefined,
|
|
1539
1675
|
),
|
|
1676
|
+
getFullscreenScrollbar,
|
|
1677
|
+
backgroundActionFor,
|
|
1540
1678
|
)
|
|
1541
1679
|
let fleetViewEnabled = true
|
|
1542
1680
|
function isFleetViewEnabled(): boolean {
|
|
@@ -1652,47 +1790,43 @@ export default function (pi: ExtensionAPI) {
|
|
|
1652
1790
|
toolDescriptionMode = mode
|
|
1653
1791
|
}
|
|
1654
1792
|
|
|
1655
|
-
// ----
|
|
1656
|
-
//
|
|
1657
|
-
//
|
|
1658
|
-
//
|
|
1659
|
-
//
|
|
1660
|
-
let
|
|
1661
|
-
let
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
const record = manager.getRecord(id)
|
|
1693
|
-
if (record?.completedAt != null && !record.resultConsumed) {
|
|
1694
|
-
sendIndividualNudge(record)
|
|
1695
|
-
}
|
|
1793
|
+
// ---- Turn batches for `group` join mode ----
|
|
1794
|
+
// Background spawns the parent made in its current turn. The turn's end is
|
|
1795
|
+
// the batch boundary: every `Agent` call of the batch has returned by then,
|
|
1796
|
+
// so a spawn whose startup failed has already thrown and was never recorded,
|
|
1797
|
+
// and a slow worktree copy cannot split the batch.
|
|
1798
|
+
let turnGroupSpawns: string[] = []
|
|
1799
|
+
let turnGroupCounter = 0
|
|
1800
|
+
|
|
1801
|
+
/** Record a `group`-mode spawn made inside a parent turn. */
|
|
1802
|
+
function joinTurnGroup(id: string, joinMode: JoinMode | undefined) {
|
|
1803
|
+
// Outside a parent turn (an `@handle` mention) no turn end is coming, and a
|
|
1804
|
+
// group of one is delivered exactly like a single completion.
|
|
1805
|
+
if (joinMode === "group" && currentCtx?.signal !== undefined)
|
|
1806
|
+
turnGroupSpawns.push(id)
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
/** Register the ending turn's `group` spawns, two or more, as one group. */
|
|
1810
|
+
function registerTurnGroup() {
|
|
1811
|
+
const ids = turnGroupSpawns
|
|
1812
|
+
turnGroupSpawns = []
|
|
1813
|
+
// One spawn is no group: it reports like any single completion.
|
|
1814
|
+
if (ids.length < 2) {
|
|
1815
|
+
const record = ids.length === 1 ? manager.getRecord(ids[0]) : undefined
|
|
1816
|
+
if (record?.completedAt != null && !record.resultConsumed)
|
|
1817
|
+
sendIndividualNudge(record)
|
|
1818
|
+
return
|
|
1819
|
+
}
|
|
1820
|
+
const groupId = `turn-${++turnGroupCounter}`
|
|
1821
|
+
groupJoin.registerGroup(groupId, ids)
|
|
1822
|
+
// Members that finished before their turn ended had their completion
|
|
1823
|
+
// deferred (see the manager's onComplete); feed them in now.
|
|
1824
|
+
for (const id of ids) {
|
|
1825
|
+
const record = manager.getRecord(id)
|
|
1826
|
+
if (!record) continue
|
|
1827
|
+
record.groupId = groupId
|
|
1828
|
+
if (record.completedAt != null && !record.resultConsumed) {
|
|
1829
|
+
groupJoin.onAgentComplete(record)
|
|
1696
1830
|
}
|
|
1697
1831
|
}
|
|
1698
1832
|
}
|
|
@@ -1773,11 +1907,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1773
1907
|
})
|
|
1774
1908
|
if (!record) return undefined
|
|
1775
1909
|
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer)
|
|
1779
|
-
batchFinalizeTimer = setTimeout(finalizeBatch, 100)
|
|
1780
|
-
}
|
|
1910
|
+
// Only a resume from an Agent call belongs to that call's turn.
|
|
1911
|
+
if (opts.toolCallId !== undefined) joinTurnGroup(id, joinMode)
|
|
1781
1912
|
|
|
1782
1913
|
agentActivity.set(id, bgState)
|
|
1783
1914
|
// This agent already finished once, so the widget holds a finished-age
|
|
@@ -1809,9 +1940,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
1809
1940
|
// SAFETY: Pi's UI context implements the FleetUICtx structural subset;
|
|
1810
1941
|
// this assertion exposes only the methods FleetList calls.
|
|
1811
1942
|
fleet.setUICtx(ctx.ui as unknown as FleetUICtx)
|
|
1943
|
+
watchHandoffKey(ctx.ui)
|
|
1812
1944
|
widget.onTurnStart()
|
|
1813
1945
|
})
|
|
1814
1946
|
|
|
1947
|
+
// The parent's tool batch is done and its next model call not yet made: the
|
|
1948
|
+
// boundary where completions are delivered, as in Claude Code.
|
|
1949
|
+
pi.on("turn_end", (event) => {
|
|
1950
|
+
registerTurnGroup()
|
|
1951
|
+
outbox.onTurnEnd(
|
|
1952
|
+
event.message.role === "assistant" ? event.message.stopReason : undefined,
|
|
1953
|
+
)
|
|
1954
|
+
})
|
|
1955
|
+
pi.on("agent_end", () => outbox.onAgentEnd())
|
|
1956
|
+
pi.on("agent_settled", () => outbox.onAgentEnd())
|
|
1957
|
+
|
|
1815
1958
|
/** Build the full type list text dynamically from available agents only. */
|
|
1816
1959
|
const buildTypeListText = () => {
|
|
1817
1960
|
const available = getAvailableTypes()
|
|
@@ -1882,6 +2025,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1882
2025
|
setShowCost,
|
|
1883
2026
|
setShowModel,
|
|
1884
2027
|
setViewerMarkdown,
|
|
2028
|
+
setPackageAgents: setPackageAgentsGate,
|
|
2029
|
+
setPackageWorkflows: setPackageWorkflowsGate,
|
|
1885
2030
|
},
|
|
1886
2031
|
(event, payload) => pi.events.emit(event, payload),
|
|
1887
2032
|
)
|
|
@@ -2135,11 +2280,16 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2135
2280
|
)
|
|
2136
2281
|
: ""
|
|
2137
2282
|
const desc = args.description ?? ""
|
|
2138
|
-
const name = renderAgentName(
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2283
|
+
const name = renderAgentName(
|
|
2284
|
+
args.subagent_type,
|
|
2285
|
+
theme,
|
|
2286
|
+
{
|
|
2287
|
+
fallbackColor: "toolTitle",
|
|
2288
|
+
restoreBackground: rowBackground,
|
|
2289
|
+
bold: true,
|
|
2290
|
+
},
|
|
2291
|
+
args.name,
|
|
2292
|
+
)
|
|
2143
2293
|
return new Text(
|
|
2144
2294
|
rowBackground +
|
|
2145
2295
|
"▸ " +
|
|
@@ -2185,12 +2335,27 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2185
2335
|
if (isPartial || details.status === "running") {
|
|
2186
2336
|
const frame = SPINNER[details.spinnerFrame ?? 0]
|
|
2187
2337
|
const s = stats(details)
|
|
2188
|
-
|
|
2338
|
+
const status = renderRunningAgentStatus(
|
|
2189
2339
|
frame,
|
|
2190
2340
|
s,
|
|
2191
2341
|
details.activity ?? "thinking…",
|
|
2192
2342
|
theme,
|
|
2193
2343
|
)
|
|
2344
|
+
// Claude Code's hint, after the same 2s: a call that finishes quickly
|
|
2345
|
+
// never flashes it.
|
|
2346
|
+
if (
|
|
2347
|
+
details.backgroundHint &&
|
|
2348
|
+
details.durationMs >= BACKGROUND_HINT_DELAY_MS
|
|
2349
|
+
) {
|
|
2350
|
+
status.addChild(
|
|
2351
|
+
new Text(
|
|
2352
|
+
theme.fg("dim", " (ctrl+b to run in background)"),
|
|
2353
|
+
0,
|
|
2354
|
+
0,
|
|
2355
|
+
),
|
|
2356
|
+
)
|
|
2357
|
+
}
|
|
2358
|
+
return status
|
|
2194
2359
|
}
|
|
2195
2360
|
|
|
2196
2361
|
// ---- Background agent launched ----
|
|
@@ -2604,6 +2769,37 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2604
2769
|
)
|
|
2605
2770
|
}
|
|
2606
2771
|
|
|
2772
|
+
// What the model sees for a run it no longer waits on — spawned in the
|
|
2773
|
+
// background, or handed there mid-run (ctrl+b / viewer `b`). One text for
|
|
2774
|
+
// both, as in Claude Code: from here on they are the same kind of agent.
|
|
2775
|
+
const backgroundResult = (
|
|
2776
|
+
id: string,
|
|
2777
|
+
record: AgentRecord | undefined,
|
|
2778
|
+
) => {
|
|
2779
|
+
const isQueued = record?.status === "queued"
|
|
2780
|
+
return textResult(
|
|
2781
|
+
`${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
|
|
2782
|
+
`Agent ID: ${id}\n` +
|
|
2783
|
+
`Type: ${displayName}\n` +
|
|
2784
|
+
`Description: ${params.description}\n` +
|
|
2785
|
+
(record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
2786
|
+
(isQueued
|
|
2787
|
+
? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n`
|
|
2788
|
+
: "") +
|
|
2789
|
+
`\nYou will be notified when this agent completes.\n` +
|
|
2790
|
+
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
|
|
2791
|
+
`Do not duplicate this agent's work.`,
|
|
2792
|
+
{
|
|
2793
|
+
...detailBaseFor(record),
|
|
2794
|
+
toolUses: 0,
|
|
2795
|
+
tokens: "",
|
|
2796
|
+
durationMs: 0,
|
|
2797
|
+
status: "background" as const,
|
|
2798
|
+
agentId: id,
|
|
2799
|
+
},
|
|
2800
|
+
)
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2607
2803
|
// Background execution
|
|
2608
2804
|
if (runInBackground) {
|
|
2609
2805
|
const { state: bgState, callbacks: bgCallbacks } =
|
|
@@ -2627,9 +2823,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2627
2823
|
}
|
|
2628
2824
|
}
|
|
2629
2825
|
|
|
2630
|
-
// A throw here means the agent never started. Let it out: pi marks
|
|
2631
|
-
// tool call failed
|
|
2632
|
-
//
|
|
2826
|
+
// A throw here means the agent never started. Let it out: pi marks the
|
|
2827
|
+
// tool call failed, whereas a returned message reads to the model as a
|
|
2828
|
+
// subagent that ran and reported this (#179).
|
|
2633
2829
|
id = manager.spawn(pi, ctx, subagentType, params.prompt, {
|
|
2634
2830
|
description: params.description,
|
|
2635
2831
|
name: params.name as string | undefined,
|
|
@@ -2661,16 +2857,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2661
2857
|
// call instead of being reported as a subagent that ran (#179).
|
|
2662
2858
|
await manager.awaitStartup(id)
|
|
2663
2859
|
|
|
2664
|
-
|
|
2665
|
-
// Foreground/no join mode or explicit async — not part of any batch
|
|
2666
|
-
} else {
|
|
2667
|
-
// smart or group — add to current batch
|
|
2668
|
-
currentBatchAgents.push({ id, joinMode })
|
|
2669
|
-
// Debounce: reset timer on each new agent so parallel tool calls
|
|
2670
|
-
// dispatched across multiple event loop ticks are captured together
|
|
2671
|
-
if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer)
|
|
2672
|
-
batchFinalizeTimer = setTimeout(finalizeBatch, 100)
|
|
2673
|
-
}
|
|
2860
|
+
joinTurnGroup(id, joinMode)
|
|
2674
2861
|
|
|
2675
2862
|
agentActivity.set(id, bgState)
|
|
2676
2863
|
widget.ensureTimer()
|
|
@@ -2686,40 +2873,27 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2686
2873
|
isBackground: true,
|
|
2687
2874
|
})
|
|
2688
2875
|
|
|
2689
|
-
|
|
2690
|
-
return textResult(
|
|
2691
|
-
`${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
|
|
2692
|
-
`Agent ID: ${id}\n` +
|
|
2693
|
-
`Type: ${displayName}\n` +
|
|
2694
|
-
`Description: ${params.description}\n` +
|
|
2695
|
-
(record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
2696
|
-
(isQueued
|
|
2697
|
-
? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n`
|
|
2698
|
-
: "") +
|
|
2699
|
-
`\nYou will be notified when this agent completes.\n` +
|
|
2700
|
-
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
|
|
2701
|
-
`Do not duplicate this agent's work.`,
|
|
2702
|
-
{
|
|
2703
|
-
...detailBaseFor(record),
|
|
2704
|
-
toolUses: 0,
|
|
2705
|
-
tokens: "",
|
|
2706
|
-
durationMs: 0,
|
|
2707
|
-
status: "background" as const,
|
|
2708
|
-
agentId: id,
|
|
2709
|
-
},
|
|
2710
|
-
)
|
|
2876
|
+
return backgroundResult(id, record)
|
|
2711
2877
|
}
|
|
2712
2878
|
|
|
2713
2879
|
// Foreground (synchronous) execution — stream progress via onUpdate
|
|
2714
2880
|
let spinnerFrame = 0
|
|
2715
2881
|
const startedAt = Date.now()
|
|
2716
2882
|
let fgId: string | undefined
|
|
2883
|
+
// Set once the run is live and can be handed to the background — see
|
|
2884
|
+
// `foregroundHandoffs`. Unset while queued: there is no run to hand over.
|
|
2885
|
+
let handoffId: string | undefined
|
|
2886
|
+
// Set by the handoff. From then on `onUpdate` is a dead handle (pi drops
|
|
2887
|
+
// updates once `execute` has returned), so `streamUpdate` stops building
|
|
2888
|
+
// them; the tracker keeps `fgState` live for the widget and FleetView.
|
|
2889
|
+
let backgrounded = false
|
|
2717
2890
|
// Set only while the spawn is parked on a foreground concurrency slot
|
|
2718
2891
|
// (maxConcurrentForeground); undefined the rest of the time, including
|
|
2719
2892
|
// always when the limit is unset.
|
|
2720
2893
|
let queuedAhead: number | undefined
|
|
2721
2894
|
|
|
2722
2895
|
const streamUpdate = () => {
|
|
2896
|
+
if (backgrounded) return
|
|
2723
2897
|
// Spend from the record, everything else from the live tracker. `fgId`
|
|
2724
2898
|
// is set in onSessionCreated below, which fires before the first
|
|
2725
2899
|
// assistant message — so nothing is spent while this reads zero.
|
|
@@ -2743,6 +2917,11 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2743
2917
|
? describeActivity(fgState.activeTools, fgState.responseText)
|
|
2744
2918
|
: `queued — waiting for a foreground slot${queuedAhead > 0 ? ` (${queuedAhead} ahead)` : ""}`,
|
|
2745
2919
|
spinnerFrame: spinnerFrame % SPINNER.length,
|
|
2920
|
+
// One hint per batch, as in Claude Code: on the earliest call still
|
|
2921
|
+
// pending (a Map iterates in insertion order). ctrl+b takes them all.
|
|
2922
|
+
backgroundHint:
|
|
2923
|
+
handoffId !== undefined &&
|
|
2924
|
+
foregroundHandoffs.keys().next().value === handoffId,
|
|
2746
2925
|
}
|
|
2747
2926
|
onUpdate?.({
|
|
2748
2927
|
content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
|
|
@@ -2800,54 +2979,87 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2800
2979
|
|
|
2801
2980
|
streamUpdate()
|
|
2802
2981
|
|
|
2803
|
-
|
|
2982
|
+
// ctrl+b or the viewer's `b` wakes this with the run already handed over
|
|
2983
|
+
// (`sendForegroundToBackground`), raced against the run below. The
|
|
2984
|
+
// manager only accepts a handoff of a run that has not settled, and the
|
|
2985
|
+
// wake-up resolves synchronously with it, so whichever promise wins is
|
|
2986
|
+
// the truth: the result is never also delivered as a notification.
|
|
2987
|
+
let wakeBackgrounded!: () => void
|
|
2988
|
+
const handedOff = new Promise<undefined>((resolve) => {
|
|
2989
|
+
wakeBackgrounded = () => resolve(undefined)
|
|
2990
|
+
})
|
|
2991
|
+
|
|
2992
|
+
let fgResult: { record: AgentRecord } | undefined
|
|
2804
2993
|
try {
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2994
|
+
fgResult = await Promise.race([
|
|
2995
|
+
manager.spawnAndWait(
|
|
2996
|
+
pi,
|
|
2997
|
+
ctx,
|
|
2998
|
+
subagentType,
|
|
2999
|
+
params.prompt,
|
|
3000
|
+
{
|
|
3001
|
+
description: params.description,
|
|
3002
|
+
name: params.name as string | undefined,
|
|
3003
|
+
model,
|
|
3004
|
+
maxTurns: effectiveMaxTurns,
|
|
3005
|
+
isolated,
|
|
3006
|
+
inheritContext,
|
|
3007
|
+
thinkingLevel: thinking,
|
|
3008
|
+
isolation,
|
|
3009
|
+
invocation: agentInvocation,
|
|
3010
|
+
signal,
|
|
3011
|
+
rootSessionId: ctx.sessionManager.getSessionId(),
|
|
3012
|
+
// Deliberately does NOT set fgId: that drives agentActivity, the
|
|
3013
|
+
// widget and the `finally` cleanup below, none of which should see an
|
|
3014
|
+
// agent that has no session and may never get one.
|
|
3015
|
+
onQueued: (_id, ahead) => {
|
|
3016
|
+
queuedAhead = ahead
|
|
3017
|
+
streamUpdate()
|
|
3018
|
+
},
|
|
3019
|
+
...fgCallbacks,
|
|
2828
3020
|
},
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
3021
|
+
(fgAgentId) => {
|
|
3022
|
+
// onSpawned: called synchronously after spawn, before onSessionCreated fires.
|
|
3023
|
+
// Set up the output file so streamToOutputFile can pick it up.
|
|
3024
|
+
const fgRec = manager.getRecord(fgAgentId)
|
|
3025
|
+
attachTranscript(fgRec, fgAgentId)
|
|
3026
|
+
// Fires once the run promise exists, i.e. the agent is running. From
|
|
3027
|
+
// here until this call returns, the run can be handed off.
|
|
3028
|
+
handoffId = fgAgentId
|
|
3029
|
+
foregroundHandoffs.set(fgAgentId, () => {
|
|
3030
|
+
// What a background spawn sets up front: the notification names
|
|
3031
|
+
// this tool call, and it reports alone — its spawn batch is long
|
|
3032
|
+
// closed.
|
|
3033
|
+
if (fgRec) {
|
|
3034
|
+
fgRec.toolCallId = toolCallId
|
|
3035
|
+
fgRec.joinMode = "async"
|
|
3036
|
+
}
|
|
3037
|
+
backgrounded = true
|
|
3038
|
+
wakeBackgrounded()
|
|
3039
|
+
})
|
|
3040
|
+
},
|
|
3041
|
+
),
|
|
3042
|
+
handedOff,
|
|
3043
|
+
])
|
|
2839
3044
|
} finally {
|
|
2840
3045
|
// Runs on both paths, so a startup throw — which now propagates, see
|
|
2841
3046
|
// the background spawn above (#179) — no longer leaves the spinner
|
|
2842
3047
|
// ticking or a finished agent on the widget.
|
|
2843
3048
|
clearInterval(spinnerInterval)
|
|
2844
|
-
if (
|
|
3049
|
+
if (handoffId !== undefined) foregroundHandoffs.delete(handoffId)
|
|
3050
|
+
// A handed-off run is still working: its row stays, and the
|
|
3051
|
+
// background settle in `onComplete` tears it down when it really ends.
|
|
3052
|
+
if (fgId && !backgrounded) {
|
|
2845
3053
|
agentActivity.delete(fgId)
|
|
2846
3054
|
widget.markFinished(fgId)
|
|
2847
3055
|
fleet.onAgentFinished(fgId)
|
|
2848
3056
|
}
|
|
2849
3057
|
}
|
|
2850
3058
|
|
|
3059
|
+
if (fgResult === undefined)
|
|
3060
|
+
return backgroundResult(handoffId!, manager.getRecord(handoffId!))
|
|
3061
|
+
const record = fgResult.record
|
|
3062
|
+
|
|
2851
3063
|
// Get final token count — from the record, like the cost below it, so the
|
|
2852
3064
|
// two describe the same work when the agent delegated to nested children.
|
|
2853
3065
|
const tokenText = formatLifetimeTokens(record)
|
|
@@ -2995,41 +3207,35 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2995
3207
|
|
|
2996
3208
|
/**
|
|
2997
3209
|
* Hand a finished run back to the model through the SAME channel a background
|
|
2998
|
-
* agent uses —
|
|
2999
|
-
*
|
|
3210
|
+
* agent uses — the completion outbox, which delivers at the parent's next
|
|
3211
|
+
* boundary — rendered by the existing `subagent-notification` renderer.
|
|
3000
3212
|
*/
|
|
3001
3213
|
function notifyWorkflowFinished(task: WorkflowTask) {
|
|
3002
3214
|
widget.update()
|
|
3003
3215
|
fleet.update()
|
|
3004
3216
|
const result = workflowResultText(task)
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
result.length > 500 ? `${result.slice(0, 500)}…` : result,
|
|
3028
|
-
},
|
|
3029
|
-
},
|
|
3030
|
-
{ deliverAs: "followUp", triggerTurn: true },
|
|
3031
|
-
)
|
|
3032
|
-
})
|
|
3217
|
+
const message = {
|
|
3218
|
+
content: formatWorkflowNotification(task),
|
|
3219
|
+
details: {
|
|
3220
|
+
id: task.id,
|
|
3221
|
+
description: `Workflow ${task.workflowName ?? task.id}`,
|
|
3222
|
+
status:
|
|
3223
|
+
task.status === "completed"
|
|
3224
|
+
? "completed"
|
|
3225
|
+
: task.status === "killed"
|
|
3226
|
+
? "stopped"
|
|
3227
|
+
: "error",
|
|
3228
|
+
toolUses: task.totalToolCalls,
|
|
3229
|
+
// A workflow has agents, not turns; rendering "↻0" would be noise.
|
|
3230
|
+
turnCount: 0,
|
|
3231
|
+
totalTokens: task.totalTokens,
|
|
3232
|
+
durationMs: elapsedMs(task, Date.now()),
|
|
3233
|
+
error: task.error,
|
|
3234
|
+
resultPreview:
|
|
3235
|
+
result.length > 500 ? `${result.slice(0, 500)}…` : result,
|
|
3236
|
+
},
|
|
3237
|
+
}
|
|
3238
|
+
outbox.add({ ids: [task.id], build: () => message })
|
|
3033
3239
|
}
|
|
3034
3240
|
|
|
3035
3241
|
// Defined unconditionally, registered only when the feature is on — the same
|
|
@@ -3492,7 +3698,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3492
3698
|
// Mark result as consumed — suppresses the completion notification
|
|
3493
3699
|
if (record.status !== "running" && record.status !== "queued") {
|
|
3494
3700
|
record.resultConsumed = true
|
|
3495
|
-
|
|
3701
|
+
outbox.cancel(record.id)
|
|
3496
3702
|
}
|
|
3497
3703
|
|
|
3498
3704
|
// Verbose: include full conversation
|
|
@@ -3695,6 +3901,16 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3695
3901
|
}
|
|
3696
3902
|
}
|
|
3697
3903
|
|
|
3904
|
+
/**
|
|
3905
|
+
* The package that provided an agent, for display only. Undefined for every
|
|
3906
|
+
* other source, and for a package path no currently-admitted package owns —
|
|
3907
|
+
* which is what a stale `sourcePath` looks like after the gate narrows.
|
|
3908
|
+
*/
|
|
3909
|
+
function packageNameOf(cfg: AgentConfig | undefined): string | undefined {
|
|
3910
|
+
if (cfg?.source !== "package" || !cfg.sourcePath) return undefined
|
|
3911
|
+
return packageNameForPath(cfg.sourcePath, process.cwd())
|
|
3912
|
+
}
|
|
3913
|
+
|
|
3698
3914
|
async function showAllAgentsList(ctx: ExtensionCommandContext) {
|
|
3699
3915
|
const allNames = getAllTypes()
|
|
3700
3916
|
if (allNames.length === 0) {
|
|
@@ -3702,44 +3918,28 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3702
3918
|
return
|
|
3703
3919
|
}
|
|
3704
3920
|
|
|
3705
|
-
|
|
3706
|
-
// Disabled agents get ✕ prefix
|
|
3707
|
-
const sourceIndicator = (cfg: AgentConfig | undefined) => {
|
|
3708
|
-
const disabled = cfg?.enabled === false
|
|
3709
|
-
if (cfg?.source === "project") return disabled ? "✕• " : "• "
|
|
3710
|
-
if (cfg?.source === "global") return disabled ? "✕◦ " : "◦ "
|
|
3711
|
-
if (disabled) return "✕ "
|
|
3712
|
-
return " "
|
|
3713
|
-
}
|
|
3921
|
+
const configs = allNames.map((name) => getAgentConfig(name))
|
|
3714
3922
|
|
|
3715
3923
|
// One row per agent (name in the left column, model on the right); the
|
|
3716
3924
|
// full description renders below the highlighted row via SettingsList,
|
|
3717
3925
|
// exactly like the Settings menu — so long descriptions never wrap the list.
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3926
|
+
// A package agent's description is prefixed with the package that provided
|
|
3927
|
+
// it: the `▪` badge says a package won, not which one.
|
|
3928
|
+
const items: SettingItem[] = allNames.map((name, i) => {
|
|
3929
|
+
const cfg = configs[i]
|
|
3721
3930
|
const model = getModelLabel(name, ctx.modelRegistry)
|
|
3722
3931
|
return {
|
|
3723
3932
|
id: name,
|
|
3724
3933
|
label: `${sourceIndicator(cfg)}${name}`,
|
|
3725
3934
|
currentValue: model,
|
|
3726
|
-
description:
|
|
3935
|
+
description: rowDescription(name, cfg, packageNameOf(cfg)),
|
|
3727
3936
|
// Single-value list so Enter "activates" the row (fires onChange with the
|
|
3728
3937
|
// agent's id) without offering anything to actually cycle.
|
|
3729
3938
|
values: [model],
|
|
3730
3939
|
}
|
|
3731
3940
|
})
|
|
3732
3941
|
|
|
3733
|
-
const
|
|
3734
|
-
const c = getAgentConfig(n)
|
|
3735
|
-
return c && !c.isDefault && c.enabled !== false
|
|
3736
|
-
})
|
|
3737
|
-
const hasDisabled = allNames.some(
|
|
3738
|
-
(n) => getAgentConfig(n)?.enabled === false,
|
|
3739
|
-
)
|
|
3740
|
-
const legendParts: string[] = []
|
|
3741
|
-
if (hasCustom) legendParts.push("• = project ◦ = global")
|
|
3742
|
-
if (hasDisabled) legendParts.push("✕ = disabled")
|
|
3942
|
+
const legend = sourceLegend(configs)
|
|
3743
3943
|
|
|
3744
3944
|
const selected = await ctx.ui.custom<string | undefined>(
|
|
3745
3945
|
(_tui, _theme, _kb, done) => {
|
|
@@ -3753,10 +3953,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3753
3953
|
)
|
|
3754
3954
|
const container = new Container()
|
|
3755
3955
|
container.addChild(new Text("Agent types", 0, 0))
|
|
3756
|
-
if (
|
|
3757
|
-
container.addChild(
|
|
3758
|
-
new Text(slTheme.hint(legendParts.join(" ")), 0, 0),
|
|
3759
|
-
)
|
|
3956
|
+
if (legend) container.addChild(new Text(slTheme.hint(legend), 0, 0))
|
|
3760
3957
|
container.addChild(new Spacer(1))
|
|
3761
3958
|
container.addChild(list)
|
|
3762
3959
|
return {
|
|
@@ -3807,13 +4004,13 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3807
4004
|
return
|
|
3808
4005
|
}
|
|
3809
4006
|
|
|
3810
|
-
const { ConversationViewer, VIEWPORT_HEIGHT_PCT } =
|
|
3811
|
-
await import("./ui/conversation-viewer.js")
|
|
3812
4007
|
const session = record.session
|
|
3813
4008
|
const activity = agentActivity.get(record.id)
|
|
4009
|
+
let overlayOptions = getConversationOverlayOptions({ mode: "regular" })
|
|
3814
4010
|
|
|
3815
4011
|
await ctx.ui.custom<undefined>(
|
|
3816
4012
|
(tui, theme, keybindings, done) => {
|
|
4013
|
+
overlayOptions = getConversationOverlayOptions(tui)
|
|
3817
4014
|
return new ConversationViewer(
|
|
3818
4015
|
tui,
|
|
3819
4016
|
session,
|
|
@@ -3831,15 +4028,15 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3831
4028
|
showCost,
|
|
3832
4029
|
getViewerMarkdown,
|
|
3833
4030
|
(mode) => chooseViewerMarkdown(mode, ctx),
|
|
4031
|
+
getFullscreenScrollbar(),
|
|
4032
|
+
backgroundActionFor(record, (message) =>
|
|
4033
|
+
ctx.ui.notify(message, "info"),
|
|
4034
|
+
),
|
|
3834
4035
|
)
|
|
3835
4036
|
},
|
|
3836
4037
|
{
|
|
3837
4038
|
overlay: true,
|
|
3838
|
-
overlayOptions:
|
|
3839
|
-
anchor: "center",
|
|
3840
|
-
width: "90%",
|
|
3841
|
-
maxHeight: `${VIEWPORT_HEIGHT_PCT}%`,
|
|
3842
|
-
},
|
|
4039
|
+
overlayOptions: () => overlayOptions,
|
|
3843
4040
|
},
|
|
3844
4041
|
)
|
|
3845
4042
|
}
|
|
@@ -3851,12 +4048,23 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3851
4048
|
return
|
|
3852
4049
|
}
|
|
3853
4050
|
|
|
3854
|
-
const
|
|
4051
|
+
const isPackage = cfg.source === "package"
|
|
4052
|
+
const file = locateAgentFile(name, cfg.sourcePath, undefined, cfg.source)
|
|
3855
4053
|
const isDefault = cfg.isDefault === true
|
|
3856
4054
|
const disabled = cfg.enabled === false
|
|
3857
4055
|
|
|
3858
4056
|
let menuOptions: string[]
|
|
3859
|
-
if (
|
|
4057
|
+
if (isPackage) {
|
|
4058
|
+
// Read-only: nothing here may write inside pi's install root. `Disable`
|
|
4059
|
+
// still appears because it writes a *local* stub that shadows the package
|
|
4060
|
+
// agent, and `Enable` never does — a package agent that reads as disabled
|
|
4061
|
+
// was disabled by its own author's `enabled: false`, which only the
|
|
4062
|
+
// package can take back. Eject is the way out of that: the copy it writes
|
|
4063
|
+
// carries no `enabled:` line, so the ejected agent is on.
|
|
4064
|
+
menuOptions = disabled
|
|
4065
|
+
? ["Eject (export as .md)", "Back"]
|
|
4066
|
+
: ["Eject (export as .md)", "Disable", "Back"]
|
|
4067
|
+
} else if (disabled && file) {
|
|
3860
4068
|
// Disabled agent with a file — offer Enable
|
|
3861
4069
|
menuOptions = isDefault
|
|
3862
4070
|
? ["Enable", "Edit", "Reset to default", "Delete", "Back"]
|
|
@@ -3872,7 +4080,14 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3872
4080
|
menuOptions = ["Edit", "Disable", "Delete", "Back"]
|
|
3873
4081
|
}
|
|
3874
4082
|
|
|
3875
|
-
|
|
4083
|
+
// Name the package in the title: this menu's options are the read-only set,
|
|
4084
|
+
// and "which package do I have to uninstall or allowlist away" is the
|
|
4085
|
+
// question they raise.
|
|
4086
|
+
const pkgName = packageNameOf(cfg)
|
|
4087
|
+
const choice = await ctx.ui.select(
|
|
4088
|
+
pkgName ? `${name} — ${pkgName}` : name,
|
|
4089
|
+
menuOptions,
|
|
4090
|
+
)
|
|
3876
4091
|
if (!choice || choice === "Back") return
|
|
3877
4092
|
|
|
3878
4093
|
if (choice === "Edit" && file) {
|
|
@@ -3951,7 +4166,12 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3951
4166
|
|
|
3952
4167
|
/** Disable an agent: set enabled: false in its .md file, or create a stub for built-in defaults. */
|
|
3953
4168
|
async function disableAgent(ctx: ExtensionCommandContext, name: string) {
|
|
3954
|
-
const
|
|
4169
|
+
const cfg = getAgentConfig(name)
|
|
4170
|
+
// `locateAgentFile` refuses a package agent's own path, so this is either the
|
|
4171
|
+
// local stub already shadowing it or nothing — never a file inside pi's
|
|
4172
|
+
// install root. An unshadowed package agent therefore falls to the stub
|
|
4173
|
+
// branch below, which writes `enabled: false` at project or personal scope.
|
|
4174
|
+
const file = locateAgentFile(name, cfg?.sourcePath, undefined, cfg?.source)
|
|
3955
4175
|
if (file) {
|
|
3956
4176
|
// Existing file — set enabled: false in frontmatter (idempotent)
|
|
3957
4177
|
const content = readFileSync(file.path, "utf-8")
|
|
@@ -3976,7 +4196,8 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3976
4196
|
return
|
|
3977
4197
|
}
|
|
3978
4198
|
|
|
3979
|
-
// No file (built-in default
|
|
4199
|
+
// No editable file (a built-in default, or a package-provided agent) —
|
|
4200
|
+
// create a stub that shadows it.
|
|
3980
4201
|
const location = await ctx.ui.select("Choose location", [
|
|
3981
4202
|
"Project (.pi/agents/)",
|
|
3982
4203
|
`Personal (${personalAgentsDir()})`,
|
|
@@ -3997,7 +4218,8 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3997
4218
|
|
|
3998
4219
|
/** Enable a disabled agent by removing enabled: false from its frontmatter. */
|
|
3999
4220
|
async function enableAgent(ctx: ExtensionCommandContext, name: string) {
|
|
4000
|
-
const
|
|
4221
|
+
const cfg = getAgentConfig(name)
|
|
4222
|
+
const file = locateAgentFile(name, cfg?.sourcePath, undefined, cfg?.source)
|
|
4001
4223
|
if (!file) return
|
|
4002
4224
|
|
|
4003
4225
|
const content = readFileSync(file.path, "utf-8")
|
|
@@ -4292,6 +4514,8 @@ Write the file using the write tool. Only write the file, nothing else.`
|
|
|
4292
4514
|
showCost: isShowCostEnabled(),
|
|
4293
4515
|
showModel: isShowModelEnabled(),
|
|
4294
4516
|
viewerMarkdown: getViewerMarkdown(),
|
|
4517
|
+
packageAgents: getPackageAgentsGate(),
|
|
4518
|
+
packageWorkflows: getPackageWorkflowsGate(),
|
|
4295
4519
|
} satisfies SubagentsSettings
|
|
4296
4520
|
}
|
|
4297
4521
|
|
|
@@ -4309,6 +4533,35 @@ Write the file using the write tool. Only write the file, nothing else.`
|
|
|
4309
4533
|
const _settingsSnapshotIsComplete: _NoMissingSettingsKeys = true
|
|
4310
4534
|
void _settingsSnapshotIsComplete
|
|
4311
4535
|
|
|
4536
|
+
/**
|
|
4537
|
+
* Whether a package gate lets anything through. `undefined` is the unset
|
|
4538
|
+
* default and reads as `true`; an empty allowlist matches nothing, like
|
|
4539
|
+
* `false`.
|
|
4540
|
+
*/
|
|
4541
|
+
function gateAdmitsAnything(
|
|
4542
|
+
gate: ReturnType<typeof getPackageAgentsGate>,
|
|
4543
|
+
): boolean {
|
|
4544
|
+
if (gate === undefined || gate === true) return true
|
|
4545
|
+
if (gate === false) return false
|
|
4546
|
+
return gate.length > 0
|
|
4547
|
+
}
|
|
4548
|
+
|
|
4549
|
+
/**
|
|
4550
|
+
* Settings-row description for a package gate, naming the configured allowlist
|
|
4551
|
+
* when there is one. The row itself is on/off — an allowlist is hand-written in
|
|
4552
|
+
* `subagents.json` — so the description is the only place it is visible, and a
|
|
4553
|
+
* user about to toggle the row can see what they are about to replace.
|
|
4554
|
+
*/
|
|
4555
|
+
function describeGateSetting(
|
|
4556
|
+
gate: ReturnType<typeof getPackageAgentsGate>,
|
|
4557
|
+
base: string,
|
|
4558
|
+
): string {
|
|
4559
|
+
if (!Array.isArray(gate)) return base
|
|
4560
|
+
return gate.length > 0
|
|
4561
|
+
? `${base} Currently limited to: ${gate.join(", ")}.`
|
|
4562
|
+
: `${base} Currently limited to an empty list, so nothing loads.`
|
|
4563
|
+
}
|
|
4564
|
+
|
|
4312
4565
|
const NUMERIC_IDS = new Set([
|
|
4313
4566
|
"maxConcurrent",
|
|
4314
4567
|
"maxConcurrentForeground",
|
|
@@ -4374,9 +4627,11 @@ Write the file using the write tool. Only write the file, nothing else.`
|
|
|
4374
4627
|
{
|
|
4375
4628
|
id: "joinMode",
|
|
4376
4629
|
label: "Join mode",
|
|
4377
|
-
description:
|
|
4378
|
-
|
|
4379
|
-
|
|
4630
|
+
description:
|
|
4631
|
+
"smart = each completion at the parent's next boundary, as in Claude Code; group = one notification per turn's batch",
|
|
4632
|
+
// `async` is an older name for `smart`; settings files may still hold it.
|
|
4633
|
+
currentValue: getDefaultJoinMode() === "group" ? "group" : "smart",
|
|
4634
|
+
values: ["smart", "group"],
|
|
4380
4635
|
},
|
|
4381
4636
|
{
|
|
4382
4637
|
id: "backgroundByDefault",
|
|
@@ -4403,6 +4658,32 @@ Write the file using the write tool. Only write the file, nothing else.`
|
|
|
4403
4658
|
currentValue: isWorkflowsEnabled() ? "on" : "off",
|
|
4404
4659
|
values: ["on", "off"],
|
|
4405
4660
|
},
|
|
4661
|
+
{
|
|
4662
|
+
id: "packageAgents",
|
|
4663
|
+
label: "Package agents",
|
|
4664
|
+
description: describeGateSetting(
|
|
4665
|
+
getPackageAgentsGate(),
|
|
4666
|
+
"Load agents declared by installed pi packages (`pi.subagents.agents` in their package.json). " +
|
|
4667
|
+
"They rank below your global and project agents, so a same-named local file always wins.",
|
|
4668
|
+
),
|
|
4669
|
+
currentValue: gateAdmitsAnything(getPackageAgentsGate())
|
|
4670
|
+
? "on"
|
|
4671
|
+
: "off",
|
|
4672
|
+
values: ["on", "off"],
|
|
4673
|
+
},
|
|
4674
|
+
{
|
|
4675
|
+
id: "packageWorkflows",
|
|
4676
|
+
label: "Package workflows",
|
|
4677
|
+
description: describeGateSetting(
|
|
4678
|
+
getPackageWorkflowsGate(),
|
|
4679
|
+
"Resolve saved workflow names against scripts declared by installed pi packages " +
|
|
4680
|
+
"(`pi.subagents.workflows`). Searched last, after your project, workspace and personal directories.",
|
|
4681
|
+
),
|
|
4682
|
+
currentValue: gateAdmitsAnything(getPackageWorkflowsGate())
|
|
4683
|
+
? "on"
|
|
4684
|
+
: "off",
|
|
4685
|
+
values: ["on", "off"],
|
|
4686
|
+
},
|
|
4406
4687
|
{
|
|
4407
4688
|
id: "scopeModels",
|
|
4408
4689
|
label: "Scope models",
|
|
@@ -4656,6 +4937,31 @@ Write the file using the write tool. Only write the file, nothing else.`
|
|
|
4656
4937
|
ctx,
|
|
4657
4938
|
`Worktree isolation ${enabled ? "enabled" : "disabled"}. Tool parameter updates on next pi session.`,
|
|
4658
4939
|
)
|
|
4940
|
+
} else if (id === "packageAgents" || id === "packageWorkflows") {
|
|
4941
|
+
const enabled = value === "on"
|
|
4942
|
+
const isAgents = id === "packageAgents"
|
|
4943
|
+
const previous = isAgents
|
|
4944
|
+
? getPackageAgentsGate()
|
|
4945
|
+
: getPackageWorkflowsGate()
|
|
4946
|
+
;(isAgents ? setPackageAgentsGate : setPackageWorkflowsGate)(enabled)
|
|
4947
|
+
reloadCustomAgents()
|
|
4948
|
+
const label = isAgents ? "Package agents" : "Package workflows"
|
|
4949
|
+
// An allowlist cannot be expressed by an on/off row, so say plainly that
|
|
4950
|
+
// it is gone rather than letting a toggle quietly widen the gate.
|
|
4951
|
+
const replaced =
|
|
4952
|
+
Array.isArray(previous) && previous.length > 0
|
|
4953
|
+
? ` Replaced the allowlist (${previous.join(", ")}) — re-add it in .pi/subagents.json to narrow it again.`
|
|
4954
|
+
: ""
|
|
4955
|
+
// Same caveat every row that changes the advertised agent set carries:
|
|
4956
|
+
// the `Agent` tool's description and schema are built at registration,
|
|
4957
|
+
// so until the next session the model is reading a stale type list.
|
|
4958
|
+
const specNote = isAgents
|
|
4959
|
+
? " Tool spec change takes effect on next pi session."
|
|
4960
|
+
: ""
|
|
4961
|
+
notifyApplied(
|
|
4962
|
+
ctx,
|
|
4963
|
+
`${label} ${enabled ? "enabled" : "disabled"}.${replaced}${specNote}`,
|
|
4964
|
+
)
|
|
4659
4965
|
} else if (id === "toolDescriptionMode") {
|
|
4660
4966
|
setToolDescriptionMode(value as ToolDescriptionMode)
|
|
4661
4967
|
notifyApplied(
|