pilotswarm 0.5.30 → 0.5.32

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 (83) hide show
  1. package/package.json +2 -2
  2. package/tui/src/node-sdk-transport.js +4 -0
  3. package/ui/core/src/controller.js +102 -30
  4. package/ui/core/src/reducer.js +50 -1
  5. package/ui/core/src/selectors.js +68 -22
  6. package/ui/core/src/state.js +2 -0
  7. package/ui/core/src/themes/doom.js +106 -0
  8. package/ui/core/src/themes/helpers.js +7 -1
  9. package/ui/core/src/themes/index.js +4 -0
  10. package/ui/core/src/themes/winamp.js +104 -0
  11. package/ui/react/src/components.js +25 -0
  12. package/ui/react/src/web-app.js +320 -51
  13. package/web/api/ws.js +7 -0
  14. package/web/authz.js +14 -81
  15. package/web/dist/assets/{abnfDiagram-VRR7QNED-Cimc0f-u.js → abnfDiagram-VRR7QNED-mXFQ4a3c.js} +1 -1
  16. package/web/dist/assets/{arc-D8-_Dx5J.js → arc-BPuuvbge.js} +1 -1
  17. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-lzMAuQHJ.js → architectureDiagram-ZJ3FMSHR-Dt4uUYIe.js} +1 -1
  18. package/web/dist/assets/{blockDiagram-677ZJIJ3-Dp2CWjVS.js → blockDiagram-677ZJIJ3-Ax8Q22vV.js} +1 -1
  19. package/web/dist/assets/{c4Diagram-LMCZKHZV-DF93UkOy.js → c4Diagram-LMCZKHZV-CPiukyHt.js} +1 -1
  20. package/web/dist/assets/channel-BCzDctYg.js +1 -0
  21. package/web/dist/assets/{chunk-2Q5K7J3B-pbsQRM-F.js → chunk-2Q5K7J3B-VJLqCZJX.js} +1 -1
  22. package/web/dist/assets/{chunk-32BRIVSS-B_f-gG1O.js → chunk-32BRIVSS-DqOshxhf.js} +1 -1
  23. package/web/dist/assets/{chunk-5VM5RSS4-gqlpJDmt.js → chunk-5VM5RSS4-KPqV21jV.js} +1 -1
  24. package/web/dist/assets/{chunk-EX3LRPZG-Cms7hPmM.js → chunk-EX3LRPZG-DfhiCVl_.js} +1 -1
  25. package/web/dist/assets/{chunk-JWPE2WC7-Boq5PtPI.js → chunk-JWPE2WC7-H6khJPuW.js} +1 -1
  26. package/web/dist/assets/{chunk-MOJQB5TN-DVn9i8zt.js → chunk-MOJQB5TN-BKMAzQxj.js} +1 -1
  27. package/web/dist/assets/{chunk-RYQCIY6F-a1vKs3UX.js → chunk-RYQCIY6F-DNXRTxtj.js} +1 -1
  28. package/web/dist/assets/{chunk-V7JOEXUC-i60p5fHd.js → chunk-V7JOEXUC-B56PM6Gv.js} +1 -1
  29. package/web/dist/assets/{chunk-VR4S4FIN-a8p0o-8B.js → chunk-VR4S4FIN-Boi3hGkB.js} +1 -1
  30. package/web/dist/assets/{chunk-XXDRQBXY-BwP1yBKd.js → chunk-XXDRQBXY-DmJyn890.js} +1 -1
  31. package/web/dist/assets/classDiagram-OUVF2IWQ-ChqjVZil.js +1 -0
  32. package/web/dist/assets/classDiagram-v2-EOCWNBFH-ChqjVZil.js +1 -0
  33. package/web/dist/assets/{cose-bilkent-JH36ORCC-HW6kGgze.js → cose-bilkent-JH36ORCC-BRdFlFWt.js} +1 -1
  34. package/web/dist/assets/{cynefin-VYW2F7L2-YUFMn-vE.js → cynefin-VYW2F7L2-DqE-xib2.js} +1 -1
  35. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-Brdy17i4.js → cynefinDiagram-TSTJHNR4-D-QBZjIL.js} +1 -1
  36. package/web/dist/assets/{dagre-VKFMJZFB-CAiSJJj-.js → dagre-VKFMJZFB-DYu9oQV6.js} +1 -1
  37. package/web/dist/assets/{diagram-FQU43EPY-B63Cqe0J.js → diagram-FQU43EPY-DOwYccCg.js} +1 -1
  38. package/web/dist/assets/{diagram-G47NLZAW-ByktrDJ2.js → diagram-G47NLZAW-DTSEmrPr.js} +1 -1
  39. package/web/dist/assets/{diagram-NH7WQ7WH-BLSfRMoE.js → diagram-NH7WQ7WH-OqbzzquA.js} +1 -1
  40. package/web/dist/assets/{diagram-OA4YK3LP-DNVu-zpk.js → diagram-OA4YK3LP-BMVG6A8I.js} +1 -1
  41. package/web/dist/assets/{diagram-WEI45ONY-CNf4Sz-I.js → diagram-WEI45ONY-Rn2Q5xaz.js} +1 -1
  42. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-9CT3ergx.js → ebnfDiagram-CCIWWBDH-aL32_v5y.js} +1 -1
  43. package/web/dist/assets/{erDiagram-Q63AITRT-BPJHAMgl.js → erDiagram-Q63AITRT-DKCpyczx.js} +1 -1
  44. package/web/dist/assets/{flowDiagram-23GEKE2U-BVtpr6Iu.js → flowDiagram-23GEKE2U-CrZTPgR_.js} +1 -1
  45. package/web/dist/assets/{ganttDiagram-NO4QXBWP-BTHnv9sV.js → ganttDiagram-NO4QXBWP-DnSE1OtC.js} +1 -1
  46. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-BfmtqbL5.js → gitGraphDiagram-IHSO6WYX-C5SeYPUR.js} +1 -1
  47. package/web/dist/assets/{index-Bmb8qK39.js → index-B-TEKrRB.js} +6 -6
  48. package/web/dist/assets/index-D2B5bzSN.css +1 -0
  49. package/web/dist/assets/{infoDiagram-FWYZ7A6U-kU4i4HRu.js → infoDiagram-FWYZ7A6U-DDnJZp0X.js} +1 -1
  50. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-Ch7i-E8O.js → ishikawaDiagram-FXEZZL3T-ewhzfFkq.js} +1 -1
  51. package/web/dist/assets/{journeyDiagram-5HDEW3XC-CPQxp0ps.js → journeyDiagram-5HDEW3XC-T0iWWkLM.js} +1 -1
  52. package/web/dist/assets/{kanban-definition-HUTT4EX6-BBgMVqoJ.js → kanban-definition-HUTT4EX6-D1MFQZjb.js} +1 -1
  53. package/web/dist/assets/{linear-rwksvzXJ.js → linear-BKfPK9wE.js} +1 -1
  54. package/web/dist/assets/{mermaid.core-o7PszRUc.js → mermaid.core-CyzD7dTc.js} +4 -4
  55. package/web/dist/assets/{mindmap-definition-LN4V7U3C-wgOTdzlq.js → mindmap-definition-LN4V7U3C-CWXtcTlP.js} +1 -1
  56. package/web/dist/assets/{papaparse.min-BRXtjEI6.js → papaparse.min-DLUoV5k5.js} +1 -1
  57. package/web/dist/assets/{pegDiagram-2B236MQR-BkhQqqTm.js → pegDiagram-2B236MQR-BFjZun-r.js} +1 -1
  58. package/web/dist/assets/{pieDiagram-ENE6RG2P-CcMnvxUz.js → pieDiagram-ENE6RG2P-DMDtW4g7.js} +1 -1
  59. package/web/dist/assets/pilotswarm-DA0u7Cq_.js +99 -0
  60. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-_KD1QeEr.js → quadrantDiagram-ABIIQ3AL-CAwPVaYL.js} +1 -1
  61. package/web/dist/assets/{railroadDiagram-RFXS5EU6-CqI0olIe.js → railroadDiagram-RFXS5EU6-Co102PkQ.js} +1 -1
  62. package/web/dist/assets/{requirementDiagram-TGXJPOKE-1JxRwusF.js → requirementDiagram-TGXJPOKE-D19VlRSl.js} +1 -1
  63. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-9IpFoKzi.js → sankeyDiagram-HTMAVEWB-Sd_a3ukL.js} +1 -1
  64. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-teyObSQ9.js → sequenceDiagram-DBY2YBRQ-CojDRK1c.js} +1 -1
  65. package/web/dist/assets/{sizeCapture-X5ZJPWSS-DVhU2J_6.js → sizeCapture-X5ZJPWSS-XqXcxnDo.js} +1 -1
  66. package/web/dist/assets/{stateDiagram-2N3HPSRC-DHPoWpIQ.js → stateDiagram-2N3HPSRC-BcN_w699.js} +1 -1
  67. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BV_w1D3C.js +1 -0
  68. package/web/dist/assets/{swimlanes-5IMT3BWC-BxTtDQoB.js → swimlanes-5IMT3BWC-BcO6ai_I.js} +2 -2
  69. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BnYdP0qP.js +8 -0
  70. package/web/dist/assets/{timeline-definition-FHXFAJF6-D0wXZx-4.js → timeline-definition-FHXFAJF6-DOPfYxUq.js} +1 -1
  71. package/web/dist/assets/{vennDiagram-L72KCM5P-BSFyI3iV.js → vennDiagram-L72KCM5P-BSKLkEOC.js} +1 -1
  72. package/web/dist/assets/{wardleyDiagram-EHGQE667-BN-S7hh0.js → wardleyDiagram-EHGQE667-BdcYAVw2.js} +1 -1
  73. package/web/dist/assets/{xychartDiagram-FW5EYKEG-nDNuPs7o.js → xychartDiagram-FW5EYKEG-C6EJT4jB.js} +1 -1
  74. package/web/dist/index.html +3 -3
  75. package/web/runtime.js +55 -0
  76. package/web/server.js +7 -0
  77. package/web/dist/assets/channel-OwWeiCox.js +0 -1
  78. package/web/dist/assets/classDiagram-OUVF2IWQ-CDaP7nuC.js +0 -1
  79. package/web/dist/assets/classDiagram-v2-EOCWNBFH-CDaP7nuC.js +0 -1
  80. package/web/dist/assets/index-B37T1c2s.css +0 -1
  81. package/web/dist/assets/pilotswarm-DYXtzuUN.js +0 -99
  82. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DJjZWYLS.js +0 -1
  83. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CV12vDcK.js +0 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pilotswarm",
