pilotswarm 0.5.57 → 0.5.58

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 (78) hide show
  1. package/package.json +2 -2
  2. package/tui/src/node-sdk-transport.js +5 -0
  3. package/ui/core/src/controller.js +117 -4
  4. package/ui/core/src/history.js +112 -1
  5. package/ui/core/src/selectors.js +164 -9
  6. package/ui/react/src/web-app.js +162 -19
  7. package/web/api/live-plane.js +193 -0
  8. package/web/api/ws.js +118 -2
  9. package/web/dist/assets/{abnfDiagram-VRR7QNED-CJOR2hPi.js → abnfDiagram-VRR7QNED-CCTGbkH5.js} +1 -1
  10. package/web/dist/assets/{arc-Yh3kc_KB.js → arc-w8Hl2ORd.js} +1 -1
  11. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-DLRlUeyk.js → architectureDiagram-ZJ3FMSHR-DAFM73jL.js} +1 -1
  12. package/web/dist/assets/{blockDiagram-677ZJIJ3-x_TGH_fp.js → blockDiagram-677ZJIJ3-BYfTQFe7.js} +1 -1
  13. package/web/dist/assets/{c4Diagram-LMCZKHZV-C-j_bxeF.js → c4Diagram-LMCZKHZV-bNF2gRsg.js} +1 -1
  14. package/web/dist/assets/channel-s4nVWOVf.js +1 -0
  15. package/web/dist/assets/{chunk-2Q5K7J3B-CaI5Xqwo.js → chunk-2Q5K7J3B-DhqJnJ4W.js} +1 -1
  16. package/web/dist/assets/{chunk-32BRIVSS-Bh_Crac6.js → chunk-32BRIVSS-Jg1a6n_3.js} +1 -1
  17. package/web/dist/assets/{chunk-5VM5RSS4-XM-3ajKG.js → chunk-5VM5RSS4-y7NKpClZ.js} +1 -1
  18. package/web/dist/assets/{chunk-EX3LRPZG-ZwkulfFp.js → chunk-EX3LRPZG-Lks3EhVc.js} +1 -1
  19. package/web/dist/assets/{chunk-JWPE2WC7-CT3Q7T2A.js → chunk-JWPE2WC7-BjcSkfk2.js} +1 -1
  20. package/web/dist/assets/{chunk-MOJQB5TN-_Uc0ksg3.js → chunk-MOJQB5TN-USDZsR-1.js} +1 -1
  21. package/web/dist/assets/{chunk-RYQCIY6F-DosoId7u.js → chunk-RYQCIY6F--HnsBTqS.js} +1 -1
  22. package/web/dist/assets/{chunk-V7JOEXUC-KRjq1cwM.js → chunk-V7JOEXUC-CUql3Oy3.js} +1 -1
  23. package/web/dist/assets/{chunk-VR4S4FIN-B6Ul1z3S.js → chunk-VR4S4FIN-CpS-l3FG.js} +1 -1
  24. package/web/dist/assets/{chunk-XXDRQBXY-AikdVXm4.js → chunk-XXDRQBXY-Bjb7ifTV.js} +1 -1
  25. package/web/dist/assets/classDiagram-OUVF2IWQ-tj8tRjww.js +1 -0
  26. package/web/dist/assets/classDiagram-v2-EOCWNBFH-tj8tRjww.js +1 -0
  27. package/web/dist/assets/{cose-bilkent-JH36ORCC-DTYwgEAa.js → cose-bilkent-JH36ORCC-CmY3-Xrd.js} +1 -1
  28. package/web/dist/assets/{cynefin-VYW2F7L2-C_8v2wIz.js → cynefin-VYW2F7L2-DVtcyDi5.js} +1 -1
  29. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-1-nw1zbQ.js → cynefinDiagram-TSTJHNR4-BPqkLecJ.js} +1 -1
  30. package/web/dist/assets/{dagre-VKFMJZFB-CAjwRpvT.js → dagre-VKFMJZFB-B73fSRpn.js} +1 -1
  31. package/web/dist/assets/{diagram-FQU43EPY-Bg41nQw_.js → diagram-FQU43EPY-uRCPZyuW.js} +1 -1
  32. package/web/dist/assets/{diagram-G47NLZAW-Cp6P-qMn.js → diagram-G47NLZAW-CHs_l9pw.js} +1 -1
  33. package/web/dist/assets/{diagram-NH7WQ7WH-DNdFuedg.js → diagram-NH7WQ7WH-BDs35ueJ.js} +1 -1
  34. package/web/dist/assets/{diagram-OA4YK3LP-DcHpELG2.js → diagram-OA4YK3LP-Dsv48zWW.js} +1 -1
  35. package/web/dist/assets/{diagram-WEI45ONY-B2_kyMw3.js → diagram-WEI45ONY-Bty-lxLj.js} +1 -1
  36. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-DztuAo3o.js → ebnfDiagram-CCIWWBDH-DgaxsuDg.js} +1 -1
  37. package/web/dist/assets/{erDiagram-Q63AITRT-DNjKTOzp.js → erDiagram-Q63AITRT-CR6l4OsK.js} +1 -1
  38. package/web/dist/assets/{flowDiagram-23GEKE2U-DWjfeUM1.js → flowDiagram-23GEKE2U-4y1ts4PX.js} +1 -1
  39. package/web/dist/assets/{ganttDiagram-NO4QXBWP-Y0MgGVmy.js → ganttDiagram-NO4QXBWP-CyKdJ2e9.js} +1 -1
  40. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX--lDKAKVz.js → gitGraphDiagram-IHSO6WYX-Ct5ShF3i.js} +1 -1
  41. package/web/dist/assets/index-DaRnh5NC.css +1 -0
  42. package/web/dist/assets/index-aFRNNWe9.js +24 -0
  43. package/web/dist/assets/{infoDiagram-FWYZ7A6U-CKmH8_YO.js → infoDiagram-FWYZ7A6U-uWoTXOM3.js} +1 -1
  44. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-D88R7zUc.js → ishikawaDiagram-FXEZZL3T-BTlDV_pc.js} +1 -1
  45. package/web/dist/assets/{journeyDiagram-5HDEW3XC-Ck7EMrsY.js → journeyDiagram-5HDEW3XC-_R-XB78s.js} +1 -1
  46. package/web/dist/assets/{kanban-definition-HUTT4EX6-hc-mOfSG.js → kanban-definition-HUTT4EX6-D7iwBjoS.js} +1 -1
  47. package/web/dist/assets/{linear-BVhU4csP.js → linear-BugN16AP.js} +1 -1
  48. package/web/dist/assets/{mermaid.core-C9u4FOCX.js → mermaid.core-De_KIbl-.js} +4 -4
  49. package/web/dist/assets/{mindmap-definition-LN4V7U3C-DatQu9Xp.js → mindmap-definition-LN4V7U3C-B72NDgcU.js} +1 -1
  50. package/web/dist/assets/{papaparse.min-BdnoXc8M.js → papaparse.min-BsD41u4P.js} +1 -1
  51. package/web/dist/assets/{pegDiagram-2B236MQR-Cr_nZ_jR.js → pegDiagram-2B236MQR-C6oifn4x.js} +1 -1
  52. package/web/dist/assets/{pieDiagram-ENE6RG2P-CcAbSIcY.js → pieDiagram-ENE6RG2P-Cpn0_Hp6.js} +1 -1
  53. package/web/dist/assets/pilotswarm-9Szh1sEC.js +99 -0
  54. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-CgJw5laM.js → quadrantDiagram-ABIIQ3AL-CJT4OoDA.js} +1 -1
  55. package/web/dist/assets/{railroadDiagram-RFXS5EU6-CHeun9SN.js → railroadDiagram-RFXS5EU6-BKtBN8XC.js} +1 -1
  56. package/web/dist/assets/{requirementDiagram-TGXJPOKE-D1kpDj8W.js → requirementDiagram-TGXJPOKE-Dw7BCEzo.js} +1 -1
  57. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-C5vUSUaX.js → sankeyDiagram-HTMAVEWB-Avmr1rvJ.js} +1 -1
  58. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-T1aPSxk1.js → sequenceDiagram-DBY2YBRQ-BRNnslq1.js} +1 -1
  59. package/web/dist/assets/{sizeCapture-X5ZJPWSS-j-wh2LVX.js → sizeCapture-X5ZJPWSS-BNmM8zTz.js} +1 -1
  60. package/web/dist/assets/{stateDiagram-2N3HPSRC-DfUZZR8N.js → stateDiagram-2N3HPSRC-CKXaP1Y6.js} +1 -1
  61. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-Dr0juUsm.js +1 -0
  62. package/web/dist/assets/{swimlanes-5IMT3BWC-OpASJiRw.js → swimlanes-5IMT3BWC-Dkeka0gO.js} +2 -2
  63. package/web/dist/assets/swimlanesDiagram-G3AALYLV-q-jLf0ZX.js +8 -0
  64. package/web/dist/assets/{timeline-definition-FHXFAJF6-BBnc_z1U.js → timeline-definition-FHXFAJF6-5hTTq-SL.js} +1 -1
  65. package/web/dist/assets/{vennDiagram-L72KCM5P-BlPQe9p4.js → vennDiagram-L72KCM5P-DL8MolqK.js} +1 -1
  66. package/web/dist/assets/{wardleyDiagram-EHGQE667-DBcBFUx2.js → wardleyDiagram-EHGQE667-CwGhy6eW.js} +1 -1
  67. package/web/dist/assets/{xychartDiagram-FW5EYKEG-BQUSGL6g.js → xychartDiagram-FW5EYKEG-Dn4T-3Dx.js} +1 -1
  68. package/web/dist/index.html +3 -3
  69. package/web/runtime.js +8 -0
  70. package/web/server.js +8 -0
  71. package/web/dist/assets/channel-BIn92AzB.js +0 -1
  72. package/web/dist/assets/classDiagram-OUVF2IWQ-B7ktW6XM.js +0 -1
  73. package/web/dist/assets/classDiagram-v2-EOCWNBFH-B7ktW6XM.js +0 -1
  74. package/web/dist/assets/index-BxPfp0AO.css +0 -1
  75. package/web/dist/assets/index-rPji8Uud.js +0 -24
  76. package/web/dist/assets/pilotswarm--kvfB_6q.js +0 -99
  77. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-ilKtTdHG.js +0 -1
  78. package/web/dist/assets/swimlanesDiagram-G3AALYLV-C1PthnB4.js +0 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pilotswarm",
