@herbertgao/pi-subagents 0.19.0 → 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/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: this decides
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
- let strictAgentFiles = loadSettings(process.cwd()).strictAgentFiles === true
636
-
637
- /** Reload agents from project/global custom agent dirs and merge with defaults (called on init and each Agent invocation). */
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
- // ---- Cancellable pending notifications ----
710
- // Holds notifications briefly so get_subagent_result can cancel them
711
- // before they reach pi.sendMessage (fire-and-forget).
712
- const pendingNudges = new Map<string, ReturnType<typeof setTimeout>>()
713
- const NUDGE_HOLD_MS = 200
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(NUDGE_HOLD_MS / 4)
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
- pi.sendMessage<NotificationDetails>(
751
- {
752
- customType: "subagent-notification",
753
- content: notification + footer,
754
- display: true,
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
- { deliverAs: "followUp", triggerTurn: true },
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 groupKey = `group:${records.map((r) => r.id).join(",")}`
782
- scheduleNudge(groupKey, () => {
783
- // Re-check at send time
784
- const unconsumed = records.filter((r) => !r.resultConsumed)
785
- if (unconsumed.length === 0) {
786
- widget.update()
787
- return
788
- }
789
-
790
- const notifications = unconsumed
791
- .map((r) => formatTaskNotification(r, 300, showCost))
792
- .join("\n\n")
793
- const label = partial
794
- ? `${unconsumed.length} agent(s) finished (partial — others still running)`
795
- : `${unconsumed.length} agent(s) finished`
796
-
797
- const [first, ...rest] = unconsumed
798
- const details = buildNotificationDetails(
799
- first,
800
- 300,
801
- agentActivity.get(first.id),
802
- )
803
- if (rest.length > 0) {
804
- details.others = rest.map((r) =>
805
- buildNotificationDetails(r, 300, agentActivity.get(r.id)),
806
- )
807
- }
808
-
809
- pi.sendMessage<NotificationDetails>(
810
- {
811
- customType: "subagent-notification",
812
- content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
813
- display: true,
814
- details,
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
- { deliverAs: "followUp", triggerTurn: true },
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
- // If this agent is pending batch finalization (debounce window still open),
904
- // don't send an individual nudge — finalizeBatch will pick it up retroactively.
905
- if (currentBatchAgents.some((a) => a.id === record.id)) {
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
- cancelNudge(record.id)
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 and max turns
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
- for (const timer of pendingNudges.values()) clearTimeout(timer)
1495
- pendingNudges.clear()
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 last two arguments keep a conversation overlay opened here identical to
1527
- // one opened from `/agents`: same setting on the way in, same persist out.
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
- // ---- Batch tracking for smart join mode ----
1656
- // Collects background agent IDs spawned in the current turn for smart grouping.
1657
- // Uses a debounced timer: each new agent resets the 100ms window so that all
1658
- // parallel tool calls (which may be dispatched across multiple microtasks by the
1659
- // framework) are captured in the same batch.
1660
- let currentBatchAgents: { id: string; joinMode: JoinMode }[] = []
1661
- let batchFinalizeTimer: ReturnType<typeof setTimeout> | undefined
1662
- let batchCounter = 0
1663
-
1664
- /** Finalize the current batch: if 2+ smart-mode agents, register as a group. */
1665
- function finalizeBatch() {
1666
- batchFinalizeTimer = undefined
1667
- const batchAgents = [...currentBatchAgents]
1668
- currentBatchAgents = []
1669
-
1670
- const smartAgents = batchAgents.filter(
1671
- (a) => a.joinMode === "smart" || a.joinMode === "group",
1672
- )
1673
- if (smartAgents.length >= 2) {
1674
- const groupId = `batch-${++batchCounter}`
1675
- const ids = smartAgents.map((a) => a.id)
1676
- groupJoin.registerGroup(groupId, ids)
1677
- // Retroactively process agents that already completed during the debounce window.
1678
- // Their onComplete fired but was deferred (agent was in currentBatchAgents),
1679
- // so we feed them into the group now.
1680
- for (const id of ids) {
1681
- const record = manager.getRecord(id)
1682
- if (!record) continue
1683
- record.groupId = groupId
1684
- if (record.completedAt != null && !record.resultConsumed) {
1685
- groupJoin.onAgentComplete(record)
1686
- }
1687
- }
1688
- } else {
1689
- // No group formed — send individual nudges for any agents that completed
1690
- // during the debounce window and had their notification deferred.
1691
- for (const { id } of batchAgents) {
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
- if (joinMode != null && joinMode !== "async") {
1777
- currentBatchAgents.push({ id, joinMode })
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
- return renderRunningAgentStatus(
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 a
2631
- // tool call failed only when execute throws, and a returned message
2632
- // reads to the model as a subagent that ran and reported this (#179).
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
- if (joinMode == null || joinMode === "async") {
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
- const isQueued = record?.status === "queued"
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
- let record: AgentRecord
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
- const fgResult = await manager.spawnAndWait(
2806
- pi,
2807
- ctx,
2808
- subagentType,
2809
- params.prompt,
2810
- {
2811
- description: params.description,
2812
- name: params.name as string | undefined,
2813
- model,
2814
- maxTurns: effectiveMaxTurns,
2815
- isolated,
2816
- inheritContext,
2817
- thinkingLevel: thinking,
2818
- isolation,
2819
- invocation: agentInvocation,
2820
- signal,
2821
- rootSessionId: ctx.sessionManager.getSessionId(),
2822
- // Deliberately does NOT set fgId: that drives agentActivity, the
2823
- // widget and the `finally` cleanup below, none of which should see an
2824
- // agent that has no session and may never get one.
2825
- onQueued: (_id, ahead) => {
2826
- queuedAhead = ahead
2827
- streamUpdate()
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
- ...fgCallbacks,
2830
- },
2831
- (fgAgentId) => {
2832
- // onSpawned: called synchronously after spawn, before onSessionCreated fires.
2833
- // Set up the output file so streamToOutputFile can pick it up.
2834
- const fgRec = manager.getRecord(fgAgentId)
2835
- attachTranscript(fgRec, fgAgentId)
2836
- },
2837
- )
2838
- record = fgResult.record
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 (fgId) {
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 — held briefly by `scheduleNudge`, delivered as a follow-up that
2999
- * triggers a turn, rendered by the existing `subagent-notification` renderer.
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
- scheduleNudge(task.id, () => {
3006
- pi.sendMessage<NotificationDetails>(
3007
- {
3008
- customType: "subagent-notification",
3009
- content: formatWorkflowNotification(task),
3010
- display: true,
3011
- details: {
3012
- id: task.id,
3013
- description: `Workflow ${task.workflowName ?? task.id}`,
3014
- status:
3015
- task.status === "completed"
3016
- ? "completed"
3017
- : task.status === "killed"
3018
- ? "stopped"
3019
- : "error",
3020
- toolUses: task.totalToolCalls,
3021
- // A workflow has agents, not turns; rendering "↻0" would be noise.
3022
- turnCount: 0,
3023
- totalTokens: task.totalTokens,
3024
- durationMs: elapsedMs(task, Date.now()),
3025
- error: task.error,
3026
- resultPreview:
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
- cancelNudge(params.agent_id)
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
- // Source indicators: defaults unmarked, custom agents get • (project) or ◦ (global)
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
- const items: SettingItem[] = allNames.map((name) => {
3719
- const cfg = getAgentConfig(name)
3720
- const disabled = cfg?.enabled === false
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: disabled ? "(disabled)" : (cfg?.description ?? name),
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 hasCustom = allNames.some((n) => {
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 (legendParts.length)
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 file = locateAgentFile(name, cfg.sourcePath)
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 (disabled && file) {
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
- const choice = await ctx.ui.select(name, menuOptions)
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 file = locateAgentFile(name, getAgentConfig(name)?.sourcePath)
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) — create a stub
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 file = locateAgentFile(name, getAgentConfig(name)?.sourcePath)
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: "Default join mode for background agents",
4378
- currentValue: getDefaultJoinMode(),
4379
- values: ["smart", "async", "group"],
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(