@herbertgao/pi-subagents 0.18.5 → 0.22.0-local.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +1 -1
- package/docs/packages.md +211 -0
- package/docs/rpc.md +61 -63
- package/docs/workflows.md +24 -16
- package/package.json +6 -6
- 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 +598 -297
- package/src/invocation-config.ts +1 -1
- package/src/mention-clone.ts +48 -72
- package/src/nested-tools.ts +1 -1
- package/src/output-file.ts +15 -6
- 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 +2 -0
- package/src/ui/conversation-viewer.ts +354 -32
- package/src/ui/fleet-list.ts +33 -15
- package/src/workflow/collisions.ts +4 -1
- package/src/workflow/host.ts +6 -0
- package/src/workflow/json-schema.ts +9 -8
- package/src/workflow/saved.ts +48 -2
package/src/index.ts
CHANGED
|
@@ -28,14 +28,16 @@ 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,
|
|
36
38
|
Text,
|
|
37
39
|
} from "@earendil-works/pi-tui"
|
|
38
|
-
import { Type } from "
|
|
40
|
+
import { Type } from "typebox"
|
|
39
41
|
import { abortable } from "./abortable.js"
|
|
40
42
|
import { hasAgentBadge, renderAgentName } from "./agent-color.js"
|
|
41
43
|
import {
|
|
@@ -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
|
)
|
|
@@ -2185,12 +2330,27 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2185
2330
|
if (isPartial || details.status === "running") {
|
|
2186
2331
|
const frame = SPINNER[details.spinnerFrame ?? 0]
|
|
2187
2332
|
const s = stats(details)
|
|
2188
|
-
|
|
2333
|
+
const status = renderRunningAgentStatus(
|
|
2189
2334
|
frame,
|
|
2190
2335
|
s,
|
|
2191
2336
|
details.activity ?? "thinking…",
|
|
2192
2337
|
theme,
|
|
2193
2338
|
)
|
|
2339
|
+
// Claude Code's hint, after the same 2s: a call that finishes quickly
|
|
2340
|
+
// never flashes it.
|
|
2341
|
+
if (
|
|
2342
|
+
details.backgroundHint &&
|
|
2343
|
+
details.durationMs >= BACKGROUND_HINT_DELAY_MS
|
|
2344
|
+
) {
|
|
2345
|
+
status.addChild(
|
|
2346
|
+
new Text(
|
|
2347
|
+
theme.fg("dim", " (ctrl+b to run in background)"),
|
|
2348
|
+
0,
|
|
2349
|
+
0,
|
|
2350
|
+
),
|
|
2351
|
+
)
|
|
2352
|
+
}
|
|
2353
|
+
return status
|
|
2194
2354
|
}
|
|
2195
2355
|
|
|
2196
2356
|
// ---- Background agent launched ----
|
|
@@ -2604,6 +2764,37 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2604
2764
|
)
|
|
2605
2765
|
}
|
|
2606
2766
|
|
|
2767
|
+
// What the model sees for a run it no longer waits on — spawned in the
|
|
2768
|
+
// background, or handed there mid-run (ctrl+b / viewer `b`). One text for
|
|
2769
|
+
// both, as in Claude Code: from here on they are the same kind of agent.
|
|
2770
|
+
const backgroundResult = (
|
|
2771
|
+
id: string,
|
|
2772
|
+
record: AgentRecord | undefined,
|
|
2773
|
+
) => {
|
|
2774
|
+
const isQueued = record?.status === "queued"
|
|
2775
|
+
return textResult(
|
|
2776
|
+
`${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
|
|
2777
|
+
`Agent ID: ${id}\n` +
|
|
2778
|
+
`Type: ${displayName}\n` +
|
|
2779
|
+
`Description: ${params.description}\n` +
|
|
2780
|
+
(record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
2781
|
+
(isQueued
|
|
2782
|
+
? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n`
|
|
2783
|
+
: "") +
|
|
2784
|
+
`\nYou will be notified when this agent completes.\n` +
|
|
2785
|
+
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
|
|
2786
|
+
`Do not duplicate this agent's work.`,
|
|
2787
|
+
{
|
|
2788
|
+
...detailBaseFor(record),
|
|
2789
|
+
toolUses: 0,
|
|
2790
|
+
tokens: "",
|
|
2791
|
+
durationMs: 0,
|
|
2792
|
+
status: "background" as const,
|
|
2793
|
+
agentId: id,
|
|
2794
|
+
},
|
|
2795
|
+
)
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2607
2798
|
// Background execution
|
|
2608
2799
|
if (runInBackground) {
|
|
2609
2800
|
const { state: bgState, callbacks: bgCallbacks } =
|
|
@@ -2627,9 +2818,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2627
2818
|
}
|
|
2628
2819
|
}
|
|
2629
2820
|
|
|
2630
|
-
// A throw here means the agent never started. Let it out: pi marks
|
|
2631
|
-
// tool call failed
|
|
2632
|
-
//
|
|
2821
|
+
// A throw here means the agent never started. Let it out: pi marks the
|
|
2822
|
+
// tool call failed, whereas a returned message reads to the model as a
|
|
2823
|
+
// subagent that ran and reported this (#179).
|
|
2633
2824
|
id = manager.spawn(pi, ctx, subagentType, params.prompt, {
|
|
2634
2825
|
description: params.description,
|
|
2635
2826
|
name: params.name as string | undefined,
|
|
@@ -2661,16 +2852,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2661
2852
|
// call instead of being reported as a subagent that ran (#179).
|
|
2662
2853
|
await manager.awaitStartup(id)
|
|
2663
2854
|
|
|
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
|
-
}
|
|
2855
|
+
joinTurnGroup(id, joinMode)
|
|
2674
2856
|
|
|
2675
2857
|
agentActivity.set(id, bgState)
|
|
2676
2858
|
widget.ensureTimer()
|
|
@@ -2686,40 +2868,27 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2686
2868
|
isBackground: true,
|
|
2687
2869
|
})
|
|
2688
2870
|
|
|
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
|
-
)
|
|
2871
|
+
return backgroundResult(id, record)
|
|
2711
2872
|
}
|
|
2712
2873
|
|
|
2713
2874
|
// Foreground (synchronous) execution — stream progress via onUpdate
|
|
2714
2875
|
let spinnerFrame = 0
|
|
2715
2876
|
const startedAt = Date.now()
|
|
2716
2877
|
let fgId: string | undefined
|
|
2878
|
+
// Set once the run is live and can be handed to the background — see
|
|
2879
|
+
// `foregroundHandoffs`. Unset while queued: there is no run to hand over.
|
|
2880
|
+
let handoffId: string | undefined
|
|
2881
|
+
// Set by the handoff. From then on `onUpdate` is a dead handle (pi drops
|
|
2882
|
+
// updates once `execute` has returned), so `streamUpdate` stops building
|
|
2883
|
+
// them; the tracker keeps `fgState` live for the widget and FleetView.
|
|
2884
|
+
let backgrounded = false
|
|
2717
2885
|
// Set only while the spawn is parked on a foreground concurrency slot
|
|
2718
2886
|
// (maxConcurrentForeground); undefined the rest of the time, including
|
|
2719
2887
|
// always when the limit is unset.
|
|
2720
2888
|
let queuedAhead: number | undefined
|
|
2721
2889
|
|
|
2722
2890
|
const streamUpdate = () => {
|
|
2891
|
+
if (backgrounded) return
|
|
2723
2892
|
// Spend from the record, everything else from the live tracker. `fgId`
|
|
2724
2893
|
// is set in onSessionCreated below, which fires before the first
|
|
2725
2894
|
// assistant message — so nothing is spent while this reads zero.
|
|
@@ -2743,6 +2912,11 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2743
2912
|
? describeActivity(fgState.activeTools, fgState.responseText)
|
|
2744
2913
|
: `queued — waiting for a foreground slot${queuedAhead > 0 ? ` (${queuedAhead} ahead)` : ""}`,
|
|
2745
2914
|
spinnerFrame: spinnerFrame % SPINNER.length,
|
|
2915
|
+
// One hint per batch, as in Claude Code: on the earliest call still
|
|
2916
|
+
// pending (a Map iterates in insertion order). ctrl+b takes them all.
|
|
2917
|
+
backgroundHint:
|
|
2918
|
+
handoffId !== undefined &&
|
|
2919
|
+
foregroundHandoffs.keys().next().value === handoffId,
|
|
2746
2920
|
}
|
|
2747
2921
|
onUpdate?.({
|
|
2748
2922
|
content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
|
|
@@ -2800,54 +2974,87 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2800
2974
|
|
|
2801
2975
|
streamUpdate()
|
|
2802
2976
|
|
|
2803
|
-
|
|
2977
|
+
// ctrl+b or the viewer's `b` wakes this with the run already handed over
|
|
2978
|
+
// (`sendForegroundToBackground`), raced against the run below. The
|
|
2979
|
+
// manager only accepts a handoff of a run that has not settled, and the
|
|
2980
|
+
// wake-up resolves synchronously with it, so whichever promise wins is
|
|
2981
|
+
// the truth: the result is never also delivered as a notification.
|
|
2982
|
+
let wakeBackgrounded!: () => void
|
|
2983
|
+
const handedOff = new Promise<undefined>((resolve) => {
|
|
2984
|
+
wakeBackgrounded = () => resolve(undefined)
|
|
2985
|
+
})
|
|
2986
|
+
|
|
2987
|
+
let fgResult: { record: AgentRecord } | undefined
|
|
2804
2988
|
try {
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2989
|
+
fgResult = await Promise.race([
|
|
2990
|
+
manager.spawnAndWait(
|
|
2991
|
+
pi,
|
|
2992
|
+
ctx,
|
|
2993
|
+
subagentType,
|
|
2994
|
+
params.prompt,
|
|
2995
|
+
{
|
|
2996
|
+
description: params.description,
|
|
2997
|
+
name: params.name as string | undefined,
|
|
2998
|
+
model,
|
|
2999
|
+
maxTurns: effectiveMaxTurns,
|
|
3000
|
+
isolated,
|
|
3001
|
+
inheritContext,
|
|
3002
|
+
thinkingLevel: thinking,
|
|
3003
|
+
isolation,
|
|
3004
|
+
invocation: agentInvocation,
|
|
3005
|
+
signal,
|
|
3006
|
+
rootSessionId: ctx.sessionManager.getSessionId(),
|
|
3007
|
+
// Deliberately does NOT set fgId: that drives agentActivity, the
|
|
3008
|
+
// widget and the `finally` cleanup below, none of which should see an
|
|
3009
|
+
// agent that has no session and may never get one.
|
|
3010
|
+
onQueued: (_id, ahead) => {
|
|
3011
|
+
queuedAhead = ahead
|
|
3012
|
+
streamUpdate()
|
|
3013
|
+
},
|
|
3014
|
+
...fgCallbacks,
|
|
2828
3015
|
},
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
3016
|
+
(fgAgentId) => {
|
|
3017
|
+
// onSpawned: called synchronously after spawn, before onSessionCreated fires.
|
|
3018
|
+
// Set up the output file so streamToOutputFile can pick it up.
|
|
3019
|
+
const fgRec = manager.getRecord(fgAgentId)
|
|
3020
|
+
attachTranscript(fgRec, fgAgentId)
|
|
3021
|
+
// Fires once the run promise exists, i.e. the agent is running. From
|
|
3022
|
+
// here until this call returns, the run can be handed off.
|
|
3023
|
+
handoffId = fgAgentId
|
|
3024
|
+
foregroundHandoffs.set(fgAgentId, () => {
|
|
3025
|
+
// What a background spawn sets up front: the notification names
|
|
3026
|
+
// this tool call, and it reports alone — its spawn batch is long
|
|
3027
|
+
// closed.
|
|
3028
|
+
if (fgRec) {
|
|
3029
|
+
fgRec.toolCallId = toolCallId
|
|
3030
|
+
fgRec.joinMode = "async"
|
|
3031
|
+
}
|
|
3032
|
+
backgrounded = true
|
|
3033
|
+
wakeBackgrounded()
|
|
3034
|
+
})
|
|
3035
|
+
},
|
|
3036
|
+
),
|
|
3037
|
+
handedOff,
|
|
3038
|
+
])
|
|
2839
3039
|
} finally {
|
|
2840
3040
|
// Runs on both paths, so a startup throw — which now propagates, see
|
|
2841
3041
|
// the background spawn above (#179) — no longer leaves the spinner
|
|
2842
3042
|
// ticking or a finished agent on the widget.
|
|
2843
3043
|
clearInterval(spinnerInterval)
|
|
2844
|
-
if (
|
|
3044
|
+
if (handoffId !== undefined) foregroundHandoffs.delete(handoffId)
|
|
3045
|
+
// A handed-off run is still working: its row stays, and the
|
|
3046
|
+
// background settle in `onComplete` tears it down when it really ends.
|
|
3047
|
+
if (fgId && !backgrounded) {
|
|
2845
3048
|
agentActivity.delete(fgId)
|
|
2846
3049
|
widget.markFinished(fgId)
|
|
2847
3050
|
fleet.onAgentFinished(fgId)
|
|
2848
3051
|
}
|
|
2849
3052
|
}
|
|
2850
3053
|
|
|
3054
|
+
if (fgResult === undefined)
|
|
3055
|
+
return backgroundResult(handoffId!, manager.getRecord(handoffId!))
|
|
3056
|
+
const record = fgResult.record
|
|
3057
|
+
|
|
2851
3058
|
// Get final token count — from the record, like the cost below it, so the
|
|
2852
3059
|
// two describe the same work when the agent delegated to nested children.
|
|
2853
3060
|
const tokenText = formatLifetimeTokens(record)
|
|
@@ -2995,41 +3202,35 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2995
3202
|
|
|
2996
3203
|
/**
|
|
2997
3204
|
* Hand a finished run back to the model through the SAME channel a background
|
|
2998
|
-
* agent uses —
|
|
2999
|
-
*
|
|
3205
|
+
* agent uses — the completion outbox, which delivers at the parent's next
|
|
3206
|
+
* boundary — rendered by the existing `subagent-notification` renderer.
|
|
3000
3207
|
*/
|
|
3001
3208
|
function notifyWorkflowFinished(task: WorkflowTask) {
|
|
3002
3209
|
widget.update()
|
|
3003
3210
|
fleet.update()
|
|
3004
3211
|
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
|
-
})
|
|
3212
|
+
const message = {
|
|
3213
|
+
content: formatWorkflowNotification(task),
|
|
3214
|
+
details: {
|
|
3215
|
+
id: task.id,
|
|
3216
|
+
description: `Workflow ${task.workflowName ?? task.id}`,
|
|
3217
|
+
status:
|
|
3218
|
+
task.status === "completed"
|
|
3219
|
+
? "completed"
|
|
3220
|
+
: task.status === "killed"
|
|
3221
|
+
? "stopped"
|
|
3222
|
+
: "error",
|
|
3223
|
+
toolUses: task.totalToolCalls,
|
|
3224
|
+
// A workflow has agents, not turns; rendering "↻0" would be noise.
|
|
3225
|
+
turnCount: 0,
|
|
3226
|
+
totalTokens: task.totalTokens,
|
|
3227
|
+
durationMs: elapsedMs(task, Date.now()),
|
|
3228
|
+
error: task.error,
|
|
3229
|
+
resultPreview:
|
|
3230
|
+
result.length > 500 ? `${result.slice(0, 500)}…` : result,
|
|
3231
|
+
},
|
|
3232
|
+
}
|
|
3233
|
+
outbox.add({ ids: [task.id], build: () => message })
|
|
3033
3234
|
}
|
|
3034
3235
|
|
|
3035
3236
|
// Defined unconditionally, registered only when the feature is on — the same
|
|
@@ -3492,7 +3693,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3492
3693
|
// Mark result as consumed — suppresses the completion notification
|
|
3493
3694
|
if (record.status !== "running" && record.status !== "queued") {
|
|
3494
3695
|
record.resultConsumed = true
|
|
3495
|
-
|
|
3696
|
+
outbox.cancel(record.id)
|
|
3496
3697
|
}
|
|
3497
3698
|
|
|
3498
3699
|
// Verbose: include full conversation
|
|
@@ -3695,6 +3896,16 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3695
3896
|
}
|
|
3696
3897
|
}
|
|
3697
3898
|
|
|
3899
|
+
/**
|
|
3900
|
+
* The package that provided an agent, for display only. Undefined for every
|
|
3901
|
+
* other source, and for a package path no currently-admitted package owns —
|
|
3902
|
+
* which is what a stale `sourcePath` looks like after the gate narrows.
|
|
3903
|
+
*/
|
|
3904
|
+
function packageNameOf(cfg: AgentConfig | undefined): string | undefined {
|
|
3905
|
+
if (cfg?.source !== "package" || !cfg.sourcePath) return undefined
|
|
3906
|
+
return packageNameForPath(cfg.sourcePath, process.cwd())
|
|
3907
|
+
}
|
|
3908
|
+
|
|
3698
3909
|
async function showAllAgentsList(ctx: ExtensionCommandContext) {
|
|
3699
3910
|
const allNames = getAllTypes()
|
|
3700
3911
|
if (allNames.length === 0) {
|
|
@@ -3702,44 +3913,28 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3702
3913
|
return
|
|
3703
3914
|
}
|
|
3704
3915
|
|
|
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
|
-
}
|
|
3916
|
+
const configs = allNames.map((name) => getAgentConfig(name))
|
|
3714
3917
|
|
|
3715
3918
|
// One row per agent (name in the left column, model on the right); the
|
|
3716
3919
|
// full description renders below the highlighted row via SettingsList,
|
|
3717
3920
|
// exactly like the Settings menu — so long descriptions never wrap the list.
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3921
|
+
// A package agent's description is prefixed with the package that provided
|
|
3922
|
+
// it: the `▪` badge says a package won, not which one.
|
|
3923
|
+
const items: SettingItem[] = allNames.map((name, i) => {
|
|
3924
|
+
const cfg = configs[i]
|
|
3721
3925
|
const model = getModelLabel(name, ctx.modelRegistry)
|
|
3722
3926
|
return {
|
|
3723
3927
|
id: name,
|
|
3724
3928
|
label: `${sourceIndicator(cfg)}${name}`,
|
|
3725
3929
|
currentValue: model,
|
|
3726
|
-
description:
|
|
3930
|
+
description: rowDescription(name, cfg, packageNameOf(cfg)),
|
|
3727
3931
|
// Single-value list so Enter "activates" the row (fires onChange with the
|
|
3728
3932
|
// agent's id) without offering anything to actually cycle.
|
|
3729
3933
|
values: [model],
|
|
3730
3934
|
}
|
|
3731
3935
|
})
|
|
3732
3936
|
|
|
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")
|
|
3937
|
+
const legend = sourceLegend(configs)
|
|
3743
3938
|
|
|
3744
3939
|
const selected = await ctx.ui.custom<string | undefined>(
|
|
3745
3940
|
(_tui, _theme, _kb, done) => {
|
|
@@ -3753,10 +3948,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3753
3948
|
)
|
|
3754
3949
|
const container = new Container()
|
|
3755
3950
|
container.addChild(new Text("Agent types", 0, 0))
|
|
3756
|
-
if (
|
|
3757
|
-
container.addChild(
|
|
3758
|
-
new Text(slTheme.hint(legendParts.join(" ")), 0, 0),
|
|
3759
|
-
)
|
|
3951
|
+
if (legend) container.addChild(new Text(slTheme.hint(legend), 0, 0))
|
|
3760
3952
|
container.addChild(new Spacer(1))
|
|
3761
3953
|
container.addChild(list)
|
|
3762
3954
|
return {
|
|
@@ -3807,13 +3999,13 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3807
3999
|
return
|
|
3808
4000
|
}
|
|
3809
4001
|
|
|
3810
|
-
const { ConversationViewer, VIEWPORT_HEIGHT_PCT } =
|
|
3811
|
-
await import("./ui/conversation-viewer.js")
|
|
3812
4002
|
const session = record.session
|
|
3813
4003
|
const activity = agentActivity.get(record.id)
|
|
4004
|
+
let overlayOptions = getConversationOverlayOptions({ mode: "regular" })
|
|
3814
4005
|
|
|
3815
4006
|
await ctx.ui.custom<undefined>(
|
|
3816
4007
|
(tui, theme, keybindings, done) => {
|
|
4008
|
+
overlayOptions = getConversationOverlayOptions(tui)
|
|
3817
4009
|
return new ConversationViewer(
|
|
3818
4010
|
tui,
|
|
3819
4011
|
session,
|
|
@@ -3831,15 +4023,15 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3831
4023
|
showCost,
|
|
3832
4024
|
getViewerMarkdown,
|
|
3833
4025
|
(mode) => chooseViewerMarkdown(mode, ctx),
|
|
4026
|
+
getFullscreenScrollbar(),
|
|
4027
|
+
backgroundActionFor(record, (message) =>
|
|
4028
|
+
ctx.ui.notify(message, "info"),
|
|
4029
|
+
),
|
|
3834
4030
|
)
|
|
3835
4031
|
},
|
|
3836
4032
|
{
|
|
3837
4033
|
overlay: true,
|
|
3838
|
-
overlayOptions:
|
|
3839
|
-
anchor: "center",
|
|
3840
|
-
width: "90%",
|
|
3841
|
-
maxHeight: `${VIEWPORT_HEIGHT_PCT}%`,
|
|
3842
|
-
},
|
|
4034
|
+
overlayOptions: () => overlayOptions,
|
|
3843
4035
|
},
|
|
3844
4036
|
)
|
|
3845
4037
|
}
|
|
@@ -3851,12 +4043,23 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3851
4043
|
return
|
|
3852
4044
|
}
|
|
3853
4045
|
|
|
3854
|
-
const
|
|
4046
|
+
const isPackage = cfg.source === "package"
|
|
4047
|
+
const file = locateAgentFile(name, cfg.sourcePath, undefined, cfg.source)
|
|
3855
4048
|
const isDefault = cfg.isDefault === true
|
|
3856
4049
|
const disabled = cfg.enabled === false
|
|
3857
4050
|
|
|
3858
4051
|
let menuOptions: string[]
|
|
3859
|
-
if (
|
|
4052
|
+
if (isPackage) {
|
|
4053
|
+
// Read-only: nothing here may write inside pi's install root. `Disable`
|
|
4054
|
+
// still appears because it writes a *local* stub that shadows the package
|
|
4055
|
+
// agent, and `Enable` never does — a package agent that reads as disabled
|
|
4056
|
+
// was disabled by its own author's `enabled: false`, which only the
|
|
4057
|
+
// package can take back. Eject is the way out of that: the copy it writes
|
|
4058
|
+
// carries no `enabled:` line, so the ejected agent is on.
|
|
4059
|
+
menuOptions = disabled
|
|
4060
|
+
? ["Eject (export as .md)", "Back"]
|
|
4061
|
+
: ["Eject (export as .md)", "Disable", "Back"]
|
|
4062
|
+
} else if (disabled && file) {
|
|
3860
4063
|
// Disabled agent with a file — offer Enable
|
|
3861
4064
|
menuOptions = isDefault
|
|
3862
4065
|
? ["Enable", "Edit", "Reset to default", "Delete", "Back"]
|
|
@@ -3872,7 +4075,14 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3872
4075
|
menuOptions = ["Edit", "Disable", "Delete", "Back"]
|
|
3873
4076
|
}
|
|
3874
4077
|
|
|
3875
|
-
|
|
4078
|
+
// Name the package in the title: this menu's options are the read-only set,
|
|
4079
|
+
// and "which package do I have to uninstall or allowlist away" is the
|
|
4080
|
+
// question they raise.
|
|
4081
|
+
const pkgName = packageNameOf(cfg)
|
|
4082
|
+
const choice = await ctx.ui.select(
|
|
4083
|
+
pkgName ? `${name} — ${pkgName}` : name,
|
|
4084
|
+
menuOptions,
|
|
4085
|
+
)
|
|
3876
4086
|
if (!choice || choice === "Back") return
|
|
3877
4087
|
|
|
3878
4088
|
if (choice === "Edit" && file) {
|
|
@@ -3951,7 +4161,12 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3951
4161
|
|
|
3952
4162
|
/** Disable an agent: set enabled: false in its .md file, or create a stub for built-in defaults. */
|
|
3953
4163
|
async function disableAgent(ctx: ExtensionCommandContext, name: string) {
|
|
3954
|
-
const
|
|
4164
|
+
const cfg = getAgentConfig(name)
|
|
4165
|
+
// `locateAgentFile` refuses a package agent's own path, so this is either the
|
|
4166
|
+
// local stub already shadowing it or nothing — never a file inside pi's
|
|
4167
|
+
// install root. An unshadowed package agent therefore falls to the stub
|
|
4168
|
+
// branch below, which writes `enabled: false` at project or personal scope.
|
|
4169
|
+
const file = locateAgentFile(name, cfg?.sourcePath, undefined, cfg?.source)
|
|
3955
4170
|
if (file) {
|
|
3956
4171
|
// Existing file — set enabled: false in frontmatter (idempotent)
|
|
3957
4172
|
const content = readFileSync(file.path, "utf-8")
|
|
@@ -3976,7 +4191,8 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3976
4191
|
return
|
|
3977
4192
|
}
|
|
3978
4193
|
|
|
3979
|
-
// No file (built-in default
|
|
4194
|
+
// No editable file (a built-in default, or a package-provided agent) —
|
|
4195
|
+
// create a stub that shadows it.
|
|
3980
4196
|
const location = await ctx.ui.select("Choose location", [
|
|
3981
4197
|
"Project (.pi/agents/)",
|
|
3982
4198
|
`Personal (${personalAgentsDir()})`,
|
|
@@ -3997,7 +4213,8 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3997
4213
|
|
|
3998
4214
|
/** Enable a disabled agent by removing enabled: false from its frontmatter. */
|
|
3999
4215
|
async function enableAgent(ctx: ExtensionCommandContext, name: string) {
|
|
4000
|
-
const
|
|
4216
|
+
const cfg = getAgentConfig(name)
|
|
4217
|
+
const file = locateAgentFile(name, cfg?.sourcePath, undefined, cfg?.source)
|
|
4001
4218
|
if (!file) return
|
|
4002
4219
|
|
|
4003
4220
|
const content = readFileSync(file.path, "utf-8")
|
|
@@ -4292,6 +4509,8 @@ Write the file using the write tool. Only write the file, nothing else.`
|
|
|
4292
4509
|
showCost: isShowCostEnabled(),
|
|
4293
4510
|
showModel: isShowModelEnabled(),
|
|
4294
4511
|
viewerMarkdown: getViewerMarkdown(),
|
|
4512
|
+
packageAgents: getPackageAgentsGate(),
|
|
4513
|
+
packageWorkflows: getPackageWorkflowsGate(),
|
|
4295
4514
|
} satisfies SubagentsSettings
|
|
4296
4515
|
}
|
|
4297
4516
|
|
|
@@ -4309,6 +4528,35 @@ Write the file using the write tool. Only write the file, nothing else.`
|
|
|
4309
4528
|
const _settingsSnapshotIsComplete: _NoMissingSettingsKeys = true
|
|
4310
4529
|
void _settingsSnapshotIsComplete
|
|
4311
4530
|
|
|
4531
|
+
/**
|
|
4532
|
+
* Whether a package gate lets anything through. `undefined` is the unset
|
|
4533
|
+
* default and reads as `true`; an empty allowlist matches nothing, like
|
|
4534
|
+
* `false`.
|
|
4535
|
+
*/
|
|
4536
|
+
function gateAdmitsAnything(
|
|
4537
|
+
gate: ReturnType<typeof getPackageAgentsGate>,
|
|
4538
|
+
): boolean {
|
|
4539
|
+
if (gate === undefined || gate === true) return true
|
|
4540
|
+
if (gate === false) return false
|
|
4541
|
+
return gate.length > 0
|
|
4542
|
+
}
|
|
4543
|
+
|
|
4544
|
+
/**
|
|
4545
|
+
* Settings-row description for a package gate, naming the configured allowlist
|
|
4546
|
+
* when there is one. The row itself is on/off — an allowlist is hand-written in
|
|
4547
|
+
* `subagents.json` — so the description is the only place it is visible, and a
|
|
4548
|
+
* user about to toggle the row can see what they are about to replace.
|
|
4549
|
+
*/
|
|
4550
|
+
function describeGateSetting(
|
|
4551
|
+
gate: ReturnType<typeof getPackageAgentsGate>,
|
|
4552
|
+
base: string,
|
|
4553
|
+
): string {
|
|
4554
|
+
if (!Array.isArray(gate)) return base
|
|
4555
|
+
return gate.length > 0
|
|
4556
|
+
? `${base} Currently limited to: ${gate.join(", ")}.`
|
|
4557
|
+
: `${base} Currently limited to an empty list, so nothing loads.`
|
|
4558
|
+
}
|
|
4559
|
+
|
|
4312
4560
|
const NUMERIC_IDS = new Set([
|
|
4313
4561
|
"maxConcurrent",
|
|
4314
4562
|
"maxConcurrentForeground",
|
|
@@ -4374,9 +4622,11 @@ Write the file using the write tool. Only write the file, nothing else.`
|
|
|
4374
4622
|
{
|
|
4375
4623
|
id: "joinMode",
|
|
4376
4624
|
label: "Join mode",
|
|
4377
|
-
description:
|
|
4378
|
-
|
|
4379
|
-
|
|
4625
|
+
description:
|
|
4626
|
+
"smart = each completion at the parent's next boundary, as in Claude Code; group = one notification per turn's batch",
|
|
4627
|
+
// `async` is an older name for `smart`; settings files may still hold it.
|
|
4628
|
+
currentValue: getDefaultJoinMode() === "group" ? "group" : "smart",
|
|
4629
|
+
values: ["smart", "group"],
|
|
4380
4630
|
},
|
|
4381
4631
|
{
|
|
4382
4632
|
id: "backgroundByDefault",
|
|
@@ -4403,6 +4653,32 @@ Write the file using the write tool. Only write the file, nothing else.`
|
|
|
4403
4653
|
currentValue: isWorkflowsEnabled() ? "on" : "off",
|
|
4404
4654
|
values: ["on", "off"],
|
|
4405
4655
|
},
|
|
4656
|
+
{
|
|
4657
|
+
id: "packageAgents",
|
|
4658
|
+
label: "Package agents",
|
|
4659
|
+
description: describeGateSetting(
|
|
4660
|
+
getPackageAgentsGate(),
|
|
4661
|
+
"Load agents declared by installed pi packages (`pi.subagents.agents` in their package.json). " +
|
|
4662
|
+
"They rank below your global and project agents, so a same-named local file always wins.",
|
|
4663
|
+
),
|
|
4664
|
+
currentValue: gateAdmitsAnything(getPackageAgentsGate())
|
|
4665
|
+
? "on"
|
|
4666
|
+
: "off",
|
|
4667
|
+
values: ["on", "off"],
|
|
4668
|
+
},
|
|
4669
|
+
{
|
|
4670
|
+
id: "packageWorkflows",
|
|
4671
|
+
label: "Package workflows",
|
|
4672
|
+
description: describeGateSetting(
|
|
4673
|
+
getPackageWorkflowsGate(),
|
|
4674
|
+
"Resolve saved workflow names against scripts declared by installed pi packages " +
|
|
4675
|
+
"(`pi.subagents.workflows`). Searched last, after your project, workspace and personal directories.",
|
|
4676
|
+
),
|
|
4677
|
+
currentValue: gateAdmitsAnything(getPackageWorkflowsGate())
|
|
4678
|
+
? "on"
|
|
4679
|
+
: "off",
|
|
4680
|
+
values: ["on", "off"],
|
|
4681
|
+
},
|
|
4406
4682
|
{
|
|
4407
4683
|
id: "scopeModels",
|
|
4408
4684
|
label: "Scope models",
|
|
@@ -4656,6 +4932,31 @@ Write the file using the write tool. Only write the file, nothing else.`
|
|
|
4656
4932
|
ctx,
|
|
4657
4933
|
`Worktree isolation ${enabled ? "enabled" : "disabled"}. Tool parameter updates on next pi session.`,
|
|
4658
4934
|
)
|
|
4935
|
+
} else if (id === "packageAgents" || id === "packageWorkflows") {
|
|
4936
|
+
const enabled = value === "on"
|
|
4937
|
+
const isAgents = id === "packageAgents"
|
|
4938
|
+
const previous = isAgents
|
|
4939
|
+
? getPackageAgentsGate()
|
|
4940
|
+
: getPackageWorkflowsGate()
|
|
4941
|
+
;(isAgents ? setPackageAgentsGate : setPackageWorkflowsGate)(enabled)
|
|
4942
|
+
reloadCustomAgents()
|
|
4943
|
+
const label = isAgents ? "Package agents" : "Package workflows"
|
|
4944
|
+
// An allowlist cannot be expressed by an on/off row, so say plainly that
|
|
4945
|
+
// it is gone rather than letting a toggle quietly widen the gate.
|
|
4946
|
+
const replaced =
|
|
4947
|
+
Array.isArray(previous) && previous.length > 0
|
|
4948
|
+
? ` Replaced the allowlist (${previous.join(", ")}) — re-add it in .pi/subagents.json to narrow it again.`
|
|
4949
|
+
: ""
|
|
4950
|
+
// Same caveat every row that changes the advertised agent set carries:
|
|
4951
|
+
// the `Agent` tool's description and schema are built at registration,
|
|
4952
|
+
// so until the next session the model is reading a stale type list.
|
|
4953
|
+
const specNote = isAgents
|
|
4954
|
+
? " Tool spec change takes effect on next pi session."
|
|
4955
|
+
: ""
|
|
4956
|
+
notifyApplied(
|
|
4957
|
+
ctx,
|
|
4958
|
+
`${label} ${enabled ? "enabled" : "disabled"}.${replaced}${specNote}`,
|
|
4959
|
+
)
|
|
4659
4960
|
} else if (id === "toolDescriptionMode") {
|
|
4660
4961
|
setToolDescriptionMode(value as ToolDescriptionMode)
|
|
4661
4962
|
notifyApplied(
|