pilotswarm 0.5.28 → 0.5.30

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.
Files changed (135) hide show
  1. package/mcp/dist/bin/pilotswarm-mcp.js +4 -0
  2. package/mcp/dist/bin/pilotswarm-mcp.js.map +1 -1
  3. package/mcp/dist/src/context.d.ts +43 -10
  4. package/mcp/dist/src/context.d.ts.map +1 -1
  5. package/mcp/dist/src/context.js +15 -7
  6. package/mcp/dist/src/context.js.map +1 -1
  7. package/mcp/dist/src/resources/artifacts.d.ts.map +1 -1
  8. package/mcp/dist/src/resources/artifacts.js +4 -3
  9. package/mcp/dist/src/resources/artifacts.js.map +1 -1
  10. package/mcp/dist/src/server.d.ts.map +1 -1
  11. package/mcp/dist/src/server.js +2 -0
  12. package/mcp/dist/src/server.js.map +1 -1
  13. package/mcp/dist/src/tools/agent-packages.d.ts +14 -0
  14. package/mcp/dist/src/tools/agent-packages.d.ts.map +1 -0
  15. package/mcp/dist/src/tools/agent-packages.js +126 -0
  16. package/mcp/dist/src/tools/agent-packages.js.map +1 -0
  17. package/mcp/dist/src/tools/agents.d.ts.map +1 -1
  18. package/mcp/dist/src/tools/agents.js +18 -0
  19. package/mcp/dist/src/tools/agents.js.map +1 -1
  20. package/mcp/dist/src/tools/artifacts.d.ts.map +1 -1
  21. package/mcp/dist/src/tools/artifacts.js +12 -11
  22. package/mcp/dist/src/tools/artifacts.js.map +1 -1
  23. package/mcp/dist/src/tools/capabilities.js +1 -1
  24. package/mcp/dist/src/tools/capabilities.js.map +1 -1
  25. package/mcp/dist/src/tools/observability.js +1 -1
  26. package/mcp/dist/src/tools/observability.js.map +1 -1
  27. package/mcp/dist/src/tools/sessions.js +2 -2
  28. package/mcp/dist/src/tools/sessions.js.map +1 -1
  29. package/mcp/dist/src/tools/system.d.ts.map +1 -1
  30. package/mcp/dist/src/tools/system.js +56 -5
  31. package/mcp/dist/src/tools/system.js.map +1 -1
  32. package/mcp/dist/src/tools/turn-control.js +1 -1
  33. package/mcp/dist/src/tools/turn-control.js.map +1 -1
  34. package/mcp/dist/src/util/command.d.ts +3 -3
  35. package/mcp/dist/src/util/command.d.ts.map +1 -1
  36. package/mcp/dist/src/util/command.js +1 -1
  37. package/mcp/dist/src/util/command.js.map +1 -1
  38. package/package.json +6 -6
  39. package/tui/bin/tui.js +6 -0
  40. package/tui/src/agents-cli.js +442 -0
  41. package/tui/src/app.js +44 -1
  42. package/tui/src/node-sdk-transport.js +386 -5
  43. package/tui/src/plugin-config.js +17 -0
  44. package/ui/core/src/commands.js +7 -0
  45. package/ui/core/src/controller.js +498 -33
  46. package/ui/core/src/formatting.js +60 -16
  47. package/ui/core/src/index.js +2 -0
  48. package/ui/core/src/reducer.js +421 -34
  49. package/ui/core/src/repo-import.js +298 -0
  50. package/ui/core/src/repo-links.js +54 -0
  51. package/ui/core/src/selectors.js +879 -91
  52. package/ui/core/src/session-tree.js +109 -8
  53. package/ui/core/src/state.js +108 -2
  54. package/ui/core/src/themes/helpers.js +4 -1
  55. package/ui/core/src/themes/index.js +4 -10
  56. package/ui/core/src/themes/workspace-dark-rich.js +65 -0
  57. package/ui/core/src/themes/workspace-dark.js +65 -0
  58. package/ui/react/src/components.js +181 -4
  59. package/ui/react/src/web-app.js +1410 -91
  60. package/web/api/router.js +20 -2
  61. package/web/authz.js +4 -10
  62. package/web/dist/assets/{abnfDiagram-VRR7QNED-DLV45wH4.js → abnfDiagram-VRR7QNED-Cimc0f-u.js} +1 -1
  63. package/web/dist/assets/{arc-CE4xAYdE.js → arc-D8-_Dx5J.js} +1 -1
  64. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-DfzR8-9F.js → architectureDiagram-ZJ3FMSHR-lzMAuQHJ.js} +1 -1
  65. package/web/dist/assets/{blockDiagram-677ZJIJ3-C2pZdqvH.js → blockDiagram-677ZJIJ3-Dp2CWjVS.js} +1 -1
  66. package/web/dist/assets/{c4Diagram-LMCZKHZV-CKRspTan.js → c4Diagram-LMCZKHZV-DF93UkOy.js} +1 -1
  67. package/web/dist/assets/channel-OwWeiCox.js +1 -0
  68. package/web/dist/assets/{chunk-2Q5K7J3B-CNjLrfHT.js → chunk-2Q5K7J3B-pbsQRM-F.js} +1 -1
  69. package/web/dist/assets/{chunk-32BRIVSS-Bv5EwyaJ.js → chunk-32BRIVSS-B_f-gG1O.js} +1 -1
  70. package/web/dist/assets/{chunk-5VM5RSS4-NDBesEbJ.js → chunk-5VM5RSS4-gqlpJDmt.js} +1 -1
  71. package/web/dist/assets/{chunk-EX3LRPZG-DHQht9ty.js → chunk-EX3LRPZG-Cms7hPmM.js} +1 -1
  72. package/web/dist/assets/{chunk-JWPE2WC7-r0OQpJRF.js → chunk-JWPE2WC7-Boq5PtPI.js} +1 -1
  73. package/web/dist/assets/{chunk-MOJQB5TN-fcwIDgtl.js → chunk-MOJQB5TN-DVn9i8zt.js} +1 -1
  74. package/web/dist/assets/{chunk-RYQCIY6F-x7XQlDO1.js → chunk-RYQCIY6F-a1vKs3UX.js} +1 -1
  75. package/web/dist/assets/{chunk-V7JOEXUC-U8ZNVy1m.js → chunk-V7JOEXUC-i60p5fHd.js} +1 -1
  76. package/web/dist/assets/{chunk-VR4S4FIN-DLEdPLcN.js → chunk-VR4S4FIN-a8p0o-8B.js} +1 -1
  77. package/web/dist/assets/{chunk-XXDRQBXY-TeyAtB-i.js → chunk-XXDRQBXY-BwP1yBKd.js} +1 -1
  78. package/web/dist/assets/classDiagram-OUVF2IWQ-CDaP7nuC.js +1 -0
  79. package/web/dist/assets/classDiagram-v2-EOCWNBFH-CDaP7nuC.js +1 -0
  80. package/web/dist/assets/{cose-bilkent-JH36ORCC-B3bAl1-Z.js → cose-bilkent-JH36ORCC-HW6kGgze.js} +1 -1
  81. package/web/dist/assets/{cynefin-VYW2F7L2-DexP8nG-.js → cynefin-VYW2F7L2-YUFMn-vE.js} +1 -1
  82. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-Doa2XnSs.js → cynefinDiagram-TSTJHNR4-Brdy17i4.js} +1 -1
  83. package/web/dist/assets/{dagre-VKFMJZFB-DZMBKQcJ.js → dagre-VKFMJZFB-CAiSJJj-.js} +1 -1
  84. package/web/dist/assets/{diagram-FQU43EPY-BkzibmSq.js → diagram-FQU43EPY-B63Cqe0J.js} +1 -1
  85. package/web/dist/assets/{diagram-G47NLZAW-CihyHR3a.js → diagram-G47NLZAW-ByktrDJ2.js} +1 -1
  86. package/web/dist/assets/{diagram-NH7WQ7WH-OMDUUxwW.js → diagram-NH7WQ7WH-BLSfRMoE.js} +1 -1
  87. package/web/dist/assets/{diagram-OA4YK3LP-Lc0imJba.js → diagram-OA4YK3LP-DNVu-zpk.js} +1 -1
  88. package/web/dist/assets/{diagram-WEI45ONY-Dlcy8hPe.js → diagram-WEI45ONY-CNf4Sz-I.js} +1 -1
  89. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-Dh3u17Dg.js → ebnfDiagram-CCIWWBDH-9CT3ergx.js} +1 -1
  90. package/web/dist/assets/{erDiagram-Q63AITRT-C0OhlUC2.js → erDiagram-Q63AITRT-BPJHAMgl.js} +1 -1
  91. package/web/dist/assets/{flowDiagram-23GEKE2U-CADKxT-x.js → flowDiagram-23GEKE2U-BVtpr6Iu.js} +1 -1
  92. package/web/dist/assets/{ganttDiagram-NO4QXBWP-iVVdxWB7.js → ganttDiagram-NO4QXBWP-BTHnv9sV.js} +1 -1
  93. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-BwDUec_a.js → gitGraphDiagram-IHSO6WYX-BfmtqbL5.js} +1 -1
  94. package/web/dist/assets/index-B37T1c2s.css +1 -0
  95. package/web/dist/assets/index-Bmb8qK39.js +24 -0
  96. package/web/dist/assets/{infoDiagram-FWYZ7A6U-CGPUif0s.js → infoDiagram-FWYZ7A6U-kU4i4HRu.js} +1 -1
  97. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-QYnrl-KA.js → ishikawaDiagram-FXEZZL3T-Ch7i-E8O.js} +1 -1
  98. package/web/dist/assets/{journeyDiagram-5HDEW3XC-CeKixxL0.js → journeyDiagram-5HDEW3XC-CPQxp0ps.js} +1 -1
  99. package/web/dist/assets/{kanban-definition-HUTT4EX6-CeYokFBS.js → kanban-definition-HUTT4EX6-BBgMVqoJ.js} +1 -1
  100. package/web/dist/assets/{linear-BbA6_gnz.js → linear-rwksvzXJ.js} +1 -1
  101. package/web/dist/assets/{mermaid.core--xGRoQTa.js → mermaid.core-o7PszRUc.js} +4 -4
  102. package/web/dist/assets/{mindmap-definition-LN4V7U3C-D53u8h0z.js → mindmap-definition-LN4V7U3C-wgOTdzlq.js} +1 -1
  103. package/web/dist/assets/{papaparse.min-BU0o7jXn.js → papaparse.min-BRXtjEI6.js} +1 -1
  104. package/web/dist/assets/{pegDiagram-2B236MQR-B7QSyG-3.js → pegDiagram-2B236MQR-BkhQqqTm.js} +1 -1
  105. package/web/dist/assets/{pieDiagram-ENE6RG2P-XtSGL7GG.js → pieDiagram-ENE6RG2P-CcMnvxUz.js} +1 -1
  106. package/web/dist/assets/pilotswarm-DYXtzuUN.js +99 -0
  107. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-LZoYSbTH.js → quadrantDiagram-ABIIQ3AL-_KD1QeEr.js} +1 -1
  108. package/web/dist/assets/{railroadDiagram-RFXS5EU6-B5FREx2U.js → railroadDiagram-RFXS5EU6-CqI0olIe.js} +1 -1
  109. package/web/dist/assets/{requirementDiagram-TGXJPOKE-1VRFkBQX.js → requirementDiagram-TGXJPOKE-1JxRwusF.js} +1 -1
  110. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-J456gWvu.js → sankeyDiagram-HTMAVEWB-9IpFoKzi.js} +1 -1
  111. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-CaCcw6KO.js → sequenceDiagram-DBY2YBRQ-teyObSQ9.js} +1 -1
  112. package/web/dist/assets/{sizeCapture-X5ZJPWSS-DClsP3_O.js → sizeCapture-X5ZJPWSS-DVhU2J_6.js} +1 -1
  113. package/web/dist/assets/{stateDiagram-2N3HPSRC-D6VyrKhd.js → stateDiagram-2N3HPSRC-DHPoWpIQ.js} +1 -1
  114. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DJjZWYLS.js +1 -0
  115. package/web/dist/assets/{swimlanes-5IMT3BWC-q678iAuo.js → swimlanes-5IMT3BWC-BxTtDQoB.js} +2 -2
  116. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CV12vDcK.js +8 -0
  117. package/web/dist/assets/{timeline-definition-FHXFAJF6-WnPK_zhe.js → timeline-definition-FHXFAJF6-D0wXZx-4.js} +1 -1
  118. package/web/dist/assets/{vennDiagram-L72KCM5P-WpumuLld.js → vennDiagram-L72KCM5P-BSFyI3iV.js} +1 -1
  119. package/web/dist/assets/{wardleyDiagram-EHGQE667-CBrYLUm_.js → wardleyDiagram-EHGQE667-BN-S7hh0.js} +1 -1
  120. package/web/dist/assets/{xychartDiagram-FW5EYKEG-DbX1rnDW.js → xychartDiagram-FW5EYKEG-nDNuPs7o.js} +1 -1
  121. package/web/dist/index.html +3 -3
  122. package/web/runtime.js +36 -4
  123. package/ui/core/src/themes/daylight.js +0 -62
  124. package/ui/core/src/themes/light-high-contrast.js +0 -65
  125. package/ui/core/src/themes/paper-ink.js +0 -62
  126. package/ui/core/src/themes/solarized-ops.js +0 -59
  127. package/ui/core/src/themes/tokyo-night.js +0 -56
  128. package/web/dist/assets/channel-D7p-E4El.js +0 -1
  129. package/web/dist/assets/classDiagram-OUVF2IWQ-yFZ-1yMb.js +0 -1
  130. package/web/dist/assets/classDiagram-v2-EOCWNBFH-yFZ-1yMb.js +0 -1
  131. package/web/dist/assets/index-BGWTu1KK.css +0 -1
  132. package/web/dist/assets/index-Djfdr7ts.js +0 -24
  133. package/web/dist/assets/pilotswarm-CtO819QH.js +0 -94
  134. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-D7eKFhHb.js +0 -1
  135. package/web/dist/assets/swimlanesDiagram-G3AALYLV-DlVHQBQx.js +0 -8
