@herbertgao/pi-subagents 0.19.0 → 0.22.0-local.2

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