3
- "version": "0.5.30",
3
+ "version": "0.5.32",
4
4
  "description": "PilotSwarm application package: terminal UI, browser portal + Web API server, and MCP server — one install, three bins.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -81,7 +81,7 @@
81
81
  "ink": "^6.8.0",
82
82
  "jose": "^6.2.2",
83
83
  "mermaid": "^11.16.0",
84
- "pilotswarm-sdk": "0.5.30",
84
+ "pilotswarm-sdk": "0.5.32",
85
85
  "react": "^19.2.4",
86
86
  "react-dom": "^19.2.4",
87
87
  "ws": "^8.18.2"
@@ -1172,6 +1172,10 @@ export class NodeSdkTransport {
1172
1172
  return this.mgmt.recordAuthzAudit(entry);
1173
1173
  }
1174
1174
 
1175
+ async recordUserRole(principal, role) {
1176
+ return this.mgmt.recordUserRole(principal, role);
1177
+ }
1178
+
1175
1179
  async listAuthzAudit(opts) {
1176
1180
  return this.mgmt.listAuthzAudit(opts);
1177
1181
  }
@@ -2814,6 +2814,29 @@ export class PilotSwarmUiController {
2814
2814
  // the current package's workspace (stale-response race).
2815
2815
  if (this.getState().admin?.packages?.selectedName !== name) return;
2816
2816
  this.dispatch({ type: "admin/packages/tree/loaded", name, tree });
2817
+ // The package's own CHANGELOG, when it ships one. Loaded
2818
+ // alongside the tree rather than on demand because it is
2819
+ // the first thing a reviewer wants: what changed, why, and
2820
+ // whether an agent or a human authored it.
2821
+ const hasChangelog = tree?.files?.some((f) => f.path === "CHANGELOG.md");
2822
+ if (hasChangelog && typeof this.transport.getAgentPackageFile === "function") {
2823
+ void (async () => {
2824
+ try {
2825
+ const file = await this.transport.getAgentPackageFile(name, null, "CHANGELOG.md");
2826
+ if (this.getState().admin?.packages?.selectedName !== name) return;
2827
+ this.dispatch({
2828
+ type: "admin/packages/changelog/loaded",
2829
+ name,
2830
+ content: typeof file === "string" ? file : (file?.content ?? file?.text ?? ""),
2831
+ });
2832
+ } catch {
2833
+ // A missing/unreadable changelog is not an error
2834
+ // worth interrupting package browsing for.
2835
+ }
2836
+ })();
2837
+ } else {
2838
+ this.dispatch({ type: "admin/packages/changelog/loaded", name, content: null });
2839
+ }
2817
2840
  // Default preview: plugin.json (always present in a valid package).
2818
2841
  const first = tree?.files?.find((f) => f.path === "plugin.json") ?? tree?.files?.[0];
2819
2842
  if (first) void this.selectAdminPackageFile(first.path);
@@ -4422,16 +4445,53 @@ export class PilotSwarmUiController {
4422
4445
  this.dispatch({ type: "ui/chatViewMode", mode });
4423
4446
  }
4424
4447
 
4425
- async openSessionAgentPicker(options = {}) {
4426
- const agents = typeof this.transport.listCreatableAgents === "function"
4427
- ? await this.transport.listCreatableAgents()
4428
- : [];
4448
+ /**
4449
+ * Opens the agent step of the new-session flow.
4450
+ *
4451
+ * This step needs a network round trip (listCreatableAgents) before it can
4452
+ * render, and it is reached from the middle of a modal chain. Callers used
4453
+ * to close the current step BEFORE awaiting it, which left the overlay
4454
+ * unmounted for the length of that fetch — the dialog visibly blinked out
4455
+ * and back on the way to the agent list.
4456
+ *
4457
+ * So nothing is dismissed until we know what replaces it: on success the
4458
+ * new modal REPLACES the open one in a single dispatch and the overlay
4459
+ * never leaves the screen; only the paths that open no modal at all close
4460
+ * it. `previousFocus` is threaded from the previous step because that step
4461
+ * is deliberately still up, so it cannot be recovered from focus state.
4462
+ */
4463
+ async openSessionAgentPicker(options = {}, previousFocusOverride = null) {
4464
+ const previousFocus = previousFocusOverride ?? this.getState().ui.focusRegion;
4465
+ const dismiss = () => {
4466
+ this.dispatch({ type: "ui/modal", modal: null });
4467
+ if (previousFocus) {
4468
+ this.setFocus(previousFocus);
4469
+ }
4470
+ };
4471
+
4472
+ let agents = [];
4473
+ try {
4474
+ agents = typeof this.transport.listCreatableAgents === "function"
4475
+ ? await this.transport.listCreatableAgents()
4476
+ : [];
4477
+ } catch (error) {
4478
+ // The previous step is deliberately still on screen. If the fetch
4479
+ // fails we own dismissing it — otherwise the flow strands on a step
4480
+ // whose Enter key has already been consumed.
4481
+ dismiss();
4482
+ this.dispatch({
4483
+ type: "ui/status",
4484
+ text: `Could not load agents: ${error?.message || error}`,
4485
+ });
4486
+ return;
4487
+ }
4429
4488
  const sessionPolicy = typeof this.transport.getSessionCreationPolicy === "function"
4430
4489
  ? this.transport.getSessionCreationPolicy()
4431
4490
  : null;
4432
4491
  const allowGeneric = sessionPolicy?.creation?.allowGeneric ?? true;
4433
4492
 
4434
4493
  if (!Array.isArray(agents) || agents.length === 0) {
4494
+ dismiss();
4435
4495
  if (!allowGeneric) {
4436
4496
  this.dispatch({
4437
4497
  type: "ui/status",
@@ -4503,7 +4563,7 @@ export class PilotSwarmUiController {
4503
4563
  title: "Select agent for new session",
4504
4564
  items,
4505
4565
  selectedIndex: 0,
4506
- previousFocus: this.getState().ui.focusRegion,
4566
+ previousFocus,
4507
4567
  sessionOptions: options,
4508
4568
  },
4509
4569
  });
@@ -4526,13 +4586,17 @@ export class PilotSwarmUiController {
4526
4586
  await this.openSessionAgentPicker(options);
4527
4587
  }
4528
4588
 
4529
- async openReasoningEffortPicker(modelItem, sessionOptions = {}) {
4589
+ // previousFocus rides along the whole chain: each step is left on screen
4590
+ // until its successor is ready (see openSessionAgentPicker), so a later
4591
+ // step cannot read the pre-modal focus back off the state.
4592
+ async openReasoningEffortPicker(modelItem, sessionOptions = {}, previousFocusOverride = null) {
4593
+ const previousFocus = previousFocusOverride ?? this.getState().ui.focusRegion;
4530
4594
  const supported = normalizeReasoningEfforts(modelItem?.supportedReasoningEfforts);
4531
4595
  const selectedEffort = sessionOptions?.reasoningEffort || resolveDefaultReasoningEffort(modelItem);
4532
4596
  if (!supported.length || !selectedEffort) {
4533
4597
  // No effort step for this model — fall through to the context-tier
4534
4598
  // step, which handles both the new-session and switch-model flows.
4535
- await this.openContextTierPicker(modelItem, sessionOptions);
4599
+ await this.openContextTierPicker(modelItem, sessionOptions, previousFocus);
4536
4600
  return;
4537
4601
  }
4538
4602
 
@@ -4552,7 +4616,7 @@ export class PilotSwarmUiController {
4552
4616
  : `Reasoning effort for ${modelItem?.modelName || modelItem?.qualifiedName || "model"}`,
4553
4617
  items,
4554
4618
  selectedIndex,
4555
- previousFocus: this.getState().ui.focusRegion,
4619
+ previousFocus,
4556
4620
  modelItem,
4557
4621
  sessionOptions,
4558
4622
  },
@@ -4560,21 +4624,26 @@ export class PilotSwarmUiController {
4560
4624
  this.dispatch({ type: "ui/status", text: "Select a reasoning effort and press Enter" });
4561
4625
  }
4562
4626
 
4563
- async openContextTierPicker(modelItem, sessionOptions = {}) {
4627
+ async openContextTierPicker(modelItem, sessionOptions = {}, previousFocusOverride = null) {
4564
4628
  // Context-window tier step of the new-session flow. Only models whose
4565
4629
  // catalog entry declares supportedContextTiers get this picker; all
4566
4630
  // others skip straight to the agent picker. The preselected tier is
4567
4631
  // the catalog default ("default", the smaller window).
4632
+ const previousFocus = previousFocusOverride ?? this.getState().ui.focusRegion;
4568
4633
  const supported = normalizeContextTiers(modelItem?.supportedContextTiers);
4569
4634
  const selectedTier = sessionOptions?.contextTier || resolveDefaultContextTier(modelItem);
4570
4635
  if (!supported.length || !selectedTier) {
4571
4636
  // Model declares no context-window tiers — skip straight to applying
4572
4637
  // the switch (switch flow) or to the agent picker (new-session flow).
4573
4638
  if (sessionOptions?.mode === "switchModel") {
4639
+ this.dispatch({ type: "ui/modal", modal: null });
4640
+ if (previousFocus) {
4641
+ this.setFocus(previousFocus);
4642
+ }
4574
4643
  await this.switchSessionModel({ ...sessionOptions, model: modelItem?.id });
4575
4644
  return;
4576
4645
  }
4577
- await this.openSessionAgentPicker(sessionOptions);
4646
+ await this.openSessionAgentPicker(sessionOptions, previousFocus);
4578
4647
  return;
4579
4648
  }
4580
4649
 
@@ -4593,7 +4662,7 @@ export class PilotSwarmUiController {
4593
4662
  title: `Context window for ${modelItem?.modelName || modelItem?.qualifiedName || "model"}`,
4594
4663
  items,
4595
4664
  selectedIndex,
4596
- previousFocus: this.getState().ui.focusRegion,
4665
+ previousFocus,
4597
4666
  modelItem,
4598
4667
  sessionOptions,
4599
4668
  },
@@ -5914,17 +5983,20 @@ export class PilotSwarmUiController {
5914
5983
  });
5915
5984
  return;
5916
5985
  }
5986
+ // Each successor opener replaces this modal itself, so the overlay
5987
+ // is NOT dismissed here: doing that before an awaited step blinked
5988
+ // the dialog off screen for the length of the step's fetch.
5917
5989
  const previousFocus = modal.previousFocus;
5918
- this.dispatch({ type: "ui/modal", modal: null });
5919
- if (previousFocus) {
5920
- this.setFocus(previousFocus);
5921
- }
5922
5990
  if (!item) {
5923
5991
  if (modal.sessionOptions?.mode === "switchModel") {
5992
+ this.dispatch({ type: "ui/modal", modal: null });
5993
+ if (previousFocus) {
5994
+ this.setFocus(previousFocus);
5995
+ }
5924
5996
  this.dispatch({ type: "ui/status", text: "No model selected" });
5925
5997
  return;
5926
5998
  }
5927
- await this.openSessionAgentPicker({});
5999
+ await this.openSessionAgentPicker({}, previousFocus);
5928
6000
  return;
5929
6001
  }
5930
6002
  const defaultReasoning = resolveDefaultReasoningEffort(item);
@@ -5933,49 +6005,49 @@ export class PilotSwarmUiController {
5933
6005
  ...(modal.sessionOptions || {}),
5934
6006
  model: item.id,
5935
6007
  reasoningEffort: defaultReasoning ?? null,
5936
- });
6008
+ }, previousFocus);
5937
6009
  return;
5938
6010
  }
5939
6011
  await this.openReasoningEffortPicker(item, {
5940
6012
  ...(modal.sessionOptions || {}),
5941
6013
  model: item.id,
5942
6014
  ...(defaultReasoning ? { reasoningEffort: defaultReasoning } : {}),
5943
- });
6015
+ }, previousFocus);
5944
6016
  return;
5945
6017
  }