@@ -1,4 +1,5 @@
1
1
  import { INSPECTOR_TABS, FOCUS_REGIONS } from "./commands.js";
2
+ import { isManuallyOrderableSession } from "./session-tree.js";
2
3
  import {
3
4
  createSplashCard,
4
5
  isRehydrationNoticeText,
@@ -216,15 +217,131 @@ function initialsFromText(value) {
216
217
  .split(/[^A-Za-z0-9]+/u)
217
218
  .map((part) => part.trim())
218
219
  .filter(Boolean);
219
- if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toLowerCase();
220
+ // UPPERCASE: these render as a monogram avatar, where lowercase reads as
221
+ // a typo rather than a name. Two letters from "First Last", else the
222
+ // first two of a single token.
223
+ if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
220
224
  const compact = (parts[0] || text).replace(/[^A-Za-z0-9]/gu, "");
221
- return compact.slice(0, 2).toLowerCase();
225
+ return compact.slice(0, 2).toUpperCase();
222
226
  }
223
227
 
224
228
  function ownerInitials(owner) {
225
229
  return initialsFromText(owner?.displayName) || initialsFromText(owner?.email) || "?";
226
230
  }
227
231
 
232
+ /**
233
+ * Which sibling list a row is ordered within — the unit a drag may rearrange.
234
+ *
235
+ * Rows only ever sort against their own siblings, and the bands keep the
236
+ * kinds apart, so the container is what decides whether a drop means "put it
237
+ * here" (same container ⇒ reorder) or "file it in there" (different one ⇒
238
+ * the folder gesture). Pinned rows form their own band and therefore their
239
+ * own list, which is why the pin state is part of the key.
240
+ *
241
+ * Returns null for rows that cannot be ordered at all.
242
+ */
243
+ export function manualOrderContainerKey(session, pinned = false) {
244
+ if (!isManuallyOrderableSession(session)) return null;
245
+ if (pinned) return "pinned";
246
+ if (session.isGroup) return "folders";
247
+ if (session.groupId) return `g:${session.groupId}`;
248
+ return "";
249
+ }
250
+
251
+ /**
252
+ * Identity directory built from the sessions already in state.
253
+ *
254
+ * Session owners arrive as FULL principals — provider, subject, email and
255
+ * displayName — while an agent package carries only `{provider, subject}`
256
+ * plus a createdBy email. Resolving the package's principal against this
257
+ * directory recovers the real name, which is what makes initials come out as
258
+ * a person's initials ("Affan Dar" → AD) rather than the first two letters of
259
+ * an alias ("daraffan@…" → DA). No fetch: the data is already loaded.
260
+ */
261
+ /**
262
+ * The one owner-badge descriptor both lists render from. Same person ⇒ same
263
+ * initials and same colour, wherever they appear.
264
+ */
265
+ export function ownerBadgeFor(owner, { isMine = false } = {}) {
266
+ if (!owner) return null;
267
+ const name = ownerDisplayName(owner, owner?.email || "");
268
+ return {
269
+ initials: ownerInitials(owner),
270
+ hue: ownerBadgeHue(ownerBadgeKey(owner, owner?.email)),
271
+ name: name || "unknown user",
272
+ isMine: Boolean(isMine),
273
+ };
274
+ }
275
+
276
+ function ownerDirectoryFromSessions(byId) {
277
+ const directory = new Map();
278
+ for (const session of Object.values(byId || {})) {
279
+ const owner = session?.owner;
280
+ if (!owner?.subject) continue;
281
+ if (!owner.displayName && !owner.email) continue;
282
+ const key = ownerKeyForOwner(owner);
283
+ if (key && !directory.has(key)) directory.set(key, owner);
284
+ }
285
+ return directory;
286
+ }
287
+
288
+ /**
289
+ * A package's owner, resolved to the richest identity available.
290
+ *
291
+ * A DISPLAY NAME beats an email, because initials should be the person's
292
+ * ("Affan Dar" → AD), not the first two letters of their alias
293
+ * ("daraffan@…" → DA) — which is exactly the mismatch that made the same
294
+ * person read differently in the session list and the package list.
295
+ */
296
+ function resolvePackageOwner(pkg, directory) {
297
+ // The server resolves this now (migration 0041 joins the users table, the
298
+ // same way the session view always has), so owner arrives with a real
299
+ // display name and the two lists agree by construction.
300
+ if (pkg?.owner?.displayName || pkg?.owner?.email) return pkg.owner;
301
+ // Deployments still on an older schema return a bare principal. Recover
302
+ // the name from sessions already in state where we can, and fall back to
303
+ // the createdBy alias only when nothing better exists.
304
+ const fromDirectory = directory?.get?.(ownerKeyForOwner(pkg?.owner)) || null;
305
+ if (fromDirectory) return fromDirectory;
306
+ return pkg?.createdBy ? { email: pkg.createdBy } : null;
307
+ }
308
+
309
+ /**
310
+ * Owner badge palette. Colour is derived from a stable identity key so the
311
+ * same person is the same colour everywhere and across reloads — the point of
312
+ * the badge is telling owners APART at a glance, which two-letter initials
313
+ * alone do poorly (and did not do at all while they were all "?").
314
+ *
315
+ * Eight hues, all legible on both the light and dark portal grounds; the
316
+ * viewer's own rows are styled separately by the caller, so this palette
317
+ * never has to encode "mine".
318
+ */
319
+ const OWNER_BADGE_HUES = 8;
320
+
321
+ export function ownerBadgeHue(key) {
322
+ const text = String(key || "").trim().toLowerCase();
323
+ if (!text) return 0;
324
+ // FNV-1a: tiny, dependency-free, and stable across processes — a hash that
325
+ // changed between the TUI and the portal would defeat the whole purpose.
326
+ let hash = 0x811c9dc5;
327
+ for (let i = 0; i < text.length; i += 1) {
328
+ hash ^= text.charCodeAt(i);
329
+ hash = Math.imul(hash, 0x01000193) >>> 0;
330
+ }
331
+ return hash % OWNER_BADGE_HUES;
332
+ }
333
+
334
+ /** Stable identity key for badge colouring: the email/name that names a human. */
335
+ export function ownerBadgeKey(owner, createdBy) {
336
+ return String(
337
+ createdBy
338
+ || owner?.email
339
+ || owner?.displayName
340
+ || (owner?.provider && owner?.subject ? `${owner.provider}:${owner.subject}` : "")
341
+ || "",
342
+ ).trim().toLowerCase();
343
+ }
344
+
228
345
  function shouldDecorateSessionOwners(state) {
229
346
  // Decorate owners only when the list actually surfaces more than one
230
347
  // distinct human owner — otherwise the owner chip is pure noise on every
@@ -381,6 +498,11 @@ function buildSessionTitle(session, brandingTitle) {
381
498
 
382
499
  function matchesOwnerFilterDirect(session, ownerFilter = {}, auth = {}, ownerOverride = undefined) {
383
500
  if (!ownerFilter || ownerFilter.all === true) return true;
501
+ // A group is the VIEWER'S OWN private organization — the server lists only
502
+ // your groups, and the row itself carries no owner. Owner filters are about
503
+ // whose sessions to show, so they must never hide your own folders (the
504
+ // group's members are still filtered on their own merits).
505
+ if (session?.isGroup) return true;
384
506
  if (session?.isSystem) return ownerFilter.includeSystem === true;
385
507
  const ownerKey = ownerKeyForOwner(ownerOverride !== undefined ? ownerOverride : session?.owner);
386
508
  if (!ownerKey) return ownerFilter.includeUnowned === true;
@@ -595,7 +717,11 @@ function buildSessionRowView(entry, session, state, totalDescendantCounts, visib
595
717
  : "";
596
718
 
597
719
  if (depthPrefix) {
598
- prefixRuns.push({ text: depthPrefix, color: "gray" });
720
+ // `role` lets a host drop this run without losing the rest: the portal
721
+ // draws nesting as a surface (the "well") and would otherwise show a
722
+ // box-drawing character inside it. The TUI renders text+colour and
723
+ // ignores the field, so its glyphs are untouched.
724
+ prefixRuns.push({ text: depthPrefix, color: "gray", role: "depth" });
599
725
  }
600
726
 
601
727
  const pinnedSet = new Set(Array.isArray(state.sessions?.pinnedIds) ? state.sessions.pinnedIds : []);
@@ -604,12 +730,11 @@ function buildSessionRowView(entry, session, state, totalDescendantCounts, visib
604
730
  const isSelected = selectedSet.has(entry.sessionId);
605
731
  const isPinned = pinnedSet.has(entry.sessionId);
606
732
 
607
- if (selectMode) {
608
- prefixRuns.push({
609
- text: isSelected ? "[x] " : "[ ] ",
610
- color: isSelected ? "cyan" : "gray",
611
- bold: isSelected,
612
- });
733
+ // Multi-selection reads as a selection BAR on the chosen rows (plus the
734
+ // host's own row styling) — not a checkbox column that shifts every row
735
+ // the moment selection mode turns on.
736
+ if (isSelected) {
737
+ prefixRuns.push({ text: "▌", color: "cyan", bold: true });
613
738
  }
614
739
 
615
740
  // Pin column renders first on top-level non-system rows so pinned and
@@ -638,9 +763,13 @@ function buildSessionRowView(entry, session, state, totalDescendantCounts, visib
638
763
  prefixRuns.push({ text: "⚗ ", color: "magenta", bold: true });
639
764
  } else {
640
765
  const icon = sessionStatusIcon(session, mode);
766
+ // role:"status" so a host can render this as a MARK rather than a
767
+ // character — the portal draws a dot, since "~" vs "*" is unreadable
768
+ // without a legend. The TUI renders text+colour and ignores the field.
641
769
  prefixRuns.push({
642
770
  text: icon ? `${icon} ` : " ",
643
771
  color: sessionStatusColor(session, mode),
772
+ role: "status",
644
773
  });
645
774
  }
646
775
 
@@ -674,10 +803,13 @@ function buildSessionRowView(entry, session, state, totalDescendantCounts, visib
674
803
  // Bracketed + bold so it reads as an owner badge, not part of the title.
675
804
  // The current viewer's own sessions get a distinct color so "mine" pops.
676
805
  if (shouldDecorateSessionOwners(state) && !session?.isSystem && !session?.isGroup) {
677
- const initials = effectiveOwner ? ownerInitials(effectiveOwner) : "?";
678
806
  const viewerKey = ownerKeyForOwner(state?.auth?.principal);
679
- const isMine = viewerKey && ownerKeyForOwner(effectiveOwner) === viewerKey;
680
- titleRuns.push({ text: `[${initials}] `, color: isMine ? "green" : "cyan", bold: true });
807
+ const isMine = Boolean(viewerKey && ownerKeyForOwner(effectiveOwner) === viewerKey);
808
+ const initials = effectiveOwner ? ownerInitials(effectiveOwner) : "?";
809
+ // Tagged so the PORTAL can drop this run and draw a real avatar in its
810
+ // place, while the TUI — which can only print text — keeps the chip.
811
+ // Both read from the same descriptor, so they cannot disagree.
812
+ titleRuns.push({ text: `[${initials}] `, color: isMine ? "green" : "cyan", bold: true, ownerChip: true });
681
813
  }
682
814
  if (hasRealTitle) {
683
815
  titleRuns.push({ text: rawTitle, color: mainColor, bold: Boolean(session?.isSystem || session?.isGroup) });
@@ -881,9 +1013,16 @@ export function selectSessionRows(state) {
881
1013
  sessionRowMemo.set(state.sessions.flat, memo);
882
1014
  }
883
1015
 
884
- return state.sessions.flat.map((entry) => {
885
- const session = state.sessions.byId[entry.sessionId];
886
- const active = entry.sessionId === state.sessions.activeSessionId;
1016
+ return state.sessions.flat.filter((entry) => (
1017
+ // A row whose session is in neither place can render nothing but an
1018
+ // owner chip and a dash - the "[?]" ghost. Drop it rather than show it.
1019
+ Boolean(state.sessions.byId[entry.sessionId] || entry.standIn)
1020
+ )).map((entry) => {
1021
+ const session = state.sessions.byId[entry.sessionId] || entry.standIn;
1022
+ // The LIST highlight can be cleared (click empty space) without
1023
+ // detaching the session — the chat and inspector panes keep it.
1024
+ const active = entry.sessionId === state.sessions.activeSessionId
1025
+ && !state.sessions.listDeselected;
887
1026
  const pinned = pinnedSet.has(entry.sessionId);
888
1027
  const selected = selectedSet.has(entry.sessionId);
889
1028
  // Every input the row below is derived from. Identity comparison is
@@ -911,6 +1050,9 @@ export function selectSessionRows(state) {
911
1050
  status: session?.status,
912
1051
  statusColor: sessionStatusColor(session, state.connection?.mode || "local"),
913
1052
  active,
1053
+ // The RAW group id (rows are keyed "group:<uuid>"); the placement
1054
+ // API and drag hit-testing both need the bare id.
1055
+ groupId: session?.groupId || entry?.groupId || null,
914
1056
  isSystem: Boolean(session?.isSystem),
915
1057
  isGroup: Boolean(session?.isGroup),
916
1058
  hasChildren: entry.hasChildren,
@@ -918,6 +1060,18 @@ export function selectSessionRows(state) {
918
1060
  pinned,
919
1061
  selected,
920
1062
  canPin: canPinSessionRow(session),
1063
+ // Manual-ordering affordances. Derived HERE so the drag layer and
1064
+ // the tree agree on what may move — a UI that permits a drag the
1065
+ // tree then ignores is worse than offering no drag at all.
1066
+ // The avatar the portal draws in place of the tagged text run.
1067
+ ownerBadge: (shouldDecorateSessionOwners(state) && !session?.isSystem && !session?.isGroup && effectiveSessionOwner(session, state.sessions.byId))
1068
+ ? ownerBadgeFor(effectiveSessionOwner(session, state.sessions.byId), {
1069
+ isMine: Boolean(ownerKeyForOwner(state?.auth?.principal)
1070
+ && ownerKeyForOwner(effectiveSessionOwner(session, state.sessions.byId)) === ownerKeyForOwner(state?.auth?.principal)),
1071
+ })
1072
+ : null,
1073
+ orderable: isManuallyOrderableSession(session),
1074
+ orderContainer: manualOrderContainerKey(session, pinned),
921
1075
  };
922
1076
  memo.set(entry.sessionId, { deps, row });
923
1077
  return row;
@@ -2969,6 +3123,75 @@ export function selectActiveActivity(state) {
2969
3123
  return history?.activity || [];
2970
3124
  }
2971
3125
 
3126
+ /**
3127
+ * Worker details — the pane that REPLACES Activity while the Node Map is up.
3128
+ * Registry specs for the selected node, then the sessions executing on it.
3129
+ */
3130
+ export function selectWorkerDetailsPane(state) {
3131
+ const view = selectNodeMapView(state);
3132
+ const node = view.selected ? view.nodes.find((candidate) => candidate.label === view.selected) : null;
3133
+ const title = buildPaneTitleRuns("Worker", "gray");
3134
+ if (!node) {
3135
+ title.push({ text: " none", color: "gray" });
3136
+ return {
3137
+ title,
3138
+ lines: [{
3139
+ text: view.degraded && view.registryError
3140
+ ? `No worker selected — ${view.registryError}`
3141
+ : "No workers to show yet.",
3142
+ color: "gray",
3143
+ }],
3144
+ };
3145
+ }
3146
+ title.push({ text: ` ${node.label}`, color: "cyan" });
3147
+ const lines = [];
3148
+ const spec = (label, value, color = "white") => {
3149
+ if (value === null || value === undefined || value === "") return;
3150
+ lines.push([{ text: `${label.padEnd(10)} `, color: "gray" }, { text: String(value), color }]);
3151
+ };
3152
+ if (node.registered) {
3153
+ spec("Node", node.workerNodeId);
3154
+ spec("Phase", node.phase, node.phase === "draining" ? "red" : node.phase === "starting" ? "yellow" : "green");
3155
+ spec("Pool", node.pool);
3156
+ if (node.owner) spec("Owner", node.owner);
3157
+ spec("Heartbeat", node.live ? `${node.agoText ?? "now"} · live` : `${node.agoText ?? "unknown"} · stale`, node.live ? "green" : "red");
3158
+ spec("Uptime", node.uptimeText);
3159
+ spec("Memory", [node.rssText ? `rss ${node.rssText}` : null, node.heapText ? `heap ${node.heapText}` : null].filter(Boolean).join(" · ") || null);
3160
+ spec("Loop p99", node.eventLoopText);
3161
+ spec("SDK", node.sdkVersion);
3162
+ spec("Runtime", node.substrate);
3163
+ if (node.capabilities.length) spec("Caps", node.capabilities.join(", "));
3164
+ if (node.consumes.length) spec("Consumes", node.consumes.join(", "));
3165
+ if (node.pkgEpoch !== null) {
3166
+ const ok = node.pkgInstalled.filter((pkg) => pkg.status === "ok").length;
3167
+ const bad = node.pkgInstalled.length - ok;
3168
+ spec("Packages", `epoch ${node.pkgEpoch} · ${ok} ok${bad ? ` · ${bad} error` : ""}`, bad ? "red" : "white");
3169
+ for (const pkg of node.pkgInstalled) {
3170
+ lines.push([
3171
+ { text: " ", color: "gray" },
3172
+ { text: `${pkg.status === "error" ? "✗" : "✓"} `, color: pkg.status === "error" ? "red" : "green" },
3173
+ { text: `${pkg.name}${pkg.semver ? `@${pkg.semver}` : ""}`, color: pkg.status === "error" ? "red" : "white" },
3174
+ ]);
3175
+ if (pkg.error) lines.push([{ text: ` ${pkg.error}`, color: "red" }]);
3176
+ }
3177
+ if (node.pkgLastError) lines.push([{ text: ` last refresh error: ${node.pkgLastError}`, color: "red" }]);
3178
+ }
3179
+ } else {
3180
+ lines.push({ text: "Not in the worker registry — derived from recent activity only.", color: "gray" });
3181
+ }
3182
+ lines.push({ text: "", color: "gray" });
3183
+ lines.push([{ text: `EXECUTING (${node.executing.length})`, color: "cyan", bold: true }]);
3184
+ for (const entry of node.executing) {
3185
+ lines.push(entry.active
3186
+ ? buildActiveHighlightLine(entry.text)
3187
+ : { text: entry.text, color: entry.color, bold: entry.bold });
3188
+ }
3189
+ if (node.executing.length === 0) {
3190
+ lines.push({ text: `Nothing executing in the ${view.windowLabel} window.`, color: "gray" });
3191
+ }
3192
+ return { title, lines };
3193
+ }
3194
+
2972
3195
  export function selectActivityPane(state, maxLines = 12) {
2973
3196
  const activity = selectActiveActivity(state);
2974
3197
  const session = selectActiveSession(state);
@@ -3510,6 +3733,28 @@ export function selectFilesView(state, options = {}) {
3510
3733
  * editing / saving / error), and a small set of "actions" describing
3511
3734
  * what keybindings or buttons are currently meaningful.
3512
3735
  */
3736
+ function adminPkgDate(value) {
3737
+ if (!value) return "—";
3738
+ const date = value instanceof Date ? value : new Date(value);
3739
+ return Number.isNaN(date.getTime()) ? "—" : date.toISOString().slice(0, 10);
3740
+ }
3741
+
3742
+ function adminPkgSize(bytes) {
3743
+ const n = Number(bytes) || 0;
3744
+ if (n <= 0) return "0 B";
3745
+ if (n < 1024) return `${n} B`;
3746
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
3747
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
3748
+ }
3749
+
3750
+ function adminPkgLanguage(filePath) {
3751
+ const lower = String(filePath || "").toLowerCase();
3752
+ if (lower.endsWith(".md")) return "markdown";
3753
+ if (lower.endsWith(".json")) return "json";
3754
+ if (/\.(mjs|cjs|js)$/.test(lower)) return "javascript";
3755
+ return "text";
3756
+ }
3757
+
3513
3758
  export function selectAdminConsole(state) {
3514
3759
  const admin = state.admin || {};
3515
3760
  const profile = admin.profile || null;
@@ -3546,7 +3791,380 @@ export function selectAdminConsole(state) {
3546
3791
  ? "Configured (overrides env GITHUB_TOKEN for this user)"
3547
3792
  : "Not configured (using env GITHUB_TOKEN fallback)");
3548
3793
 
3794
+ // ── Agent packages view-model (Admin → Agents) ───────────────
3795
+ const pkgState = admin.packages || {};
3796
+ const pkgList = Array.isArray(pkgState.list) ? pkgState.list : [];
3797
+ const ownsPackage = (pkg) => Boolean(
3798
+ isAdmin
3799
+ || (pkg?.owner && principal
3800
+ && pkg.owner.provider === principal.provider
3801
+ && pkg.owner.subject === principal.subject),
3802
+ );
3803
+ // Built once per render, not per row.
3804
+ const ownerDirectory = ownerDirectoryFromSessions(state?.sessions?.byId);
3805
+ const packageRow = (pkg) => ({
3806
+ name: pkg.name,
3807
+ scope: pkg.scope === "shared" ? "shared" : "user",
3808
+ enabled: Boolean(pkg.enabled),
3809
+ semver: pkg.active?.semver || null,
3810
+ sha7: pkg.active?.sha256 ? String(pkg.active.sha256).slice(0, 7) : null,
3811
+ agentCount: Array.isArray(pkg.active?.manifest?.agents) ? pkg.active.manifest.agents.length : 0,
3812
+ canManage: ownsPackage(pkg),
3813
+ // A user-scope package belongs to a PERSON, so it carries the same
3814
+ // owner-initials chip the session rows use. Shared packages belong to
3815
+ // the deployment and keep the scope badge instead.
3816
+ // Resolved against the session-owner directory so the initials are the
3817
+ // PERSON's, matching the session list exactly.
3818
+ ownerBadge: pkg.scope === "shared" ? null : ownerBadgeFor(resolvePackageOwner(pkg, ownerDirectory)),
3819
+ // Highlight only while the Agents section is active — otherwise the
3820
+ // GitHub Keys screen shows a double selection.
3821
+ selected: admin.section === "packages" && pkgState.selectedName === pkg.name,
3822
+ });
3823
+ // Admins are served every user-scope package, but another person's private
3824
+ // agents are not part of YOUR workspace — keep them out of the main tree
3825
+ // and behind an explicit "Other users" group so the list stays yours.
3826
+ const isMinePackage = (pkg) => Boolean(
3827
+ pkg?.owner && principal
3828
+ && pkg.owner.provider === principal.provider
3829
+ && pkg.owner.subject === principal.subject,
3830
+ );
3831
+ // The owner filter is a statement about WHOSE work you want to see, and it
3832
+ // means the same thing here: with Sean filtered out, Sean's private agents
3833
+ // are not part of your workspace either.
3834
+ //
3835
+ // NOT matchesOwnerFilterDirect: for sessions, `includeShared` means "things
3836
+ // other people shared WITH me", a real relation that earns them a place in
3837
+ // your list. Another person's private package has no such relation — you
3838
+ // only see it because you are an admin — so reusing that rule would let
3839
+ // every other user's package through and the filter would do nothing.
3840
+ // Someone else's agents appear only when you asked for that someone (or
3841
+ // for everyone). Your own and the deployment's shared packages are always
3842
+ // yours to see, exactly as session groups are exempt.
3843
+ const ownerFilter = state.sessions?.ownerFilter;
3844
+ const passesOwnerFilter = (pkg) => {
3845
+ if (!ownerFilter || ownerFilter.all === true) return true;
3846
+ const ownerKey = ownerKeyForOwner(pkg?.owner);
3847
+ if (!ownerKey) return ownerFilter.includeUnowned === true;
3848
+ return Array.isArray(ownerFilter.ownerKeys) && ownerFilter.ownerKeys.includes(ownerKey);
3849
+ };
3850
+ const sharedRows = pkgList.filter((pkg) => pkg.scope === "shared").map(packageRow);
3851
+ const userRows = pkgList
3852
+ .filter((pkg) => pkg.scope !== "shared" && isMinePackage(pkg))
3853
+ .map(packageRow);
3854
+ const otherUserRows = pkgList
3855
+ .filter((pkg) => pkg.scope !== "shared" && !isMinePackage(pkg) && passesOwnerFilter(pkg))
3856
+ .map((pkg) => ({ ...packageRow(pkg), ownerLabel: pkg?.createdBy || pkg?.owner?.subject || "another user" }));
3857
+
3858
+ // Settings tree — the session-list-slot navigation. Rendered by both
3859
+ // hosts; `kind` drives affordances (section rows switch panes, package
3860
+ // rows select a package).
3861
+ const section = ["packages", "workers"].includes(admin.section) ? admin.section : "ghcp";
3862
+ const settingsTree = [
3863
+ { id: "ghcp", kind: "section", depth: 0, label: "GitHub Keys", selected: section === "ghcp" },
3864
+ { id: "agents", kind: "section", depth: 0, label: "Agents", selected: section === "packages" && !pkgState.selectedName },
3865
+ { id: "group:shared", kind: "group", depth: 1, label: "Shared", count: sharedRows.length },
3866
+ ...sharedRows.map((row) => ({ id: `pkg:${row.name}`, kind: "package", depth: 2, label: row.name, ...row })),
3867
+ { id: "group:user", kind: "group", depth: 1, label: "User", count: userRows.length },
3868
+ ...userRows.map((row) => ({ id: `pkg:${row.name}`, kind: "package", depth: 2, label: row.name, ...row })),
3869
+ ...(otherUserRows.length > 0
3870
+ ? [
3871
+ { id: "group:others", kind: "group", depth: 1, label: "Other users", count: otherUserRows.length },
3872
+ ...otherUserRows.map((row) => ({ id: `pkg:${row.name}`, kind: "package", depth: 2, label: row.name, ...row })),
3873
+ ]
3874
+ : []),
3875
+ // The worker registry is a hard-gated admin read; hide the section
3876
+ // entirely from non-admins rather than showing a doomed pane.
3877
+ ...(isAdmin ? [{ id: "workers", kind: "section", depth: 0, label: "Workers", selected: section === "workers" }] : []),
3878
+ ];
3879
+
3880
+ // Detail view-model for the selected package.
3881
+ const detail = pkgState.detail || null;
3882
+ let packageDetail = null;
3883
+ if (pkgState.selectedName) {
3884
+ const summary = pkgList.find((pkg) => pkg.name === pkgState.selectedName) || null;
3885
+ const activeVersion = detail?.versions?.find((v) => v.versionId === detail.activeVersionId) || null;
3886
+ const manifest = activeVersion?.manifest || summary?.active?.manifest || {};
3887
+ // Workers are ephemeral pods: rows for retired pod names linger until
3888
+ // the server-side prune, so fleet adoption counts only workers whose
3889
+ // heartbeat (updated_at, touched every ~20s poll) is fresh.
3890
+ const FLEET_LIVENESS_MS = 90_000;
3891
+ const liveCutoff = Date.now() - FLEET_LIVENESS_MS;
3892
+ const workerRows = (Array.isArray(pkgState.workerState) ? pkgState.workerState : [])
3893
+ .filter((worker) => {
3894
+ const at = worker?.updatedAt instanceof Date ? worker.updatedAt : new Date(worker?.updatedAt ?? 0);
3895
+ return !Number.isNaN(at.getTime()) && at.getTime() >= liveCutoff;
3896
+ });
3897
+ const fleetTotal = workerRows.length;
3898
+ const fleetCurrent = activeVersion
3899
+ ? workerRows.filter((worker) => worker.installed?.[pkgState.selectedName]?.semver === activeVersion.semver
3900
+ && worker.installed?.[pkgState.selectedName]?.status === "ok").length
3901
+ : 0;
3902
+ const canManage = detail ? ownsPackage(detail) : (summary ? ownsPackage(summary) : false);
3903
+ packageDetail = {
3904
+ name: pkgState.selectedName,
3905
+ loading: Boolean(pkgState.detailLoading),
3906
+ error: pkgState.detailError || null,
3907
+ scope: (detail?.scope || summary?.scope) === "shared" ? "shared" : "user",
3908
+ enabled: detail ? Boolean(detail.enabled) : Boolean(summary?.enabled),
3909
+ canManage,
3910
+ createdBy: detail?.createdBy || summary?.createdBy || null,
3911
+ createdAtText: adminPkgDate(detail?.createdAt || summary?.createdAt),
3912
+ description: typeof manifest.description === "string" ? manifest.description : "",
3913
+ activeSemver: activeVersion?.semver || summary?.active?.semver || null,
3914
+ activeSha12: activeVersion?.sha256 ? String(activeVersion.sha256).slice(0, 12) : null,
3915
+ sizeText: adminPkgSize(activeVersion?.sizeBytes ?? summary?.active?.sizeBytes),
3916
+ agents: Array.isArray(manifest.agents)
3917
+ ? manifest.agents.map((agent) => ({
3918
+ name: agent.name,
3919
+ description: agent.description || "",
3920
+ toolCount: Array.isArray(agent.tools) ? agent.tools.length : 0,
3921
+ skillCount: Array.isArray(agent.skills) ? agent.skills.length : 0,
3922
+ }))
3923
+ : [],
3924
+ versions: (detail?.versions || []).map((version) => ({
3925
+ semver: version.semver,
3926
+ sha12: String(version.sha256 || "").slice(0, 12),
3927
+ dateText: adminPkgDate(version.createdAt),
3928
+ active: version.versionId === detail?.activeVersionId,
3929
+ })),
3930
+ fleet: fleetTotal > 0 && activeVersion
3931
+ ? { current: fleetCurrent, total: fleetTotal, text: `${fleetCurrent}/${fleetTotal} workers current` }
3932
+ : null,
3933
+ actionPending: pkgState.action?.pending || null,
3934
+ actionError: pkgState.action?.error || null,
3935
+ };
3936
+ }
3937
+
3938
+ // Workspace view-model: nested tree rows honoring expandedDirs + preview.
3939
+ const workspaceState = pkgState.workspace || {};
3940
+ let workspace = null;
3941
+ if (pkgState.selectedName) {
3942
+ const tree = workspaceState.tree;
3943
+ const expanded = new Set(workspaceState.expandedDirs || []);
3944
+ const treeRows = [];
3945
+ if (tree) {
3946
+ const visible = (relPath) => {
3947
+ const parts = relPath.split("/");
3948
+ for (let i = 1; i < parts.length; i++) {
3949
+ if (!expanded.has(parts.slice(0, i).join("/"))) return false;
3950
+ }
3951
+ return true;
3952
+ };
3953
+ const treeDirs = Array.isArray(tree.dirs) ? tree.dirs : [];
3954
+ const treeFiles = Array.isArray(tree.files) ? tree.files : [];
3955
+ // Segment-aware sort: "/" must sort before every sibling char or a
3956
+ // dir separates from its children (agents/ vs agents-guide.md).
3957
+ const sortKey = (value) => String(value).split("/").join("\u0000");
3958
+ const nodes = [
3959
+ ...treeDirs.map((dir) => ({ type: "dir", path: dir })),
3960
+ ...treeFiles.map((file) => ({ type: "file", path: file.path, size: file.size })),
3961
+ ].sort((a, b) => (sortKey(a.path) < sortKey(b.path) ? -1 : 1));
3962
+ for (const node of nodes) {
3963
+ if (!visible(node.path)) continue;
3964
+ const depth = node.path.split("/").length - 1;
3965
+ treeRows.push({
3966
+ type: node.type,
3967
+ path: node.path,
3968
+ label: node.path.split("/").pop() + (node.type === "dir" ? "/" : ""),
3969
+ depth,
3970
+ expanded: node.type === "dir" ? expanded.has(node.path) : undefined,
3971
+ sizeText: node.type === "file" ? adminPkgSize(node.size) : null,
3972
+ selected: workspaceState.selectedPath === node.path,
3973
+ });
3974
+ }
3975
+ }
3976
+ const file = workspaceState.file;
3977
+ workspace = {
3978
+ loading: Boolean(workspaceState.treeLoading),
3979
+ error: workspaceState.treeError || null,
3980
+ semver: workspaceState.tree?.semver || null,
3981
+ treeRows,
3982
+ file: file
3983
+ ? {
3984
+ path: file.path,
3985
+ sizeText: adminPkgSize(file.size),
3986
+ truncated: Boolean(file.truncated),
3987
+ isBinary: file.encoding === "base64",
3988
+ text: file.encoding === "base64" ? null : String(file.content || ""),
3989
+ language: adminPkgLanguage(file.path),
3990
+ }
3991
+ : null,
3992
+ fileLoading: Boolean(workspaceState.fileLoading),
3993
+ fileError: workspaceState.fileError || null,
3994
+ selectedPath: workspaceState.selectedPath || null,
3995
+ };
3996
+ }
3997
+
3998
+ // ── Worker registry (Admin → Workers) ────────────────────────
3999
+ // Rows self-prune server-side after 1h; the UI additionally marks a
4000
+ // worker live only when its heartbeat is younger than the same 90s
4001
+ // window the fleet-adoption count uses.
4002
+ const workersState = admin.workers || {};
4003
+ const workerList = Array.isArray(workersState.list) ? workersState.list : [];
4004
+ const WORKERS_LIVE_MS = 90_000;
4005
+ const workersNow = Date.now();
4006
+ const workerAgo = (ms) => {
4007
+ if (!Number.isFinite(ms) || ms < 0) return "—";
4008
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s ago`;
4009
+ if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m ago`;
4010
+ return `${Math.round(ms / 3_600_000)}h ago`;
4011
+ };
4012
+ const workerUptime = (seconds) => {
4013
+ if (!Number.isFinite(seconds) || seconds < 0) return null;
4014
+ if (seconds < 60) return `${Math.round(seconds)}s`;
4015
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
4016
+ const h = Math.floor(seconds / 3600);
4017
+ return h < 48 ? `${h}h ${Math.floor((seconds % 3600) / 60)}m` : `${Math.floor(h / 24)}d ${h % 24}h`;
4018
+ };
4019
+ const workerRegistryRows = workerList
4020
+ .map((worker) => {
4021
+ const at = worker?.updatedAt instanceof Date ? worker.updatedAt : new Date(worker?.updatedAt ?? 0);
4022
+ const ageMs = Number.isNaN(at.getTime()) ? Number.NaN : workersNow - at.getTime();
4023
+ const health = worker?.health || {};
4024
+ const info = worker?.info || {};
4025
+ const pkg = worker?.state?.["agent-packages"] || null;
4026
+ const installed = pkg?.installed && typeof pkg.installed === "object" ? Object.values(pkg.installed) : [];
4027
+ const pkgErrors = installed.filter((entry) => entry?.status === "error").length;
4028
+ return {
4029
+ id: String(worker?.workerNodeId ?? ""),
4030
+ pool: String(worker?.pool ?? "default"),
4031
+ phase: ["starting", "ready", "draining"].includes(worker?.phase) ? worker.phase : "ready",
4032
+ live: Number.isFinite(ageMs) && ageMs <= WORKERS_LIVE_MS,
4033
+ agoText: workerAgo(ageMs),
4034
+ uptimeText: workerUptime(health.uptimeS),
4035
+ rssText: adminPkgSize(health.rssBytes),
4036
+ sessions: Number.isFinite(health.activeSessions) ? health.activeSessions : null,
4037
+ eventLoopText: Number.isFinite(health.eventLoopDelayP99Ms) ? `${health.eventLoopDelayP99Ms}ms` : null,
4038
+ sdkVersion: typeof info.sdkVersion === "string" ? info.sdkVersion : null,
4039
+ substrate: typeof info.runtime?.substrate === "string" ? info.runtime.substrate : null,
4040
+ consumes: Array.isArray(info.consumes) ? info.consumes : [],
4041
+ owner: worker?.owner?.subject ? String(worker.owner.subject) : null,
4042
+ pkgEpoch: Number.isFinite(pkg?.epoch) ? pkg.epoch : null,
4043
+ pkgText: pkg
4044
+ ? `${installed.length - pkgErrors} ok${pkgErrors ? ` · ${pkgErrors} error` : ""}`
4045
+ : null,
4046
+ };
4047
+ })
4048
+ .sort((a, b) => (a.pool === b.pool ? (a.id < b.id ? -1 : 1) : (a.pool < b.pool ? -1 : 1)));
4049
+ const liveWorkerRows = workerRegistryRows.filter((row) => row.live);
4050
+ const workersView = {
4051
+ loading: Boolean(workersState.loading),
4052
+ error: workersState.error || null,
4053
+ empty: workerRegistryRows.length === 0 && !workersState.loading,
4054
+ rows: workerRegistryRows,
4055
+ counts: {
4056
+ registered: workerRegistryRows.length,
4057
+ live: liveWorkerRows.length,
4058
+ ready: liveWorkerRows.filter((row) => row.phase === "ready").length,
4059
+ starting: liveWorkerRows.filter((row) => row.phase === "starting").length,
4060
+ draining: liveWorkerRows.filter((row) => row.phase === "draining").length,
4061
+ pools: [...new Set(liveWorkerRows.map((row) => row.pool))].length,
4062
+ },
4063
+ summaryText: workerRegistryRows.length
4064
+ ? `${liveWorkerRows.length} live / ${workerRegistryRows.length} registered`
4065
+ : null,
4066
+ };
4067
+
4068
+ const packagesView = {
4069
+ loading: Boolean(pkgState.loading),
4070
+ error: pkgState.error || null,
4071
+ empty: pkgList.length === 0 && !pkgState.loading,
4072
+ sharedCount: sharedRows.length,
4073
+ userCount: userRows.length,
4074
+ selectedName: pkgState.selectedName || null,
4075
+ detail: packageDetail,
4076
+ workspace,
4077
+ addDialog: {
4078
+ open: Boolean(pkgState.addDialog?.open),
4079
+ kind: pkgState.addDialog?.kind || "repo",
4080
+ scope: pkgState.addDialog?.scope || "user",
4081
+ repoUrl: pkgState.addDialog?.repoUrl || "",
4082
+ ref: pkgState.addDialog?.ref || "",
4083
+ path: pkgState.addDialog?.path || "",
4084
+ url: pkgState.addDialog?.url || "",
4085
+ authToken: pkgState.addDialog?.authToken || "",
4086
+ submitting: Boolean(pkgState.addDialog?.submitting),
4087
+ progress: pkgState.addDialog?.progress || null,
4088
+ error: pkgState.addDialog?.error || null,
4089
+ },
4090
+ };
4091
+
3549
4092
  const actions = [];
4093
+ if (section === "workers") {
4094
+ actions.push({ id: "workersRefresh", label: "Refresh workers", key: "r" });
4095
+ actions.push({ id: "showPackages", label: "Agents", key: "a" });
4096
+ actions.push({ id: "showGhcp", label: "GitHub Keys", key: "g" });
4097
+ actions.push({ id: "close", label: "Close console", key: "Esc" });
4098
+ return {
4099
+ visible: Boolean(admin.visible),
4100
+ loading: Boolean(admin.loading),
4101
+ loadError: admin.loadError || null,
4102
+ principal,
4103
+ isAdmin,
4104
+ section,
4105
+ settingsTree,
4106
+ packages: packagesView,
4107
+ workers: workersView,
4108
+ ghcpKey: {
4109
+ configured: Boolean(profile?.githubCopilotKeySet),
4110
+ targetConfigured,
4111
+ storeAsSystem,
4112
+ editing: false,
4113
+ draft: "",
4114
+ cursorIndex: 0,
4115
+ saving: false,
4116
+ error: null,
4117
+ statusText: ghcpStatusText,
4118
+ },
4119
+ systemGhcpKey: {
4120
+ supported: Boolean(systemGhcpKey.supported),
4121
+ loading: Boolean(systemGhcpKey.loading),
4122
+ configured: Boolean(systemGhcpKey.configured),
4123
+ changedBy: systemGhcpKey.changedBy || null,
4124
+ changedAt: systemGhcpKey.changedAt || null,
4125
+ error: systemGhcpKey.error || null,
4126
+ },
4127
+ actions,
4128
+ };
4129
+ }
4130
+ if (section === "packages") {
4131
+ actions.push({ id: "packagesRefresh", label: "Refresh packages", key: "r" });
4132
+ actions.push({ id: "packagesNext", label: "Next package", key: "j" });
4133
+ actions.push({ id: "packagesPrev", label: "Previous package", key: "k" });
4134
+ actions.push({ id: "showGhcp", label: "GitHub Keys", key: "g" });
4135
+ actions.push({ id: "close", label: "Close console", key: "Esc" });
4136
+ return {
4137
+ visible: Boolean(admin.visible),
4138
+ loading: Boolean(admin.loading),
4139
+ loadError: admin.loadError || null,
4140
+ principal,
4141
+ isAdmin,
4142
+ section,
4143
+ settingsTree,
4144
+ packages: packagesView,
4145
+ workers: workersView,
4146
+ ghcpKey: {
4147
+ configured: Boolean(profile?.githubCopilotKeySet),
4148
+ targetConfigured,
4149
+ storeAsSystem,
4150
+ editing: false,
4151
+ draft: "",
4152
+ cursorIndex: 0,
4153
+ saving: false,
4154
+ error: null,
4155
+ statusText: ghcpStatusText,
4156
+ },
4157
+ systemGhcpKey: {
4158
+ supported: Boolean(systemGhcpKey.supported),
4159
+ loading: Boolean(systemGhcpKey.loading),
4160
+ configured: Boolean(systemGhcpKey.configured),
4161
+ changedBy: systemGhcpKey.changedBy || null,
4162
+ changedAt: systemGhcpKey.changedAt || null,
4163
+ error: systemGhcpKey.error || null,
4164
+ },
4165
+ actions,
4166
+ };
4167
+ }
3550
4168
  if (!ghcpKey.editing) {
3551
4169
  actions.push({ id: "edit", label: targetConfigured ? `Replace ${keyNoun}` : `Set ${keyNoun}`, key: "e" });
3552
4170
  if (targetConfigured) {
@@ -3559,12 +4177,19 @@ export function selectAdminConsole(state) {
3559
4177
  }
3560
4178
  actions.push({ id: "close", label: "Close console", key: ghcpKey.editing ? "Ctrl+Esc" : "Esc" });
3561
4179
 
4180
+ if (section === "ghcp") {
4181
+ actions.splice(actions.length - 1, 0, { id: "showPackages", label: "Agents", key: "a" });
4182
+ }
3562
4183
  return {
3563
4184
  visible: Boolean(admin.visible),
3564
4185
  loading: Boolean(admin.loading),
3565
4186
  loadError: admin.loadError || null,
3566
4187
  principal,
3567
4188
  isAdmin,
4189
+ section,
4190
+ settingsTree,
4191
+ packages: packagesView,
4192
+ workers: workersView,
3568
4193
  ghcpKey: {
3569
4194
  configured: Boolean(profile?.githubCopilotKeySet),
3570
4195
  targetConfigured,
@@ -4611,92 +5236,228 @@ function buildNodeMapCell(session, brandingTitle, width, active) {
4611
5236
  };
4612
5237
  }
4613
5238
 
4614
- function buildNodeMapLines(state, maxWidth, options = {}) {
4615
- const allowWideColumns = Boolean(options?.allowWideColumns);
4616
- const orderedSessionIds = buildOrderedSessionIds(state);
4617
- if (orderedSessionIds.length === 0) {
4618
- return [plainInspectorLine("No sessions available for the node map.", "gray")];
4619
- }
5239
+ function nodeMapAgoText(ms) {
5240
+ if (!Number.isFinite(ms) || ms < 0) return null;
5241
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s ago`;
5242
+ if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m ago`;
5243
+ return `${Math.round(ms / 3_600_000)}h ago`;
5244
+ }
4620
5245
 
5246
+ function nodeMapUptimeText(seconds) {
5247
+ if (!Number.isFinite(seconds) || seconds < 0) return null;
5248
+ if (seconds < 60) return `${Math.round(seconds)}s`;
5249
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
5250
+ const h = Math.floor(seconds / 3600);
5251
+ return h < 48 ? `${h}h ${Math.floor((seconds % 3600) / 60)}m` : `${Math.floor(h / 24)}d ${h % 24}h`;
5252
+ }
5253
+
5254
+ function nodeMapSessionEntry(session, brandingTitle, active) {
5255
+ const label = session?.isSystem
5256
+ ? canonicalSystemTitle(session, brandingTitle)
5257
+ : (session?.title || shortSessionId(session?.sessionId));
5258
+ const prefix = session?.isSystem ? "⚙ " : session?.serviceKind ? "⚗ " : `${sessionStatusIcon(session) || "."} `;
5259
+ return {
5260
+ sessionId: session?.sessionId || null,
5261
+ text: `${prefix}${label}`,
5262
+ color: session?.isSystem ? "yellow" : sessionStatusColor(session),
5263
+ bold: Boolean(session?.isSystem),
5264
+ active: Boolean(active),
5265
+ };
5266
+ }
5267
+
5268
+ /**
5269
+ * Node Map view-model — registry-first (docs/proposals/worker-registry.md).
5270
+ *
5271
+ * Nodes lead with the WORKER REGISTRY: every registered worker with phase,
5272
+ * pool, and health specs, unioned with any node seen in recent activity
5273
+ * history (covers deployments/credentials without registry access — the view
5274
+ * degrades to activity-derived nodes rather than going blank). Sessions map
5275
+ * onto nodes exactly the way the legacy grid mapped them: last known
5276
+ * workerNodeId in the recent window. Both hosts render from this one VM.
5277
+ */
5278
+ export function selectNodeMapView(state) {
4621
5279
  const recentWindow = getRecentActivityWindow(state);
4622
- const nodeSessionMap = new Map();
4623
- const knownNodes = buildNodeMapNodeUnionForWindow(state, recentWindow);
4624
- let missingHistoryCount = 0;
4625
- let inWindowSessionCount = 0;
5280
+ const brandingTitle = state.branding?.title || "PilotSwarm";
5281
+ const activeSessionId = state.sessions.activeSessionId;
5282
+ const registry = Array.isArray(state.admin?.workers?.list) ? state.admin.workers.list : [];
5283
+ const now = Date.now();
5284
+ const NODE_LIVE_MS = 90_000;
5285
+
5286
+ const byLabel = new Map();
5287
+ for (const worker of registry) {
5288
+ const label = shortNodeLabel(worker?.workerNodeId);
5289
+ if (!label) continue;
5290
+ const at = worker?.updatedAt instanceof Date ? worker.updatedAt.getTime() : new Date(worker?.updatedAt ?? 0).getTime();
5291
+ const ageMs = Number.isFinite(at) ? now - at : Number.NaN;
5292
+ const health = worker?.health || {};
5293
+ byLabel.set(label, {
5294
+ label,
5295
+ workerNodeId: String(worker?.workerNodeId ?? ""),
5296
+ registered: true,
5297
+ live: Number.isFinite(ageMs) && ageMs <= NODE_LIVE_MS,
5298
+ phase: ["starting", "ready", "draining"].includes(worker?.phase) ? worker.phase : "ready",
5299
+ pool: String(worker?.pool ?? "default"),
5300
+ agoText: nodeMapAgoText(ageMs),
5301
+ uptimeText: nodeMapUptimeText(health.uptimeS),
5302
+ rssText: Number.isFinite(health.rssBytes) ? adminPkgSize(health.rssBytes) : null,
5303
+ heapText: Number.isFinite(health.heapUsedBytes) ? adminPkgSize(health.heapUsedBytes) : null,
5304
+ eventLoopText: Number.isFinite(health.eventLoopDelayP99Ms) ? `${health.eventLoopDelayP99Ms}ms` : null,
5305
+ sessions: Number.isFinite(health.activeSessions) ? health.activeSessions : null,
5306
+ sdkVersion: typeof worker?.info?.sdkVersion === "string" ? worker.info.sdkVersion : null,
5307
+ substrate: typeof worker?.info?.runtime?.substrate === "string" ? worker.info.runtime.substrate : null,
5308
+ capabilities: worker?.info?.capabilities && typeof worker.info.capabilities === "object"
5309
+ ? Object.entries(worker.info.capabilities).filter(([, on]) => Boolean(on)).map(([cap]) => cap).sort()
5310
+ : [],
5311
+ consumes: Array.isArray(worker?.info?.consumes) ? worker.info.consumes : [],
5312
+ owner: worker?.owner?.subject ? String(worker.owner.subject) : null,
5313
+ pkgEpoch: Number.isFinite(worker?.state?.["agent-packages"]?.epoch) ? worker.state["agent-packages"].epoch : null,
5314
+ pkgInstalled: worker?.state?.["agent-packages"]?.installed && typeof worker.state["agent-packages"].installed === "object"
5315
+ ? Object.entries(worker.state["agent-packages"].installed).map(([name, entry]) => ({
5316
+ name,
5317
+ semver: entry?.semver ?? null,
5318
+ status: entry?.status === "error" ? "error" : "ok",
5319
+ error: entry?.error ? String(entry.error) : null,
5320
+ }))
5321
+ : [],
5322
+ pkgLastError: worker?.state?.["agent-packages"]?.lastError ? String(worker.state["agent-packages"].lastError) : null,
5323
+ executing: [],
5324
+ });
5325
+ }
5326
+ for (const label of buildNodeMapNodeUnionForWindow(state, recentWindow)) {
5327
+ if (byLabel.has(label)) continue;
5328
+ byLabel.set(label, {
5329
+ label, workerNodeId: null, registered: false, live: true,
5330
+ phase: null, pool: null, agoText: null, uptimeText: null,
5331
+ rssText: null, heapText: null, eventLoopText: null, sessions: null,
5332
+ sdkVersion: null, substrate: null, capabilities: [], consumes: [],
5333
+ owner: null, pkgEpoch: null, pkgInstalled: [], pkgLastError: null,
5334
+ executing: [],
5335
+ });
5336
+ }
4626
5337
 
4627
- for (const sessionId of orderedSessionIds) {
5338
+ // Sessions → nodes: last known workerNodeId inside the recent window.
5339
+ let missingHistoryCount = 0;
5340
+ for (const sessionId of buildOrderedSessionIds(state)) {
4628
5341
  const session = state.sessions.byId[sessionId];
4629
5342
  if (!session) continue;
4630
5343
  const history = state.history.bySessionId.get(sessionId);
4631
5344
  if (!history?.events) missingHistoryCount += 1;
4632
5345
  const nodeLabel = getLastKnownSessionNode(history, recentWindow);
4633
- if (!nodeLabel || !knownNodes.includes(nodeLabel)) continue;
4634
- inWindowSessionCount += 1;
4635
- if (!nodeSessionMap.has(nodeLabel)) {
4636
- nodeSessionMap.set(nodeLabel, []);
4637
- }
4638
- nodeSessionMap.get(nodeLabel).push(session);
4639
- }
4640
-
4641
- if (knownNodes.length === 0) {
4642
- return [plainInspectorLine(`No worker activity in the ${recentWindow.label} window.`, "gray")];
5346
+ const node = nodeLabel ? byLabel.get(nodeLabel) : null;
5347
+ if (!node) continue;
5348
+ node.executing.push(nodeMapSessionEntry(session, brandingTitle, sessionId === activeSessionId));
4643
5349
  }
4644
5350
 
4645
- const availableWidth = Math.max(18, maxWidth);
4646
- const maxColumns = Math.max(1, Math.floor((availableWidth + 1) / 10));
4647
- let nodeLabels = knownNodes;
4648
- if (!allowWideColumns && knownNodes.length > maxColumns) {
4649
- const visibleCount = Math.max(0, maxColumns - 1);
4650
- const overflowSessions = [];
4651
- const visibleLabels = knownNodes.slice(0, visibleCount);
4652
- for (const hiddenLabel of knownNodes.slice(visibleCount)) {
4653
- overflowSessions.push(...(nodeSessionMap.get(hiddenLabel) || []));
4654
- }
4655
- nodeSessionMap.set("…", overflowSessions);
4656
- nodeLabels = [...visibleLabels, "…"];
4657
- }
4658
-
4659
- const gapWidth = Math.max(0, nodeLabels.length - 1);
4660
- const colWidth = Math.max(8, Math.floor((availableWidth - gapWidth) / Math.max(1, nodeLabels.length)));
4661
- const maxRows = nodeLabels.reduce(
4662
- (max, nodeLabel) => Math.max(max, (nodeSessionMap.get(nodeLabel) || []).length),
4663
- 0,
4664
- );
5351
+ const nodes = Array.from(byLabel.values()).sort((a, b) => {
5352
+ if (a.registered !== b.registered) return a.registered ? -1 : 1;
5353
+ if (a.live !== b.live) return a.live ? -1 : 1;
5354
+ const poolCmp = String(a.pool ?? "~").localeCompare(String(b.pool ?? "~"));
5355
+ if (poolCmp !== 0) return poolCmp;
5356
+ return a.label.localeCompare(b.label);
5357
+ });
5358
+ nodes.forEach((node, index) => { node.ordinal = index + 1; });
5359
+
5360
+ const registeredNodes = nodes.filter((node) => node.registered);
5361
+ // A node is always selected while the Node Map is up: the remembered
5362
+ // choice when it is still in the fleet, otherwise the first row. The
5363
+ // remembered value is never cleared, so switching away and back restores
5364
+ // it — and a worker that vanishes falls back instead of blanking.
5365
+ const remembered = state.ui?.nodeMapSelectedNode;
5366
+ const selected = (remembered && nodes.some((node) => node.label === remembered))
5367
+ ? remembered
5368
+ : (nodes[0]?.label ?? null);
4665
5369
 
4666
- const lines = [
4667
- plainInspectorLine(`Window: ${recentWindow.label}`, "gray"),
4668
- buildNodeMapHeaderLine(nodeLabels, colWidth),
4669
- plainInspectorLine(nodeLabels.map(() => "─".repeat(colWidth)).join(" "), "gray"),
4670
- ];
5370
+ return {
5371
+ windowLabel: recentWindow.label,
5372
+ degraded: registeredNodes.length === 0,
5373
+ registryError: state.admin?.workers?.error || null,
5374
+ registryFetchedAt: state.admin?.workers?.fetchedAt || 0,
5375
+ registryLoading: Boolean(state.admin?.workers?.loading),
5376
+ registryAttempts: state.admin?.workers?.attempts || 0,
5377
+ registryLastAttemptAt: state.admin?.workers?.lastAttemptAt || 0,
5378
+ registryLastSkip: state.admin?.workers?.lastSkip || null,
5379
+ registered: registeredNodes.length,
5380
+ liveCount: registeredNodes.filter((node) => node.live).length,
5381
+ executingTotal: nodes.reduce((sum, node) => sum + node.executing.length, 0),
5382
+ missingHistoryCount,
5383
+ selected,
5384
+ nodes,
5385
+ };
5386
+ }
4671
5387
 
4672
- for (let rowIndex = 0; rowIndex < maxRows; rowIndex += 1) {
4673
- const rowRuns = [];
4674
- nodeLabels.forEach((nodeLabel, columnIndex) => {
4675
- if (columnIndex > 0) rowRuns.push({ text: " ", color: null });
4676
- const session = (nodeSessionMap.get(nodeLabel) || [])[rowIndex];
4677
- if (!session) {
4678
- rowRuns.push({ text: " ".repeat(colWidth), color: null });
4679
- return;
4680
- }
4681
- rowRuns.push(buildNodeMapCell(
4682
- session,
4683
- state.branding?.title || "PilotSwarm",
4684
- colWidth,
4685
- session.sessionId === state.sessions.activeSessionId,
4686
- ));
4687
- });
4688
- lines.push(rowRuns);
5388
+ function buildNodeMapLines(state, maxWidth, options = {}) {
5389
+ void options;
5390
+ const view = selectNodeMapView(state);
5391
+ if (view.nodes.length === 0) {
5392
+ return [plainInspectorLine(
5393
+ view.degraded
5394
+ ? `No workers registered and no worker activity in the ${view.windowLabel} window.`
5395
+ : `No worker activity in the ${view.windowLabel} window.`,
5396
+ "gray")];
4689
5397
  }
4690
5398
 
4691
- if (missingHistoryCount > 0) {
4692
- lines.push(plainInspectorLine("", "gray"));
4693
- lines.push(plainInspectorLine(`Loading worker history for ${missingHistoryCount} session(s)…`, "gray"));
5399
+ const lines = [];
5400
+ lines.push([{
5401
+ text: view.degraded
5402
+ ? `Nodes (activity-derived) · window ${view.windowLabel}`
5403
+ : `Nodes · ${view.liveCount} live / ${view.registered} registered · window ${view.windowLabel}`,
5404
+ color: "gray",
5405
+ }]);
5406
+ if (view.degraded) {
5407
+ // Say WHY the registry is absent — three different failures used to
5408
+ // collapse into one silent "(activity-derived)" header.
5409
+ if (view.registryError) {
5410
+ lines.push([{ text: `! worker registry unavailable: ${view.registryError}`, color: "red" }]);
5411
+ } else if (view.registryFetchedAt) {
5412
+ lines.push([{ text: "registry reachable but EMPTY — no worker has heartbeated in the last hour", color: "yellow" }]);
5413
+ } else if (view.registryLoading) {
5414
+ lines.push([{ text: "registry request in flight… (times out red after 10s)", color: "yellow" }]);
5415
+ } else if (view.registryAttempts === 0) {
5416
+ // No attempt has EVER been recorded: the refresh call is not
5417
+ // reaching the controller at all (host wiring), which used to be
5418
+ // indistinguishable from "the fetch failed".
5419
+ lines.push([{ text: "registry refresh has NEVER been attempted in this tab — reload; if it persists the refresh call is not reaching the controller", color: "red" }]);
5420
+ } else {
5421
+ const ago = view.registryLastAttemptAt ? Math.round((Date.now() - view.registryLastAttemptAt) / 1000) : null;
5422
+ lines.push([{
5423
+ text: `registry attempted ${view.registryAttempts}× (last ${ago === null ? "?" : `${ago}s`} ago${view.registryLastSkip ? `, skipped: ${view.registryLastSkip}` : ""}) but no rows and no error — reload if this persists`,
5424
+ color: "yellow",
5425
+ }]);
5426
+ }
4694
5427
  }
4695
- if (inWindowSessionCount === 0) {
4696
- lines.push(plainInspectorLine("", "gray"));
4697
- lines.push(plainInspectorLine(`No sessions mapped onto worker nodes in the ${recentWindow.label} window.`, "gray"));
5428
+ lines.push(plainInspectorLine("", "gray"));
5429
+
5430
+ for (const node of view.nodes) {
5431
+ const isSelected = node.label === view.selected;
5432
+ const dot = node.live ? "●" : "○";
5433
+ const dotColor = !node.live ? "gray" : node.phase === "draining" ? "red" : node.phase === "starting" ? "yellow" : "green";
5434
+ const runs = [
5435
+ { text: isSelected ? "› " : " ", color: "green", bold: isSelected, nodeSelect: node.label, nodeSelected: isSelected },
5436
+ { text: node.ordinal <= 9 ? `${node.ordinal} ` : " ", color: "gray", nodeSelect: node.label },
5437
+ { text: `${dot} `, color: dotColor, nodeSelect: node.label },
5438
+ { text: node.label.padEnd(7), color: isSelected ? "white" : node.live ? "white" : "gray", bold: isSelected, nodeSelect: node.label },
5439
+ ];
5440
+ if (node.registered) {
5441
+ runs.push({ text: ` ${(node.phase || "").padEnd(8)}`, color: dotColor, nodeSelect: node.label });
5442
+ runs.push({ text: ` ${node.pool}`, color: "gray", nodeSelect: node.label });
5443
+ const specs = [
5444
+ node.executing.length ? `${node.executing.length} sess` : null,
5445
+ node.rssText, node.uptimeText ? `up ${node.uptimeText}` : null, node.agoText,
5446
+ ].filter(Boolean).join(" · ");
5447
+ if (specs) runs.push({ text: ` ${specs}`, color: "gray", nodeSelect: node.label });
5448
+ } else {
5449
+ runs.push({ text: " activity only", color: "gray", nodeSelect: node.label });
5450
+ if (node.executing.length) runs.push({ text: ` · ${node.executing.length} sess`, color: "gray", nodeSelect: node.label });
5451
+ }
5452
+ lines.push(trimTrailingRunPad(runs));
4698
5453
  }
4699
5454
 
5455
+ lines.push(plainInspectorLine("", "gray"));
5456
+ lines.push(plainInspectorLine("Click a node (or press 1-9) — its details fill the pane below.", "gray"));
5457
+
5458
+ if (view.missingHistoryCount > 0) {
5459
+ lines.push(plainInspectorLine(`Loading worker history for ${view.missingHistoryCount} session(s)…`, "gray"));
5460
+ }
4700
5461
  return lines;
4701
5462
  }
4702
5463
 
@@ -4804,18 +5565,40 @@ export function selectSessionAgentPickerModal(state, maxWidth = 76) {
4804
5565
  const items = Array.isArray(modal.items) ? modal.items : [];
4805
5566
  const selectedIndex = Math.max(0, Number(modal.selectedIndex) || 0);
4806
5567
  const contentWidth = Math.max(24, maxWidth - 4);
4807
- const rows = items.map((item, index) => {
5568
+ // Items arrive pre-sorted Shared → My agents → Generic (controller);
5569
+ // emit a non-clickable heading row at each group boundary. rowItemIndexes
5570
+ // maps rendered rows back to item indexes (null = heading) — the same
5571
+ // mechanism the model picker uses.
5572
+ const GROUP_HEADINGS = { shared: "Shared", mine: "My agents", generic: "" };
5573
+ // Package-scope vocabulary only makes sense when packages exist: a pure
5574
+ // baked deployment keeps the classic flat list, no headings.
5575
+ const hasPackageItems = items.some((item) => item?.packageName);
5576
+ const rows = [];
5577
+ const rowItemIndexes = [];
5578
+ let lastGroup = null;
5579
+ items.forEach((item, index) => {
5580
+ const group = item?.group || "shared";
5581
+ if (hasPackageItems && group !== lastGroup) {
5582
+ const heading = GROUP_HEADINGS[group] ?? "";
5583
+ rows.push([{ text: heading ? `── ${heading} ──` : "──", color: "gray" }]);
5584
+ rowItemIndexes.push(null);
5585
+ lastGroup = group;
5586
+ }
4808
5587
  const isSelected = index === selectedIndex;
5588
+ const suffix = item?.kind === "generic"
5589
+ ? " [generic]"
5590
+ : item?.packageName
5591
+ ? ` ${item.packageName} ${item.packageSemver || ""}`.trimEnd()
5592
+ : ` (${item?.agentName || item?.id || "agent"})`;
4809
5593
  const labelRuns = fitRuns([
4810
5594
  { text: item?.kind === "generic" ? "○ " : "· ", color: "gray" },
4811
5595
  { text: item?.title || item?.agentName || item?.id || "Agent", color: "white", bold: true },
4812
- ...(item?.kind === "generic"
4813
- ? [{ text: " [generic]", color: "gray" }]
4814
- : [{ text: ` (${item?.agentName || item?.id || "agent"})`, color: "gray" }]),
5596
+ { text: suffix, color: "gray" },
4815
5597
  ], contentWidth);
4816
- return isSelected
5598
+ rows.push(isSelected
4817
5599
  ? buildActiveHighlightLine(labelRuns.map((run) => run.text).join("").padEnd(contentWidth, " "))
4818
- : labelRuns;
5600
+ : labelRuns);
5601
+ rowItemIndexes.push(index);
4819
5602
  });
4820
5603
 
4821
5604
  const selectedItem = items[selectedIndex] || null;
@@ -4831,6 +5614,9 @@ export function selectSessionAgentPickerModal(state, maxWidth = 76) {
4831
5614
  ...(selectedItem.kind === "generic"
4832
5615
  ? [[{ text: "Open-ended session", color: "gray" }]]
4833
5616
  : [[{ text: selectedItem.agentName || selectedItem.id || "agent", color: "gray" }]]),
5617
+ ...(selectedItem.packageName
5618
+ ? [[{ text: `Package: ${selectedItem.packageName}@${selectedItem.packageSemver || "?"} · ${selectedItem.packageScope || "shared"}`, color: "gray" }]]
5619
+ : (selectedItem.kind === "agent" ? [[{ text: "Built-in agent", color: "gray" }]] : [])),
4834
5620
  ...(selectedModel ? [[{ text: `Model: ${selectedModel}`, color: "gray" }]] : []),
4835
5621
  ...(selectedReasoningEffort ? [[{ text: `Reasoning: ${selectedReasoningEffort}`, color: "gray" }]] : []),
4836
5622
  [{ text: "", color: "gray" }],
@@ -4852,10 +5638,12 @@ export function selectSessionAgentPickerModal(state, maxWidth = 76) {
4852
5638
  ]
4853
5639
  : [[{ text: "No agent selected.", color: "gray" }]];
4854
5640
 
5641
+ const selectedRowIndex = rowItemIndexes.indexOf(selectedIndex);
4855
5642
  return {
4856
5643
  title: modal.title || "Select agent for new session",
4857
5644
  rows,
4858
- selectedRowIndex: selectedIndex,
5645
+ rowItemIndexes,
5646
+ selectedRowIndex: selectedRowIndex >= 0 ? selectedRowIndex : 0,
4859
5647
  detailsTitle: "Agent Details",
4860
5648
  detailsLines,
4861
5649
  idealWidth: Math.min(