3
- "version": "0.5.57",
3
+ "version": "0.5.58",
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.57",
84
+ "pilotswarm-sdk": "0.5.58",
85
85
  "react": "^19.2.4",
86
86
  "react-dom": "^19.2.4",
87
87
  "ws": "^8.18.2"
@@ -1976,6 +1976,11 @@ export class NodeSdkTransport {
1976
1976
  return this.mgmt.getCanvasLive(sessionId);
1977
1977
  }
1978
1978
 
1979
+ async getLive(sessionId, topics) {
1980
+ if (typeof this.mgmt.getLive !== "function") return [];
1981
+ return this.mgmt.getLive(sessionId, topics);
1982
+ }
1983
+
1979
1984
  // The canvas KV store. The portal runtime hands a resolved principal
1980
1985
  // (door 1); the native TUI has no canvas surface and never calls these.
1981
1986
  async readCanvasKv(sessionId, slot, principal, query = {}) {
@@ -4,8 +4,10 @@ import { parseAgentSourceLink } from "./repo-links.js";
4
4
  import { importPackageFilesFromLink, readImportedPackageName } from "./repo-import.js";
5
5
  import {
6
6
  appendEventToHistory,
7
+ applyLiveTurnToHistory,
7
8
  buildHistoryModel,
8
9
  CHAT_HISTORY_EVENT_TYPES,
10
+ clearLiveTurnFromHistory,
9
11
  DEFAULT_HISTORY_EVENT_LIMIT,
10
12
  dedupeChatMessages,
11
13
  getNextHistoryEventLimit,
@@ -2503,6 +2505,8 @@ export class PilotSwarmUiController {
2503
2505
  }
2504
2506
 
2505
2507
  async stop() {
2508
+ for (const timer of this._liveTurnIdleTimers?.values() || []) clearTimeout(timer);
2509
+ this._liveTurnIdleTimers?.clear();
2506
2510
  if (this.catalogTimer) clearInterval(this.catalogTimer);
2507
2511
  this.catalogTimer = null;
2508
2512
  if (this.activeSessionDetailTimer) clearTimeout(this.activeSessionDetailTimer);
@@ -2518,6 +2522,16 @@ export class PilotSwarmUiController {
2518
2522
  }
2519
2523
 
2520
2524
  detachActiveSession() {
2525
+ // Timers belong to the subscription, not to a future visit to this session.
2526
+ const sessionId = this.activeSessionSubscriptionId;
2527
+ if (sessionId) {
2528
+ clearTimeout(this._liveTurnIdleTimers?.get(sessionId));
2529
+ this._liveTurnIdleTimers?.delete(sessionId);
2530
+ const history = this.getState().history.bySessionId.get(sessionId);
2531
+ if (history) this.dispatch({ type: "history/set", sessionId, history: {
2532
+ ...history, chat: history.chat.filter((item) => !item.liveTurn),
2533
+ } });
2534
+ }
2521
2535
  if (this.activeSessionUnsub) {
2522
2536
  this.activeSessionUnsub();
2523
2537
  this.activeSessionUnsub = null;
@@ -2866,8 +2880,9 @@ export class PilotSwarmUiController {
2866
2880
  if (newEvents.length === 0) {
2867
2881
  return existingHistory;
2868
2882
  }
2869
- let history = existingHistory;
2883
+ let history = this.getState().history.bySessionId.get(sessionId) || existingHistory;
2870
2884
  for (const event of newEvents) {
2885
+ if (event.seq <= (history.lastSeq || 0)) continue;
2871
2886
  history = appendEventToHistory(history, event);
2872
2887
  }
2873
2888
  history = {
@@ -2937,10 +2952,37 @@ export class PilotSwarmUiController {
2937
2952
  }
2938
2953
  throw error;
2939
2954
  }
2940
- const history = {
2941
- ...buildHistoryModel(events, { requestedLimit }),
2942
- lastSeq: events[events.length - 1]?.seq || 0,
2955
+ const current = this.getState().history.bySessionId.get(sessionId);
2956
+ // Durable events and previews can arrive while REST is pending.
2957
+ // Merge that newer truth before replacing the loaded window.
2958
+ const mergedEvents = [...new Map([...(events || []), ...(current?.events || [])]
2959
+ .map((event) => [event.seq, event])).values()].sort((a, b) => a.seq - b.seq);
2960
+ let history = {
2961
+ ...buildHistoryModel(mergedEvents, { requestedLimit }),
2962
+ lastSeq: mergedEvents.at(-1)?.seq || 0,
2943
2963
  };
2964
+ history.closedLiveKeys = current?.closedLiveKeys || [];
2965
+ history.closedLiveStreams = current?.closedLiveStreams || [];
2966
+ const newTerminal = mergedEvents.some((event) => event.seq > (current?.lastSeq || 0)
2967
+ && ["session.turn_completed", "session.turn_stopped"].includes(event.eventType));
2968
+ for (const item of current?.chat || []) {
2969
+ if (item.streamSettling) {
2970
+ history.chat = history.chat.map((message) => message.messageId === item.messageId
2971
+ ? { ...item, ...message } : message);
2972
+ } else if (item.liveTurn && !newTerminal) {
2973
+ // Reuse the item itself: its key, reveal clock and memoized
2974
+ // rendering belong to this visit, not the REST response.
2975
+ if (!history.chat.some((message) => message.messageId && message.messageId === item.messageId)) {
2976
+ history.chat.push(item);
2977
+ } else {
2978
+ const finalEvent = mergedEvents.find((event) => event.eventType === "assistant.message" && event.data?.messageId === item.messageId);
2979
+ if (finalEvent) {
2980
+ const reconciled = appendEventToHistory({ chat: [item], events: [], activity: [], lastSeq: 0 }, finalEvent).chat[0];
2981
+ history.chat = history.chat.map((message) => message.messageId === item.messageId ? { ...reconciled, ...message } : message);
2982
+ }
2983
+ }
2984
+ }
2985
+ }
2944
2986
  this.dispatch({
2945
2987
  type: "history/set",
2946
2988
  sessionId,
@@ -5708,6 +5750,71 @@ export class PilotSwarmUiController {
5708
5750
  // history would corrupt the replay cursor — and they must never
5709
5751
  // appear in the transcript.
5710
5752
  if (event.transient === true) {
5753
+ if (event.eventType === "assistant.live_tick") {
5754
+ // A worker can die leaving a reasoning-only retained row.
5755
+ // On re-entry its message ID cannot match a durable final;
5756
+ // use database timestamps to reject snapshots older than an
5757
+ // already-loaded terminal event. Never use client wall time.
5758
+ const snapshotTime = event.liveUpdatedAt ? new Date(event.liveUpdatedAt).getTime() : NaN;
5759
+ if (event.data?.phase === "live" && Number.isFinite(snapshotTime)) {
5760
+ const history = this.getState().history.bySessionId.get(sessionId);
5761
+ if (history?.events?.some((entry) => ["session.turn_completed", "session.turn_stopped"].includes(entry.eventType)
5762
+ && new Date(entry.createdAt).getTime() > snapshotTime)) return false;
5763
+ }
5764
+ if (!this._liveTurnIdleTimers) this._liveTurnIdleTimers = new Map();
5765
+ const pendingIdle = this._liveTurnIdleTimers.get(sessionId);
5766
+ if (pendingIdle && event.data?.phase === "idle") {
5767
+ clearTimeout(pendingIdle);
5768
+ this._liveTurnIdleTimers.delete(sessionId);
5769
+ }
5770
+ // The live plane and durable event stream are independent.
5771
+ // An idle tick commonly wins the race by a few milliseconds;
5772
+ // tearing down immediately makes the live answer flash out
5773
+ // before its durable replacement lands. Give reconciliation a
5774
+ // bounded grace window, then clear only if it is still live.
5775
+ if (event.data?.phase === "idle") {
5776
+ const ended = this.getState().history.bySessionId.get(sessionId);
5777
+ if (!ended) return true;
5778
+ const streamId = event.data?.streamId || null;
5779
+ const endedKeys = new Set(ended.chat.filter((item) => item.liveTurn && (
5780
+ !streamId || item.streamId === streamId
5781
+ || (ended.closedLiveStreams || []).includes(item.streamId)
5782
+ || (ended.closedLiveKeys || []).includes(item.liveKey)
5783
+ )).map((item) => item.liveKey));
5784
+ // Never reveal a preview that has already finished. Keep
5785
+ // only previews that crossed the reveal threshold, and
5786
+ // close their keys now so late packets cannot revive them.
5787
+ this.dispatch({ type: "history/set", sessionId, history: {
5788
+ ...clearLiveTurnFromHistory(ended, null, streamId),
5789
+ chat: ended.chat.filter((item) => !item.liveTurn || !endedKeys.has(item.liveKey) || Date.now() - item.liveStartedAt >= 200),
5790
+ } });
5791
+ const timer = setTimeout(() => {
5792
+ this._liveTurnIdleTimers?.delete(sessionId);
5793
+ const current = this.getState().history.bySessionId.get(sessionId);
5794
+ if (!current) return;
5795
+ const chat = current.chat.filter((item) => !item.liveTurn || !endedKeys.has(item.liveKey));
5796
+ const cleared = chat.length === current.chat.length ? current : { ...current, chat };
5797
+ if (cleared !== current) {
5798
+ this.dispatch({ type: "history/set", sessionId, history: cleared });
5799
+ }
5800
+ }, 1_200);
5801
+ timer.unref?.();
5802
+ this._liveTurnIdleTimers.set(sessionId, timer);
5803
+ return true;
5804
+ }
5805
+ const existing = this.getState().history.bySessionId.get(sessionId)
5806
+ || { chat: [], activity: [], events: [], lastSeq: 0 };
5807
+ this.dispatch({
5808
+ type: "history/set",
5809
+ sessionId,
5810
+ history: applyLiveTurnToHistory(existing, event.data, {
5811
+ sessionId,
5812
+ seq: event.liveSeq,
5813
+ createdAt: Date.now(),
5814
+ }),
5815
+ });
5816
+ return true;
5817
+ }
5711
5818
  if (event.eventType === "session.canvas_data") {
5712
5819
  this.applyCanvasDataEvent(sessionId, event);
5713
5820
  }
@@ -5730,6 +5837,12 @@ export class PilotSwarmUiController {
5730
5837
  }
5731
5838
  return false;
5732
5839
  }
5840
+ if (this._liveTurnIdleTimers?.has(sessionId)
5841
+ && (event.eventType === "session.turn_completed"
5842
+ || event.eventType === "session.turn_stopped")) {
5843
+ clearTimeout(this._liveTurnIdleTimers.get(sessionId));
5844
+ this._liveTurnIdleTimers.delete(sessionId);
5845
+ }
5733
5846
  const state = this.getState();
5734
5847
  const existing = state.history.bySessionId.get(sessionId) || { chat: [], activity: [], lastSeq: 0 };
5735
5848
  if (event.seq <= (existing.lastSeq || 0)) return false;
@@ -530,6 +530,9 @@ function buildChatMessage(event, role) {
530
530
  const attachments = Array.isArray(event?.data?.attachments)
531
531
  ? event.data.attachments.filter((a) => a && typeof a.filename === "string" && a.filename)
532
532
  : [];
533
+ const messageId = typeof event?.data?.messageId === "string" && event.data.messageId
534
+ ? event.data.messageId
535
+ : null;
533
536
  return {
534
537
  id: `${event.sessionId}:${event.seq}`,
535
538
  role: deriveChatRole(event, role, text),
@@ -539,9 +542,76 @@ function buildChatMessage(event, role) {
539
542
  ...(clientMessageIds.length > 0 ? { clientMessageIds } : {}),
540
543
  ...(sender ? { sender } : {}),
541
544
  ...(attachments.length > 0 ? { attachments } : {}),
545
+ ...(messageId ? { messageId } : {}),
542
546
  };
543
547
  }
544
548
 
549
+ /** Apply one transient `turn` topic snapshot without touching durable events/seq. */
550
+ export function applyLiveTurnToHistory(history, payload, meta = {}) {
551
+ if (!payload) return history;
552
+ if (payload.phase === "idle") return clearLiveTurnFromHistory(history, null, payload.streamId || null);
553
+ if (payload.phase !== "live") return history;
554
+ const messageId = typeof payload.messageId === "string" && payload.messageId ? payload.messageId : null;
555
+ const reasoningId = typeof payload.reasoningId === "string" && payload.reasoningId ? payload.reasoningId : null;
556
+ const liveKey = messageId || (reasoningId ? `reasoning:${reasoningId}` : "active-turn");
557
+ const createdAt = Number(meta.createdAt) || Date.now();
558
+ const current = history || { chat: [], activity: [], events: [], lastSeq: 0 };
559
+ const oldChat = current.chat || [];
560
+ // Durable truth wins even when a delayed retained snapshot arrives later.
561
+ if ((messageId && oldChat.some((item) => !item.liveTurn && item.messageId === messageId))
562
+ || (current.closedLiveKeys || []).includes(liveKey)
563
+ || (payload.streamId && (current.closedLiveStreams || []).includes(payload.streamId))) return current;
564
+ const matches = (item) => item?.liveTurn === true && (
565
+ item?.liveKey === liveKey
566
+ || (messageId && item?.messageId === messageId)
567
+ || (reasoningId && item?.reasoningId === reasoningId && (!item.messageId || !messageId))
568
+ );
569
+ const firstMatch = oldChat.findIndex(matches);
570
+ const previous = firstMatch >= 0 ? oldChat[firstMatch] : null;
571
+ const chat = oldChat.filter((item) => !matches(item));
572
+ const reasoningText = typeof payload.reasoningText === "string" ? payload.reasoningText : "";
573
+ const text = typeof payload.text === "string" ? payload.text : "";
574
+ const liveStartedAt = Number(previous?.liveStartedAt || previous?.createdAt) || createdAt;
575
+ // Reasoning and answer text are one visual turn, not two independently
576
+ // mounted transcript rows. Keeping them in one keyed item lets the browser
577
+ // collapse reasoning above the answer and lets the durable final settle
578
+ // into the exact same React component instead of flashing between nodes.
579
+ const replacement = (reasoningText || text) ? [{
580
+ id: previous?.id || `live:${meta.sessionId || ""}:${liveKey}`,
581
+ role: "assistant",
582
+ ...(messageId ? { messageId } : {}),
583
+ ...(reasoningId ? { reasoningId } : {}),
584
+ liveKey: previous?.liveKey || liveKey,
585
+ liveTurn: true,
586
+ streamId: payload.streamId || previous?.streamId || null,
587
+ streaming: true,
588
+ truncated: payload.truncated === true,
589
+ text,
590
+ reasoningText,
591
+ liveStartedAt,
592
+ createdAt: liveStartedAt,
593
+ time: "",
594
+ }] : [];
595
+ chat.splice(firstMatch < 0 ? chat.length : Math.min(firstMatch, chat.length), 0, ...replacement);
596
+ return { ...current, chat };
597
+ }
598
+
599
+ export function clearLiveTurnFromHistory(history, messageId = null, streamId = null) {
600
+ if (!history?.chat) return history;
601
+ const chat = history.chat.filter((item) => !(
602
+ item?.liveTurn === true && (!messageId || item?.messageId === messageId) && (!streamId || item?.streamId === streamId)
603
+ ));
604
+ const retired = history.chat.filter((item) => item.liveTurn && (!messageId || item.messageId === messageId) && (!streamId || item.streamId === streamId));
605
+ const closedLiveKeys = [...new Set([...(history.closedLiveKeys || []),
606
+ ...retired.flatMap((item) => [item.liveKey, item.messageId, item.reasoningId && `reasoning:${item.reasoningId}`].filter(Boolean)),
607
+ ])].slice(-256);
608
+ const closedLiveStreams = [...new Set([...(history.closedLiveStreams || []),
609
+ ...(!messageId ? retired.map((item) => item.streamId).filter(Boolean) : []),
610
+ ...(streamId ? [streamId] : []),
611
+ ])].slice(-256);
612
+ return chat.length === history.chat.length && !streamId ? history : { ...history, chat, closedLiveKeys, closedLiveStreams };
613
+ }
614
+
545
615
  // The Copilot SDK echoes the system prompt as a system.message on EVERY
546
616
  // turn, and the worker keeps only a 120-char snippet of it (session-proxy.ts,
547
617
  // "Full content omitted from CMS"). For one release (0.5.47) each echo also
@@ -765,6 +835,12 @@ function formatActivity(event) {
765
835
  case "assistant.streaming_progress":
766
836
  return null;
767
837
 
838
+ case "model.call_start": {
839
+ const model = String(event?.data?.model || event?.data?.modelId || "model").split(":").pop();
840
+ runs = buildLabeledActivityRuns(time, "[model]", "cyan", `calling ${model}`, "white");
841
+ break;
842
+ }
843
+
768
844
  case "session.artifact_presented": {
769
845
  const data = (event?.data ?? {}) || {};
770
846
  const filename = String(data.filename || "artifact");
@@ -1167,6 +1243,8 @@ export function appendEventToHistory(history, event) {
1167
1243
  ? existingEvents
1168
1244
  : [...existingEvents, event].slice(-loadedEventLimit);
1169
1245
  const next = {
1246
+ closedLiveKeys: history?.closedLiveKeys || [],
1247
+ closedLiveStreams: history?.closedLiveStreams || [],
1170
1248
  chat: clampHistoryItems(history?.chat || [], loadedEventLimit),
1171
1249
  activity: clampHistoryItems(history?.activity || [], loadedEventLimit),
1172
1250
  events: nextEvents,
@@ -1201,6 +1279,7 @@ export function appendEventToHistory(history, event) {
1201
1279
  : m
1202
1280
  ));
1203
1281
  }
1282
+ Object.assign(next, clearLiveTurnFromHistory(next));
1204
1283
  return next;
1205
1284
  }
1206
1285
 
@@ -1241,10 +1320,42 @@ export function appendEventToHistory(history, event) {
1241
1320
  next.activity = clampHistoryItems(next.activity, loadedEventLimit);
1242
1321
  const message = buildChatMessage(event, "assistant");
1243
1322
  if (!message) return next;
1244
- next.chat.push(message);
1323
+ const liveIndex = message.messageId
1324
+ ? next.chat.findIndex((item) => item?.liveTurn === true && item?.messageId === message.messageId)
1325
+ : -1;
1326
+ const liveItem = liveIndex >= 0 ? next.chat[liveIndex] : null;
1327
+ Object.assign(next, clearLiveTurnFromHistory(next, message.messageId || null));
1328
+ // Preserve the model-call slot when several assistant messages are
1329
+ // interleaved: the durable final replaces its own live bubble in
1330
+ // place instead of jumping behind a later streaming message.
1331
+ if (liveIndex >= 0) {
1332
+ const settledAt = Date.now();
1333
+ const liveStartedAt = Number(liveItem?.liveStartedAt || liveItem?.createdAt) || settledAt;
1334
+ const liveAgeMs = Math.max(0, settledAt - liveStartedAt);
1335
+ // A live surface that never crossed the reveal threshold remains
1336
+ // invisible and the durable answer renders normally. Once it was
1337
+ // eligible to be seen, preserve the key and reasoning long enough
1338
+ // for the browser to morph it smoothly into committed transcript.
1339
+ if (liveAgeMs >= 200) {
1340
+ const streamSettleDelayMs = Math.max(0, 900 - liveAgeMs);
1341
+ Object.assign(message, {
1342
+ streamSettling: true,
1343
+ liveKey: liveItem?.liveKey || message.messageId,
1344
+ liveStartedAt,
1345
+ liveReasoningText: liveItem?.reasoningText || "",
1346
+ streamSettleDelayMs,
1347
+ streamSettleAt: settledAt + streamSettleDelayMs + 180,
1348
+ });
1349
+ }
1350
+ next.chat.splice(Math.min(liveIndex, next.chat.length), 0, message);
1351
+ }
1352
+ else next.chat.push(message);
1245
1353
  next.chat = clampHistoryItems(dedupeChatMessages(next.chat), loadedEventLimit);
1246
1354
  return next;
1247
1355
  }
1356
+ if (event.eventType === "session.turn_completed" || event.eventType === "session.turn_stopped") {
1357
+ Object.assign(next, clearLiveTurnFromHistory(next));
1358
+ }
1248
1359
  if (event.eventType === "system.message") {
1249
1360
  if (shouldRenderSystemMessageAsActivity(event)) {
1250
1361
  const activityItem = formatActivity(event);
@@ -457,17 +457,65 @@ export function manualOrderContainerKey(session, pinned = false) {
457
457
  * The one owner-badge descriptor both lists render from. Same person ⇒ same
458
458
  * initials and same colour, wherever they appear.
459
459
  */
460
- export function ownerBadgeFor(owner, { isMine = false } = {}) {
460
+ export function ownerBadgeFor(owner, { isMine = false, hueByKey = null } = {}) {
461
461
  if (!owner) return null;
462
462
  const name = ownerDisplayName(owner, owner?.email || "");
463
+ const key = ownerBadgeKey(owner, owner?.email);
463
464
  return {
464
465
  initials: ownerInitials(owner),
465
- hue: ownerBadgeHue(ownerBadgeKey(owner, owner?.email)),
466
+ // The list-wide assignment when the caller has one (no two people in
467
+ // the same list share a colour); the bare hash otherwise.
468
+ hue: hueByKey?.get?.(key) ?? ownerBadgeHue(key),
466
469
  name: name || "unknown user",
467
470
  isMine: Boolean(isMine),
468
471
  };
469
472
  }
470
473
 
474
+ /**
475
+ * One colour per PERSON in a list, no two alike while the palette lasts.
476
+ *
477
+ * The hash alone puts different people on the same colour far too often
478
+ * (three people, twelve colours: one collision in four), and a badge whose
479
+ * colour does not tell people apart is not doing its job. So: everyone
480
+ * gets their hash hue first; anyone landing on a taken hue walks to the next
481
+ * free one. Walk order is the sorted identity key, so the outcome depends
482
+ * only on WHO is in the list — the same people get the same colours across
483
+ * reloads and in both panes, and a newcomer only ever takes a free colour.
484
+ */
485
+ export function assignOwnerBadgeHues(keys) {
486
+ const sorted = [...new Set([...keys].map((k) => String(k || "").trim().toLowerCase()).filter(Boolean))].sort();
487
+ const taken = new Set();
488
+ const out = new Map();
489
+ for (const key of sorted) {
490
+ let hue = ownerBadgeHue(key);
491
+ if (taken.size < OWNER_BADGE_HUES) {
492
+ let steps = 0;
493
+ while (taken.has(hue) && steps < OWNER_BADGE_HUES) { hue = (hue + 1) % OWNER_BADGE_HUES; steps += 1; }
494
+ }
495
+ taken.add(hue);
496
+ out.set(key, hue);
497
+ }
498
+ return out;
499
+ }
500
+
501
+ // The assignment for every human owner in the session catalog, memoised on
502
+ // the catalog's identity: the row selector runs per row and per dispatch.
503
+ let ownerHueMapCache = null;
504
+ export function ownerHueMapForSessions(byId) {
505
+ if (ownerHueMapCache && ownerHueMapCache.byId === byId) return ownerHueMapCache.map;
506
+ const keys = [];
507
+ for (const session of Object.values(byId || {})) {
508
+ if (!session || session.isSystem || session.isGroup) continue;
509
+ const owner = session.owner;
510
+ const ownerKey = ownerKeyForOwner(owner);
511
+ if (!ownerKey || ownerKey === SYSTEM_OWNER_KEY) continue;
512
+ keys.push(ownerBadgeKey(owner, owner?.email));
513
+ }
514
+ const map = assignOwnerBadgeHues(keys);
515
+ ownerHueMapCache = { byId, map };
516
+ return map;
517
+ }
518
+
471
519
  function ownerDirectoryFromSessions(byId) {
472
520
  const directory = new Map();
473
521
  for (const session of Object.values(byId || {})) {
@@ -507,11 +555,11 @@ function resolvePackageOwner(pkg, directory) {
507
555
  * the badge is telling owners APART at a glance, which two-letter initials
508
556
  * alone do poorly (and did not do at all while they were all "?").
509
557
  *
510
- * Eight hues, all legible on both the light and dark portal grounds; the
558
+ * Twelve hues, all legible on both the light and dark portal grounds; the
511
559
  * viewer's own rows are styled separately by the caller, so this palette
512
560
  * never has to encode "mine".
513
561
  */
514
- const OWNER_BADGE_HUES = 8;
562
+ export const OWNER_BADGE_HUES = 12;
515
563
 
516
564
  export function ownerBadgeHue(key) {
517
565
  const text = String(key || "").trim().toLowerCase();
@@ -1358,6 +1406,7 @@ export function selectSessionRows(state) {
1358
1406
  ? ownerBadgeFor(effectiveSessionOwner(session, state.sessions.byId), {
1359
1407
  isMine: Boolean(ownerKeyForOwner(state?.auth?.principal)
1360
1408
  && ownerKeyForOwner(effectiveSessionOwner(session, state.sessions.byId)) === ownerKeyForOwner(state?.auth?.principal)),
1409
+ hueByKey: ownerHueMapForSessions(state.sessions.byId),
1361
1410
  })
1362
1411
  : null,
1363
1412
  orderable: isManuallyOrderableSession(session),
@@ -1904,7 +1953,11 @@ function describeChatMessageHeader(message, options = {}) {
1904
1953
  const sharedContext = options?.sharedContext === true;
1905
1954
  const isUser = message?.role === "user";
1906
1955
  const isSelf = isUser && senderKey && viewerKey && senderKey === viewerKey;
1907
- const fromOtherPerson = sharedContext && isUser && senderKey && !isSelf;
1956
+ // A user-stamped message whose key is not the viewer's IS from someone
1957
+ // else, whatever the session's visibility says: a targeted share leaves
1958
+ // the session "private" but still puts another person's messages in the
1959
+ // transcript. Naming them never needs the shared-context gate.
1960
+ const fromOtherPerson = isUser && Boolean(senderKey) && Boolean(viewerKey) && !isSelf;
1908
1961
  // Owner tag: the sender is the session owner (or, for a sender-less own
1909
1962
  // message, the viewer themselves is the owner).
1910
1963
  const senderIsOwner = sharedContext && isUser && ownerKey && (
@@ -2427,7 +2480,22 @@ function splashArtWidth(text) {
2427
2480
  .reduce((widest, line) => Math.max(widest, stripTerminalMarkupTags(line).length), 0);
2428
2481
  }
2429
2482
 
2483
+ // Immutable history items survive live ticks. Do not re-parse thousands of
2484
+ // committed lines because the last message gained a handful of characters.
2485
+ const chatMessageLinesCache = new WeakMap();
2430
2486
  function buildChatMessageLines(message, maxWidth, options = {}) {
2487
+ if (!message || typeof message !== "object") return buildChatMessageLinesUncached(message, maxWidth, options);
2488
+ const key = JSON.stringify([maxWidth, options]);
2489
+ let entries = chatMessageLinesCache.get(message);
2490
+ if (entries?.has(key)) return entries.get(key);
2491
+ const lines = buildChatMessageLinesUncached(message, maxWidth, options);
2492
+ if (!entries) { entries = new Map(); chatMessageLinesCache.set(message, entries); }
2493
+ if (entries.size >= 4) entries.delete(entries.keys().next().value);
2494
+ entries.set(key, lines);
2495
+ return lines;
2496
+ }
2497
+
2498
+ function buildChatMessageLinesUncached(message, maxWidth, options = {}) {
2431
2499
  if (message?.splash) {
2432
2500
  // Swap in the narrow-viewport variant when the main art would
2433
2501
  // overflow the pane (mobile portal, narrow terminals). When the
@@ -2447,6 +2515,66 @@ function buildChatMessageLines(message, maxWidth, options = {}) {
2447
2515
  return buildThinkingCardLines(message, maxWidth);
2448
2516
  }
2449
2517
 
2518
+ // The browser gives an in-flight assistant answer its own stable surface.
2519
+ // Previously it was flattened into the ordinary transcript on every
2520
+ // snapshot. As partial markdown changed shape (plain lines -> table/code
2521
+ // block), React could replace several adjacent nodes and the whole answer
2522
+ // appeared to flash. One keyed card keeps the outer DOM and its shading in
2523
+ // place while only the body grows. The TUI deliberately keeps its existing
2524
+ // line-oriented rendering.
2525
+ if ((message?.liveTurn || message?.streamSettling) && options.tableMode === "sentinel") {
2526
+ const isLive = message?.liveTurn === true;
2527
+ const reasoningText = String(message?.reasoningText || message?.liveReasoningText || "");
2528
+ const cardLines = buildMessageCardLines({
2529
+ title: isLive
2530
+ ? (message?.text ? "Agent is responding" : "Agent is thinking")
2531
+ : "Agent responded",
2532
+ body: decorateArtifactLinksForChat(message?.text || ""),
2533
+ width: Math.max(20, maxWidth),
2534
+ titleColor: "cyan",
2535
+ borderColor: "cyan",
2536
+ tableMode: "sentinel",
2537
+ });
2538
+ const cardEndIndex = cardLines.findIndex((line) => line?.kind === "cardEnd");
2539
+ if (cardEndIndex > 0) {
2540
+ const bodyLines = cardLines.slice(1, cardEndIndex);
2541
+ if (reasoningText) {
2542
+ bodyLines.unshift({
2543
+ ...buildSystemNoticeLine({
2544
+ title: isLive ? "Thinking" : "Thought",
2545
+ summary: isLive ? "In progress" : "Completed",
2546
+ body: reasoningText,
2547
+ color: "gray",
2548
+ }),
2549
+ liveReasoning: isLive,
2550
+ streamReasoning: true,
2551
+ });
2552
+ }
2553
+ // Keep the progress marker out of markdown syntax: appending it
2554
+ // to a closing fence or an empty row can change parsing/height.
2555
+ if (isLive) cardLines[0].runs.push({ text: " ▍", color: "cyan", role: "streamingCaret" });
2556
+ if (isLive && message.truncated) bodyLines.push([{ text: "Preview paused — the full answer will appear when complete.", color: "gray" }]);
2557
+ const liveKey = message?.liveKey || message?.messageId || message?.id || "active-turn";
2558
+ return [
2559
+ {
2560
+ ...cardLines[0],
2561
+ cardVariant: isLive ? "live" : "settling",
2562
+ cardKey: `live:${liveKey}`,
2563
+ liveStartedAt: message?.liveStartedAt || message?.createdAt || Date.now(),
2564
+ settleDelayMs: Number(message?.streamSettleDelayMs) || 0,
2565
+ settleAt: Number(message?.streamSettleAt) || null,
2566
+ },
2567
+ ...bodyLines,
2568
+ ...cardLines.slice(cardEndIndex),
2569
+ ];
2570
+ }
2571
+ }
2572
+
2573
+ if (message?.liveTurn && options.tableMode !== "sentinel" && message.reasoningText) {
2574
+ const reasoning = buildThinkingCardLines({ ...message, text: message.reasoningText }, maxWidth);
2575
+ return message.text ? [...reasoning, ...buildChatMessageLines({ ...message, reasoningText: "" }, maxWidth, options)] : reasoning;
2576
+ }
2577
+
2450
2578
  // Chrome-less message: render the text as plain markdown directly into
2451
2579
  // the chat pane (no card border, no speaker prefix, no timestamp). Used
2452
2580
  // for the session summary view so the summary feels like the pane's
@@ -2589,6 +2717,7 @@ function buildChatMessageLines(message, maxWidth, options = {}) {
2589
2717
 
2590
2718
  if (startsWithStructuredBlock(tintedMarkdownLines)) {
2591
2719
  const structured = prefix.length > 0 ? [prefix, ...tintedMarkdownLines] : tintedMarkdownLines;
2720
+ if (message?.streaming) appendStreamingCaret(structured);
2592
2721
  return attachmentChipLines.length > 0 ? [...structured, ...attachmentChipLines] : structured;
2593
2722
  }
2594
2723
 
@@ -2610,10 +2739,23 @@ function buildChatMessageLines(message, maxWidth, options = {}) {
2610
2739
  renderedLines.push(lineRuns);
2611
2740
  }
2612
2741
  }
2742
+ if (message?.streaming) {
2743
+ appendStreamingCaret(renderedLines);
2744
+ }
2613
2745
  renderedLines.push(...attachmentChipLines);
2614
2746
  return renderedLines;
2615
2747
  }
2616
2748
 
2749
+ function appendStreamingCaret(lines) {
2750
+ const lastRuns = [...lines].reverse().find((line) => Array.isArray(line)
2751
+ && line.some((run) => String(run?.text || "").trim()));
2752
+ if (lastRuns) {
2753
+ lastRuns.push({ text: " ▍", color: "cyan", role: "streamingCaret" });
2754
+ } else {
2755
+ lines.push([{ text: "▍", color: "cyan", role: "streamingCaret" }]);
2756
+ }
2757
+ }
2758
+
2617
2759
  // Image attachment chips under a message. In sentinel mode (browser portal)
2618
2760
  // a single structured line carries the refs so the web renderer can show
2619
2761
  // authenticated thumbnails; everywhere else (TUI) each file renders as a
@@ -2730,6 +2872,10 @@ function liveActivityPhaseText(item) {
2730
2872
  case "assistant.intent": return "planning\u2026";
2731
2873
  case "assistant.message_start":
2732
2874
  case "assistant.message": return "writing reply\u2026";
2875
+ case "model.call_start": {
2876
+ const model = String(item?.text || "").replace(/^.*\bcalling\s+/i, "").trim();
2877
+ return `calling ${model || "model"}\u2026`;
2878
+ }
2733
2879
  case "session.compaction_start": return "compacting context\u2026";
2734
2880
  case "session.compaction_complete": return "context compacted";
2735
2881
  case "session.dehydrated": return "dehydrating\u2026";
@@ -2891,12 +3037,19 @@ export function selectChatLines(state, maxWidth = 80, options = {}) {
2891
3037
  const activeSessionId = state?.sessions?.activeSessionId;
2892
3038
  const activeSession = activeSessionId ? state?.sessions?.byId?.[activeSessionId] : null;
2893
3039
  const ownerKey = ownerKeyForOwner(activeSession?.owner);
2894
- // A shared context = the session is shared deployment-wide, or the viewer
2895
- // is not its owner (someone shared it with them). Only then does the
2896
- // transcript name speakers and tag the owner.
3040
+ // A shared context = the session is shared deployment-wide, the viewer is
3041
+ // not its owner (someone shared it with them), or someone other than the
3042
+ // viewer has written to it (the owner shared it with a writer; the session
3043
+ // row still says "private" — targeted shares are not on the row, but the
3044
+ // other person's stamped messages are in the transcript). Only then does
3045
+ // the transcript name speakers and tag the owner.
2897
3046
  const sharedContext = Boolean(activeSession && (
2898
3047
  (activeSession.visibility && activeSession.visibility !== "private")
2899
3048
  || (ownerKey && viewerKey && ownerKey !== viewerKey)
3049
+ || (viewerKey && messages.some((message) => message?.role === "user"
3050
+ && message?.sender?.kind === "user"
3051
+ && ownerKeyForOwner(message.sender)
3052
+ && ownerKeyForOwner(message.sender) !== viewerKey))
2900
3053
  ));
2901
3054
  const buildOptions = {
2902
3055
  ...(options?.tableMode ? { tableMode: options.tableMode } : {}),
@@ -4165,6 +4318,8 @@ export function selectAdminConsole(state) {
4165
4318
  const canEditPackage = (pkg) => ownsPackage(pkg) || Boolean(pkg?.canEdit);
4166
4319
  // Built once per render, not per row.
4167
4320
  const ownerDirectory = ownerDirectoryFromSessions(state?.sessions?.byId);
4321
+ // Same assignment as the session list, so the same person is the same colour in both panes.
4322
+ const packageHueByKey = ownerHueMapForSessions(state?.sessions?.byId);
4168
4323
  // Same rule as the session list: the owner chip only earns its place when
4169
4324
  // this list actually holds more than one owner. Shared packages belong to
4170
4325
  // the deployment, not a person, so they neither carry a chip nor count
@@ -4212,7 +4367,7 @@ export function selectAdminConsole(state) {
4212
4367
  // PERSON's, matching the session list exactly.
4213
4368
  ownerBadge: (pkg.scope === "shared" || !decoratePackageOwners)
4214
4369
  ? null
4215
- : ownerBadgeFor(resolvePackageOwner(pkg, ownerDirectory)),
4370
+ : ownerBadgeFor(resolvePackageOwner(pkg, ownerDirectory), { hueByKey: packageHueByKey }),
4216
4371
  // Highlight only while the Agents section is active — otherwise the
4217
4372
  // GitHub Keys screen shows a double selection.
4218
4373
  selected: rowMatchesSelection(pkg),