5946
6018
  if (modal.type === "reasoningEffortPicker") {
5947
6019
  const item = modal.items?.[modal.selectedIndex || 0];
5948
6020
  const previousFocus = modal.previousFocus;
5949
6021
  const sessionOptions = modal.sessionOptions || {};
5950
- this.dispatch({ type: "ui/modal", modal: null });
5951
- if (previousFocus) {
5952
- this.setFocus(previousFocus);
5953
- }
6022
+ // Not dismissed here — the tier step (or the agent step it falls
6023
+ // through to) replaces this modal in one dispatch.
5954
6024
  await this.openContextTierPicker(modal.modelItem, {
5955
6025
  ...sessionOptions,
5956
6026
  ...(item?.id ? { reasoningEffort: item.id } : {}),
5957
- });
6027
+ }, previousFocus);
5958
6028
  return;
5959
6029
  }
5960
6030
  if (modal.type === "contextTierPicker") {
5961
6031
  const item = modal.items?.[modal.selectedIndex || 0];
5962
6032
  const previousFocus = modal.previousFocus;
5963
6033
  const sessionOptions = modal.sessionOptions || {};
5964
- this.dispatch({ type: "ui/modal", modal: null });
5965
- if (previousFocus) {
5966
- this.setFocus(previousFocus);
5967
- }
5968
6034
  const nextOptions = {
5969
6035
  ...sessionOptions,
5970
6036
  ...(item?.id ? { contextTier: item.id } : {}),
5971
6037
  };
5972
- // Switch-model flow ends here — apply the model/effort/tier switch.
5973
- // New-session flow continues to the agent picker.
6038
+ // Switch-model flow ends here — apply the model/effort/tier switch,
6039
+ // so this one DOES close the overlay. The new-session flow continues
6040
+ // to the agent picker, which replaces the modal itself once its
6041
+ // agent list has loaded.
5974
6042
  if (sessionOptions?.mode === "switchModel") {
6043
+ this.dispatch({ type: "ui/modal", modal: null });
6044
+ if (previousFocus) {
6045
+ this.setFocus(previousFocus);
6046
+ }
5975
6047
  await this.switchSessionModel({ ...nextOptions, model: modal.modelItem?.id || sessionOptions.model });
5976
6048
  return;
5977
6049
  }
5978
- await this.openSessionAgentPicker(nextOptions);
6050
+ await this.openSessionAgentPicker(nextOptions, previousFocus);
5979
6051
  return;
5980
6052
  }
5981
6053
  if (modal.type === "sessionAgentPicker") {
@@ -53,8 +53,38 @@ function isPinnableSession(session) {
53
53
  );
54
54
  }
55
55
 
56
+ /**
57
+ * Keep a pin unless the row is loaded AND demonstrably no longer pinnable.
58
+ *
59
+ * Same rule, and the same reasoning, as pruneCollapsedIds below: a row missing
60
+ * from the current listing has NOT been unpinned. Not-loaded, filtered out,
61
+ * on another page, or simply slower to arrive are indistinguishable from here,
62
+ * and none of them is the user changing their mind.
63
+ *
64
+ * This ran unguarded on every `sessions/loaded`, so any listing that did not
65
+ * happen to contain the pinned row dropped the pin — and because pinnedIds is
66
+ * a dependency of the profile-save effect, the emptied list was then written
67
+ * back to the server as the user's preference. That is a one-way ratchet: the
68
+ * pin did not survive a restart, and the destroyed preference propagated to
69
+ * every other device, which is why a pin set on the desktop never reached the
70
+ * phone.
71
+ *
72
+ * A session that is genuinely present and NOT pinnable (moved into a group or
73
+ * under a parent, or a system row) is a real signal, so that still drops.
74
+ */
56
75
  function prunePinnedIds(ids, byId) {
57
- return pruneIdList(ids, byId).filter((sessionId) => isPinnableSession(byId[sessionId]));
76
+ const out = [];
77
+ const seen = new Set();
78
+ for (const id of ids || []) {
79
+ if (!id || seen.has(id)) continue;
80
+ seen.add(id);
81
+ const session = byId?.[id];
82
+ // Absent from this listing tells us nothing — keep it. A stale id
83
+ // matches no row and hoists nothing; a wrongly dropped one costs the
84
+ // user their pin permanently.
85
+ if (!session || isPinnableSession(session)) out.push(id);
86
+ }
87
+ return out;
58
88
  }
59
89
 
60
90
 
@@ -2128,6 +2158,10 @@ export function appReducer(state, action) {
2128
2158
  detail: null,
2129
2159
  detailLoading: Boolean(action.name),
2130
2160
  detailError: null,
2161
+ // Cleared on every selection: showing the previous
2162
+ // package's history under a new package is worse than
2163
+ // showing none.
2164
+ changelog: null,
2131
2165
  action: { pending: null, error: null },
2132
2166
  workspace: {
2133
2167
  tree: null, treeLoading: Boolean(action.name), treeError: null,
@@ -2146,6 +2180,21 @@ export function appReducer(state, action) {
2146
2180
  if (state.admin.packages.selectedName !== action.name) return state;
2147
2181
  return { ...state, admin: { ...state.admin, packages: { ...state.admin.packages, detail: null, detailLoading: false, detailError: action.error || "Failed to load package" } } };
2148
2182
  }
2183
+ case "admin/packages/changelog/loaded": {
2184
+ // Stale-response guard, same as the tree: a slow changelog for a
2185
+ // previously-selected package must not surface under the current one.
2186
+ if (state.admin.packages.selectedName !== action.name) return state;
2187
+ return {
2188
+ ...state,
2189
+ admin: {
2190
+ ...state.admin,
2191
+ packages: {
2192
+ ...state.admin.packages,
2193
+ changelog: action.content || null,
2194
+ },
2195
+ },
2196
+ };
2197
+ }
2149
2198
  case "admin/packages/tree/loaded": {
2150
2199
  if (state.admin.packages.selectedName !== action.name) return state;
2151
2200
  const topDirs = Array.isArray(action.tree?.dirs)
@@ -256,7 +256,7 @@ export function manualOrderContainerKey(session, pinned = false) {
256
256
  * plus a createdBy email. Resolving the package's principal against this
257
257
  * directory recovers the real name, which is what makes initials come out as
258
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.
259
+ * an alias ("ada@…" → AD). No fetch: the data is already loaded.
260
260
  */
261
261
  /**
262
262
  * The one owner-badge descriptor both lists render from. Same person ⇒ same
@@ -290,7 +290,7 @@ function ownerDirectoryFromSessions(byId) {
290
290
  *
291
291
  * A DISPLAY NAME beats an email, because initials should be the person's
292
292
  * ("Affan Dar" → AD), not the first two letters of their alias
293
- * ("daraffan@…" → DA) — which is exactly the mismatch that made the same
293
+ * ("ada@…" → AD) — which is exactly the mismatch that made the same
294
294
  * person read differently in the session list and the package list.
295
295
  */
296
296
  function resolvePackageOwner(pkg, directory) {
@@ -342,29 +342,50 @@ export function ownerBadgeKey(owner, createdBy) {
342
342
  ).trim().toLowerCase();
343
343
  }
344
344
 
345
- function shouldDecorateSessionOwners(state) {
346
- // Decorate owners only when the list actually surfaces more than one
347
- // distinct human owner otherwise the owner chip is pure noise on every
348
- // row (you are always "you"). A narrowed owner filter is an explicit
349
- // multi-user context, so honor that too.
350
- // A narrowed filter counts as an explicit multi-user context ONLY when it
351
- // names specific owners. The DEFAULT signed-in filter already sets
352
- // `all: false` AND `includeShared: true` (see
353
- // defaultOwnerFilterForPrincipal), so testing either of those tagged every
354
- // row in a single-user deployment with a pointless "you are you" chip.
355
- // Everything else falls through to counting DISTINCT owners below, which
356
- // is what the chip actually communicates.
357
- const ownerFilter = state.sessions?.ownerFilter;
358
- if (Array.isArray(ownerFilter?.ownerKeys) && ownerFilter.ownerKeys.length > 0) return true;
345
+ /**
346
+ * Distinct HUMAN owners **in the list** — where "the list" means the rows the
347
+ * owner filter admits, whether or not they happen to be on screen.
348
+ *
349
+ * That distinction is the whole rule, and the two halves pull in opposite
350
+ * directions on purpose:
351
+ * - **Collapsing** a folder hides rows that are still in the list, so they
352
+ * still count. A chip that appeared on expand and vanished on collapse
353
+ * would be worse than no chip.
354
+ * - **Filtering** takes rows out of the list entirely, so they stop counting.
355
+ * Narrowing to "me + System" leaves one human, and one human needs no chip.
356
+ *
357
+ * "Human" excludes:
358
+ * - `isSystem` rows (the permanent system agents);
359
+ * - rows owned by the first-class System principal. Sessions a system agent
360
+ * spawns on a user's behalf are NOT flagged isSystem — deliberately, so
361
+ * they stay deletable (see SYSTEM_OWNER_KEY) — so gating on the flag alone
362
+ * let "System" through as a second "person";
363
+ * - unowned rows, whose owner key is null.
364
+ */
365
+ function distinctHumanOwnerCount(state, stopAt = Infinity) {
366
+ const byId = state?.sessions?.byId;
367
+ const ownerFilter = state?.sessions?.ownerFilter || { all: true };
368
+ const auth = state?.auth || {};
359
369
  const owners = new Set();
360
- for (const session of Object.values(state.sessions?.byId || {})) {
370
+ for (const session of Object.values(byId || {})) {
361
371
  if (!session || session.isSystem || session.isGroup) continue;
362
372
  const key = ownerKeyForOwner(session.owner);
363
- if (!key) continue;
373
+ if (!key || key === SYSTEM_OWNER_KEY) continue;
374
+ // Only rows the filter admits. Uses the same predicate the rows
375
+ // themselves are filtered with, so the tally can never disagree with
376
+ // what is actually listed.
377
+ if (!matchesOwnerFilterDirect(session, ownerFilter, auth)) continue;
364
378
  owners.add(key);
365
- if (owners.size > 1) return true;
379
+ if (owners.size >= stopAt) break;
366
380
  }
367
- return false;
381
+ return owners.size;
382
+ }
383
+
384
+ function shouldDecorateSessionOwners(state) {
385
+ // One rule: the chip earns its place when the list holds more than one
386
+ // distinct human owner. Otherwise it is noise on every row — you are
387
+ // always "you".
388
+ return distinctHumanOwnerCount(state, 2) > 1;
368
389
  }
369
390
 
370
391
  function groupMemberSessions(group, byId = {}) {
@@ -3802,6 +3823,18 @@ export function selectAdminConsole(state) {
3802
3823
  );
3803
3824
  // Built once per render, not per row.
3804
3825
  const ownerDirectory = ownerDirectoryFromSessions(state?.sessions?.byId);
3826
+ // Same rule as the session list: the owner chip only earns its place when
3827
+ // this list actually holds more than one owner. Shared packages belong to
3828
+ // the deployment, not a person, so they neither carry a chip nor count
3829
+ // toward the tally.
3830
+ const distinctPackageOwners = new Set();
3831
+ for (const pkg of pkgList) {
3832
+ if (pkg?.scope === "shared") continue;
3833
+ const key = ownerKeyForOwner(pkg?.owner) || String(pkg?.createdBy || "").trim().toLowerCase();
3834
+ if (!key || key === SYSTEM_OWNER_KEY) continue;
3835
+ distinctPackageOwners.add(key);
3836
+ }
3837
+ const decoratePackageOwners = distinctPackageOwners.size > 1;
3805
3838
  const packageRow = (pkg) => ({
3806
3839
  name: pkg.name,
3807
3840
  scope: pkg.scope === "shared" ? "shared" : "user",
@@ -3815,7 +3848,9 @@ export function selectAdminConsole(state) {
3815
3848
  // the deployment and keep the scope badge instead.
3816
3849
  // Resolved against the session-owner directory so the initials are the
3817
3850
  // PERSON's, matching the session list exactly.
3818
- ownerBadge: pkg.scope === "shared" ? null : ownerBadgeFor(resolvePackageOwner(pkg, ownerDirectory)),
3851
+ ownerBadge: (pkg.scope === "shared" || !decoratePackageOwners)
3852
+ ? null
3853
+ : ownerBadgeFor(resolvePackageOwner(pkg, ownerDirectory)),
3819
3854
  // Highlight only while the Agents section is active — otherwise the
3820
3855
  // GitHub Keys screen shows a double selection.
3821
3856
  selected: admin.section === "packages" && pkgState.selectedName === pkg.name,
@@ -4074,6 +4109,14 @@ export function selectAdminConsole(state) {
4074
4109
  selectedName: pkgState.selectedName || null,
4075
4110
  detail: packageDetail,
4076
4111
  workspace,
4112
+ /**
4113
+ * The package's own CHANGELOG.md, when it ships one.
4114
+ *
4115
+ * Passed through verbatim rather than parsed: it is authored markdown,
4116
+ * and the renderer only needs to pick out headings and the signature
4117
+ * line. Null when the package carries no changelog.
4118
+ */
4119
+ changelog: pkgState.changelog || null,
4077
4120
  addDialog: {
4078
4121
  open: Boolean(pkgState.addDialog?.open),
4079
4122
  kind: pkgState.addDialog?.kind || "repo",
@@ -7255,7 +7298,10 @@ export function selectInspector(state, options = {}) {
7255
7298
  ]
7256
7299
  : activeTab === "history"
7257
7300
  ? [
7258
- ...buildPaneTitleRuns(compactSecondaryMeta ? "History" : `History: ${shortId}`, "magenta"),
7301
+ // Named for what it actually shows: the durable Duroxide
7302
+ // event log. The compact branch abbreviates rather than
7303
+ // truncates — the full name overruns a narrow inspector.
7304
+ ...buildPaneTitleRuns(compactSecondaryMeta ? "Duroxide Events" : `Duroxide Event History: ${shortId}`, "magenta"),
7259
7305
  ]
7260
7306
  : activeTab === "stats"
7261
7307
  ? [
@@ -343,6 +343,8 @@ export function createInitialState({ mode = "local", branding = null, docs = nul
343
343
  detail: null,
344
344
  detailLoading: false,
345
345
  detailError: null,
346
+ /** Raw CHANGELOG.md of the selected package, or null when it has none. */
347
+ changelog: null,
346
348
  workspace: {
347
349
  tree: null,
348
350
  treeLoading: false,
@@ -0,0 +1,106 @@
1
+ import { createTheme } from "./helpers.js";
2
+
3
+ // DOOM (id Software, 1993) — the status bar, not the shooter.
4
+ //
5
+ // The recognizable thing about DOOM's UI is STBAR: a band of hammered brown
6
+ // metal carrying big red numerals and the marine's face. That maps onto a
7
+ // portal header almost directly, so this theme is warm brown chrome around a
8
+ // near-black ground, with bone-coloured type.
9
+ //
10
+ // The semantic palette is the THREE KEYCARDS — blue, yellow, red. Every player
11
+ // already reads that triad as progress / caution / stop without being taught,
12
+ // and it happens to be exactly the distinction a session list needs. Armor
13
+ // green is held back for "completed".
14
+ //
15
+ // The ground is a red-biased near-black rather than pure #000: DOOM's letterbox
16
+ // was black but everything in frame was warm, and a neutral black underneath
17
+ // warm chrome reads as two unrelated themes stacked.
18
+ const doomTheme = createTheme({
19
+ id: "doom",
20
+ label: "DOOM",
21
+ description: "STBAR brown metal, bone type, and the three keycards as the status palette.",
22
+ // The face, drawn as data rather than markup so ui/core stays free of any
23
+ // framework and nothing has to inject raw HTML. The portal renders it in
24
+ // place of the deployment's logo; the TUI ignores it.
25
+ //
26
+ // In DOOM the face was never decoration — it reported your state. Keeping
27
+ // it as the mark leaves that door open.
28
+ icon: {
29
+ viewBox: "0 0 32 32",
30
+ paths: [
31
+ { d: "M0 0h32v32H0z", fill: "#2a1d14" }, // recessed frame
32
+ { d: "M6 7h20v20H6z", fill: "#c89a6a" }, // face
33
+ { d: "M5 6h22v6H5z", fill: "#4a2f1a" }, // hair
34
+ { d: "M9 15h5v3.4H9z", fill: "#1a1008" }, // left eye
35
+ { d: "M18 15h5v3.4h-5z", fill: "#1a1008" }, // right eye
36
+ { d: "M8.6 13.6l6 1 -.3 1.7-6-1z", fill: "#3a2412" }, // brows, angled in
37
+ { d: "M23.4 13.6l-6 1 .3 1.7 6-1z", fill: "#3a2412" },
38
+ { d: "M14.5 18.5h3v2.4h-3z", fill: "#a8734a" }, // nose
39
+ { d: "M11 22h10v1.8H11z", fill: "#7a3a2a" }, // mouth
40
+ ],
41
+ },
42
+ page: {
43
+ background: "#100b09",
44
+ foreground: "#d9cba8",
45
+ overlayBackground: "#1c1310",
46
+ overlayForeground: "#d9cba8",
47
+ // 6.2:1 on the ground. Authentic DOOM shadow browns sit nearer 3:1 and
48
+ // would fail the muted-contrast bar the theme suite enforces, so this
49
+ // is lifted — the same deliberate deviation ms-dos.js documents for CGA
50
+ // dark grey.
51
+ hintColor: "#a08d68",
52
+ modalBackdrop: "rgba(16, 11, 9, 0.82)",
53
+ modalBackground: "#1c1310",
54
+ modalBorder: "#6f5432",
55
+ modalForeground: "#d9cba8",
56
+ modalMuted: "#a08d68",
57
+ // Selection is the STBAR band itself, not a tint.
58
+ modalSelectedBackground: "#6f5432",
59
+ modalSelectedBorder: "#a4885c",
60
+ modalSelectedForeground: "#fff4d6",
61
+ },
62
+ terminal: {
63
+ background: "#100b09",
64
+ foreground: "#d9cba8",
65
+ cursor: "#b81c1c",
66
+ cursorAccent: "#100b09",
67
+ selectionBackground: "#6f5432",
68
+ black: "#100b09",
69
+ // Health red, lifted a shade: the sprite value lands at 2.9994:1 on
70
+ // this ground — close enough to look fine and just under the 3:1 a
71
+ // status mark needs.
72
+ red: "#c42222",
73
+ green: "#3f9e3f", // armor bonus
74
+ yellow: "#e8c020", // yellow keycard
75
+ // Lifted from the sprite's #3050d0, which measures 2.97:1 on this
76
+ // ground — under the 3:1 a status dot needs. Still unmistakably the
77
+ // blue keycard, now legible.
78
+ blue: "#3a5ce0",
79
+ magenta: "#a0446a",
80
+ cyan: "#4a8f8f",
81
+ white: "#d9cba8", // bone
82
+ brightBlack: "#a08d68",
83
+ brightRed: "#ff3a1e",
84
+ brightGreen: "#5fc45f",
85
+ brightYellow: "#ffd84a",
86
+ brightBlue: "#5a7ae8",
87
+ brightMagenta: "#c46a94",
88
+ brightCyan: "#6fb5b5",
89
+ brightWhite: "#fff4d6",
90
+ },
91
+ tui: {
92
+ border: "#6f5432",
93
+ // The transcript is prose, so speakers keep the keycard colours rather
94
+ // than everything collapsing into bone-on-brown.
95
+ userChat: "#e8c020",
96
+ userChatLabel: "#ffd84a",
97
+ selectionBackground: "#6f5432",
98
+ selectionForeground: "#fff4d6",
99
+ activeHighlightBackground: "#6f5432",
100
+ activeHighlightForeground: "#fff4d6",
101
+ promptCursorBackground: "#b81c1c",
102
+ promptCursorForeground: "#fff4d6",
103
+ },
104
+ });
105
+
106
+ export default doomTheme;
@@ -41,7 +41,7 @@ export function isThemeLight(theme) {
41
41
  return getRelativeLuminance(background) >= 0.5;
42
42
  }
43
43
 
44
- export function createTheme({ id, label, description, page, terminal, tui = {}, richChat = false }) {
44
+ export function createTheme({ id, label, description, page, terminal, tui = {}, richChat = false, icon = null }) {
45
45
  const baseTui = {
46
46
  background: page?.background || terminal?.background || "#000000",
47
47
  surface: terminal?.background || page?.background || "#000000",
@@ -76,6 +76,12 @@ export function createTheme({ id, label, description, page, terminal, tui = {},
76
76
  // Portal-only: this theme renders the chat transcript in the rich
77
77
  // (desktop-style) view. The TUI ignores it.
78
78
  richChat: Boolean(richChat),
79
+ // Portal-only: an optional brand mark carried BY the theme. When set it
80
+ // outranks the deployment's own logo, because a theme that restyles the
81
+ // whole shell and then leaves a foreign logo in the corner reads as
82
+ // broken. Data, not markup — ui/core has no framework and must not
83
+ // inject HTML. Shape: { viewBox, paths: [{ d, fill }] }.
84
+ icon: icon || null,
79
85
  page,
80
86
  terminal,
81
87
  tui: {
@@ -15,6 +15,8 @@ import terminalGreenTheme from "./terminal-green.js";
15
15
  import highContrastMonoTheme from "./high-contrast-mono.js";
16
16
  import win95Theme from "./win95.js";
17
17
  import msDosTheme from "./ms-dos.js";
18
+ import doomTheme from "./doom.js";
19
+ import winampTheme from "./winamp.js";
18
20
 
19
21
  const THEMES = Object.freeze([
20
22
  workspaceDarkTheme,
@@ -34,6 +36,8 @@ const THEMES = Object.freeze([
34
36
  highContrastMonoTheme,
35
37
  win95Theme,
36
38
  msDosTheme,
39
+ doomTheme,
40
+ winampTheme,
37
41
  ].sort((left, right) => left.label.localeCompare(right.label, undefined, { sensitivity: "base" })));
38
42
 
39
43
  const THEME_MAP = new Map(THEMES.map((theme) => [theme.id, theme]));