pilotswarm 0.5.59 → 0.5.61

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 (79) hide show
  1. package/README.md +7 -0
  2. package/package.json +2 -2
  3. package/ui/core/src/controller.js +56 -13
  4. package/ui/core/src/history.js +15 -1
  5. package/ui/core/src/index.js +2 -0
  6. package/ui/core/src/moa.js +47 -0
  7. package/ui/core/src/reducer.js +70 -49
  8. package/ui/core/src/selectors.js +27 -13
  9. package/ui/core/src/session-errors.js +97 -0
  10. package/ui/core/src/state.js +1 -0
  11. package/ui/react/src/web-app.js +327 -136
  12. package/web/dist/assets/{abnfDiagram-VRR7QNED-D9Rv2VpV.js → abnfDiagram-VRR7QNED-CCVkGWsr.js} +1 -1
  13. package/web/dist/assets/{arc-BATiucnq.js → arc-y040btO3.js} +1 -1
  14. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-CSTMt0si.js → architectureDiagram-ZJ3FMSHR-24Yt5esy.js} +1 -1
  15. package/web/dist/assets/{blockDiagram-677ZJIJ3-C_RGqfeY.js → blockDiagram-677ZJIJ3-BlbS-_GI.js} +1 -1
  16. package/web/dist/assets/{c4Diagram-LMCZKHZV-DH__s__0.js → c4Diagram-LMCZKHZV-_mamn0Fv.js} +1 -1
  17. package/web/dist/assets/channel-COFs9dqk.js +1 -0
  18. package/web/dist/assets/{chunk-2Q5K7J3B-C197fD3B.js → chunk-2Q5K7J3B-TCbIB2Ni.js} +1 -1
  19. package/web/dist/assets/{chunk-32BRIVSS-BjpuBroU.js → chunk-32BRIVSS-C9q5KNKG.js} +1 -1
  20. package/web/dist/assets/{chunk-5VM5RSS4-W_jL8euj.js → chunk-5VM5RSS4-BvoqfU8P.js} +1 -1
  21. package/web/dist/assets/{chunk-EX3LRPZG-a9h0dtlp.js → chunk-EX3LRPZG-zFJjZeNd.js} +1 -1
  22. package/web/dist/assets/{chunk-JWPE2WC7-CQN0qE7F.js → chunk-JWPE2WC7-Ce_6C5M-.js} +1 -1
  23. package/web/dist/assets/{chunk-MOJQB5TN-D-Y6KQ7t.js → chunk-MOJQB5TN-CPjpLM65.js} +1 -1
  24. package/web/dist/assets/{chunk-RYQCIY6F-jPwseC5f.js → chunk-RYQCIY6F-CsmVUSDH.js} +1 -1
  25. package/web/dist/assets/{chunk-V7JOEXUC-DRNCEcQh.js → chunk-V7JOEXUC-CgrmPPOd.js} +1 -1
  26. package/web/dist/assets/{chunk-VR4S4FIN-DzhUcoZ7.js → chunk-VR4S4FIN-BxAHDR7h.js} +1 -1
  27. package/web/dist/assets/{chunk-XXDRQBXY-ldJfyPXK.js → chunk-XXDRQBXY-BGfFoO5_.js} +1 -1
  28. package/web/dist/assets/classDiagram-OUVF2IWQ-B5-KmsCx.js +1 -0
  29. package/web/dist/assets/classDiagram-v2-EOCWNBFH-B5-KmsCx.js +1 -0
  30. package/web/dist/assets/{cose-bilkent-JH36ORCC-BBVo21wR.js → cose-bilkent-JH36ORCC-B6KcngJW.js} +1 -1
  31. package/web/dist/assets/{cynefin-VYW2F7L2-CgRQfdW1.js → cynefin-VYW2F7L2-DaY7l4Ce.js} +1 -1
  32. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-Ckz2X0t3.js → cynefinDiagram-TSTJHNR4-BldfGsoa.js} +1 -1
  33. package/web/dist/assets/{dagre-VKFMJZFB-C1SanJiY.js → dagre-VKFMJZFB-C4T9X5Bs.js} +1 -1
  34. package/web/dist/assets/{diagram-FQU43EPY-dBdEifVy.js → diagram-FQU43EPY-DCVA_t-e.js} +1 -1
  35. package/web/dist/assets/{diagram-G47NLZAW-Byqc1VCB.js → diagram-G47NLZAW-pbTZGgE5.js} +1 -1
  36. package/web/dist/assets/{diagram-NH7WQ7WH-9aeTaCsX.js → diagram-NH7WQ7WH-P8Iks122.js} +1 -1
  37. package/web/dist/assets/{diagram-OA4YK3LP-CL0a0o0k.js → diagram-OA4YK3LP-hEjZPipp.js} +1 -1
  38. package/web/dist/assets/{diagram-WEI45ONY-YlMQOJfY.js → diagram-WEI45ONY-CVAzVCGu.js} +1 -1
  39. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-qMZQEnws.js → ebnfDiagram-CCIWWBDH-BLL398qt.js} +1 -1
  40. package/web/dist/assets/{erDiagram-Q63AITRT-C3CWBCtq.js → erDiagram-Q63AITRT-CLTADyUG.js} +1 -1
  41. package/web/dist/assets/{flowDiagram-23GEKE2U-BN79sybL.js → flowDiagram-23GEKE2U-BAdlWAt0.js} +1 -1
  42. package/web/dist/assets/{ganttDiagram-NO4QXBWP-BOWhiAgB.js → ganttDiagram-NO4QXBWP-C7Z2wavr.js} +1 -1
  43. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-CKX9OACp.js → gitGraphDiagram-IHSO6WYX-BI546pYu.js} +1 -1
  44. package/web/dist/assets/index-CvW-4eXF.css +1 -0
  45. package/web/dist/assets/index-DsPaDrf9.js +24 -0
  46. package/web/dist/assets/{infoDiagram-FWYZ7A6U-QQEunPNs.js → infoDiagram-FWYZ7A6U-8C7iQydo.js} +1 -1
  47. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-CvMwrnLQ.js → ishikawaDiagram-FXEZZL3T-Co4STVxQ.js} +1 -1
  48. package/web/dist/assets/{journeyDiagram-5HDEW3XC-giNpEVpj.js → journeyDiagram-5HDEW3XC-DbUt11q-.js} +1 -1
  49. package/web/dist/assets/{kanban-definition-HUTT4EX6-CUPPpo27.js → kanban-definition-HUTT4EX6-DUNcFmme.js} +1 -1
  50. package/web/dist/assets/{linear-AXqadfOR.js → linear-Cjaxj94d.js} +1 -1
  51. package/web/dist/assets/{mermaid.core-C78wdk8Y.js → mermaid.core-B3cPFf4f.js} +4 -4
  52. package/web/dist/assets/{mindmap-definition-LN4V7U3C-BGUmh9bK.js → mindmap-definition-LN4V7U3C-DaZtxB4N.js} +1 -1
  53. package/web/dist/assets/{papaparse.min-Bh8RLCk_.js → papaparse.min-vfdso2fs.js} +1 -1
  54. package/web/dist/assets/{pegDiagram-2B236MQR-BHsKXKhK.js → pegDiagram-2B236MQR-DkBpYV7m.js} +1 -1
  55. package/web/dist/assets/{pieDiagram-ENE6RG2P-D3EvWTdH.js → pieDiagram-ENE6RG2P-By3tI-vc.js} +1 -1
  56. package/web/dist/assets/pilotswarm-BL_NbZug.js +98 -0
  57. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-CbkxfLkV.js → quadrantDiagram-ABIIQ3AL-DZmVm0xK.js} +1 -1
  58. package/web/dist/assets/{railroadDiagram-RFXS5EU6-H4M6JSDW.js → railroadDiagram-RFXS5EU6-BC5Mbz7q.js} +1 -1
  59. package/web/dist/assets/{requirementDiagram-TGXJPOKE-BJo7JW1v.js → requirementDiagram-TGXJPOKE-CyZNX_O1.js} +1 -1
  60. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-DL19MeOP.js → sankeyDiagram-HTMAVEWB-7603NEmu.js} +1 -1
  61. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-DyVFc5pQ.js → sequenceDiagram-DBY2YBRQ-CXJ-i2Xj.js} +1 -1
  62. package/web/dist/assets/{sizeCapture-X5ZJPWSS-CKbcKIEQ.js → sizeCapture-X5ZJPWSS-CKT0ATnS.js} +1 -1
  63. package/web/dist/assets/{stateDiagram-2N3HPSRC-BSukSXzS.js → stateDiagram-2N3HPSRC-BIDPX4YU.js} +1 -1
  64. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-Dr8oD9T6.js +1 -0
  65. package/web/dist/assets/{swimlanes-5IMT3BWC-BUd7UhsF.js → swimlanes-5IMT3BWC-CPoy6vnO.js} +2 -2
  66. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BV0EQRlB.js +8 -0
  67. package/web/dist/assets/{timeline-definition-FHXFAJF6-Dt1gludn.js → timeline-definition-FHXFAJF6-BGdNkplk.js} +1 -1
  68. package/web/dist/assets/{vennDiagram-L72KCM5P-BHxt00GV.js → vennDiagram-L72KCM5P-ByFzarUb.js} +1 -1
  69. package/web/dist/assets/{wardleyDiagram-EHGQE667-BT3fIDbe.js → wardleyDiagram-EHGQE667-CAk0SYuv.js} +1 -1
  70. package/web/dist/assets/{xychartDiagram-FW5EYKEG-ChoYDS2b.js → xychartDiagram-FW5EYKEG-Np7h6_A0.js} +1 -1
  71. package/web/dist/index.html +3 -3
  72. package/web/dist/assets/channel-CqgY9oZh.js +0 -1
  73. package/web/dist/assets/classDiagram-OUVF2IWQ-Uz4DYZs2.js +0 -1
  74. package/web/dist/assets/classDiagram-v2-EOCWNBFH-Uz4DYZs2.js +0 -1
  75. package/web/dist/assets/index-B4s2lTsK.js +0 -24
  76. package/web/dist/assets/index-DhGdE2Nd.css +0 -1
  77. package/web/dist/assets/pilotswarm-UV6T1_QI.js +0 -98
  78. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BhKG3pJB.js +0 -1
  79. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CgoFkXJE.js +0 -8
package/README.md CHANGED
@@ -260,3 +260,10 @@ Docs: [Quick Start](https://github.com/affandar/pilotswarm/blob/main/docs/quicks
260
260
  [MCP Setup](https://github.com/affandar/pilotswarm/blob/main/docs/user-guide/mcp-local-setup.md) ·
261
261
  [Web API Reference](https://github.com/affandar/pilotswarm/blob/main/docs/api/reference.md) ·
262
262
  [Architecture / Layering](https://github.com/affandar/pilotswarm/blob/main/docs/architecture/layering.md)
263
+
264
+ ## Master of Agents
265
+
266
+ The portal includes a personal multi-session workspace with resizable desktop
267
+ chat/canvas panels, one focused composer, and zen mode. Phones show one panel
268
+ at a time with a layout minimap and swipe navigation. Layouts save automatically.
269
+ See the [MoA guide](https://github.com/affandar/PilotSwarm/blob/main/docs/user-guide/master-of-agents.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pilotswarm",
3
- "version": "0.5.59",
3
+ "version": "0.5.61",
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.59",
84
+ "pilotswarm-sdk": "0.5.61",
85
85
  "react": "^19.2.4",
86
86
  "react-dom": "^19.2.4",
87
87
  "ws": "^8.18.2"
@@ -362,6 +362,16 @@ function timestampMs(value) {
362
362
  }
363
363
 
364
364
  function isSameOrOlderSessionUpdate(previousSession, nextSession) {
365
+ // Detail patch construction must use the same ordering as the reducer.
366
+ // Client event timestamps can exceed the server's next status write.
367
+ const version = (value) => {
368
+ if (value == null || value === "" || typeof value === "boolean") return null;
369
+ const number = Number(value);
370
+ return Number.isSafeInteger(number) && number > 0 ? number : null;
371
+ };
372
+ const previousVersion = version(previousSession?.statusVersion);
373
+ const nextVersion = version(nextSession?.statusVersion);
374
+ if (previousVersion != null && nextVersion != null) return nextVersion <= previousVersion;
365
375
  const previousUpdatedAt = timestampMs(previousSession?.updatedAt);
366
376
  const nextUpdatedAt = timestampMs(nextSession?.updatedAt);
367
377
  return previousUpdatedAt > 0 && nextUpdatedAt > 0 && nextUpdatedAt <= previousUpdatedAt;
@@ -473,6 +483,13 @@ function buildSessionMergePatch(previousSession, nextSession) {
473
483
  changed = true;
474
484
  }
475
485
 
486
+ // Ordering metadata is required even when unchanged. A conflicting status
487
+ // at the same version must not masquerade as an unversioned live event.
488
+ if (changed) {
489
+ for (const key of ["statusVersion", "updatedAt"]) {
490
+ if (nextSession[key] !== undefined) patch[key] = nextSession[key];
491
+ }
492
+ }
476
493
  return changed ? patch : null;
477
494
  }
478
495
 
@@ -3255,6 +3272,8 @@ export class PilotSwarmUiController {
3255
3272
  */
3256
3273
  revealCreatedSession() {
3257
3274
  if (this.getState().admin?.visible) this.closeAdminConsole();
3275
+ this.closeBudget();
3276
+ this.dispatch({ type: "ui/revealCreatedSession", sessionId: this.getState().sessions.activeSessionId });
3258
3277
  this.setFocus(FOCUS_REGIONS.PROMPT);
3259
3278
  }
3260
3279
 
@@ -5454,7 +5473,9 @@ export class PilotSwarmUiController {
5454
5473
  return;
5455
5474
  }
5456
5475
  await this.ensureSessionHistory(sessionId, { force: true });
5476
+ if (this.getState().sessions.activeSessionId !== sessionId) return;
5457
5477
  await this.syncSessionDetail(sessionId).catch(() => {});
5478
+ if (this.getState().sessions.activeSessionId !== sessionId) return;
5458
5479
  this.attachActiveSession(sessionId);
5459
5480
  this.ensureInspectorData().catch(() => {});
5460
5481
  // Canvas snapshot rides the selection burst. Invalidate-then-fetch on
@@ -5590,8 +5611,9 @@ export class PilotSwarmUiController {
5590
5611
  * history pipeline recognizes the canonical prefix and keeps it out of
5591
5612
  * the portal chat pane; the transcript still records it for provenance.
5592
5613
  */
5593
- submitCanvasAction(sessionId, message) {
5614
+ submitCanvasAction(sessionId, message, slot = 1) {
5594
5615
  if (!sessionId) return Promise.resolve({ ok: false, reason: "no session" });
5616
+ if (!Number.isInteger(slot) || slot < 1 || slot > 5) return Promise.resolve({ ok: false, reason: "invalid canvas slot" });
5595
5617
  // Creator-only, mirrored from the server's enforcement (the server is
5596
5618
  // the authority; this is the friendly refusal). The canvas mutates —
5597
5619
  // a shared viewer may be looking at a different revision than the one
@@ -5601,7 +5623,7 @@ export class PilotSwarmUiController {
5601
5623
  // longer pre-refuses non-owners: a write-shared collaborator's click
5602
5624
  // must reach the agent, and the server's refusal for a read-only
5603
5625
  // viewer comes back as the result's reason.
5604
- const contract = this.getState().canvas.bySessionId[sessionId]?.responseContract || null;
5626
+ const contract = this.getState().canvas.bySessionId[canvasPrefKey(sessionId, slot)]?.responseContract || null;
5605
5627
  const verdict = validateCanvasAction(contract, message);
5606
5628
  if (!verdict.ok) return Promise.resolve(verdict);
5607
5629
  if (!this.canvasActionLimiters) this.canvasActionLimiters = new Map();
@@ -6011,15 +6033,42 @@ export class PilotSwarmUiController {
6011
6033
  this.maybeFlushQueuedOutbox(sessionId, session);
6012
6034
  }
6013
6035
 
6036
+ async openCreatedSession(created, options = {}) {
6037
+ const sessionId = created.sessionId;
6038
+ // Creation is explicit navigation, even if a paged/stale catalog or
6039
+ // the current filters omit the new row. Seed the response, preserve
6040
+ // the outgoing draft, and latch the same visibility rules as a link.
6041
+ // Invalidate catalog reads that started before this row existed.
6042
+ this.sessionRefreshSeq = (this.sessionRefreshSeq || 0) + 1;
6043
+ if (this.getState().ui.promptEdit) this.exitPendingPromptEdit({ restoreDraft: true });
6044
+ this.dispatch({ type: "sessions/selected", sessionId });
6045
+ this.dispatch({ type: "ui/sequenceExpandedTurns", turns: [] });
6046
+ this.dispatch({ type: "ui/sequenceSelectedTurn", turn: null });
6047
+ this.dispatch({ type: "sessions/navigationIntent", sessionId });
6048
+ this.dispatch({ type: "sessions/merged", session: normalizeSessionListRow({
6049
+ ...created,
6050
+ ...(options.groupId ? { viewerGroupId: options.groupId } : {}),
6051
+ }) });
6052
+ this.revealCreatedSession();
6053
+ this.dispatch({ type: "ui/status", text: `Created session ${sessionId.slice(0, 8)}` });
6054
+ // A follow-up read failure must not masquerade as a failed create and
6055
+ // invite a duplicate. The regular refresh loop can finish hydration.
6056
+ try {
6057
+ await this.loadSession(sessionId);
6058
+ } catch (error) {
6059
+ if (this.getState().sessions.activeSessionId === sessionId) {
6060
+ this.dispatch({ type: "ui/status", text: `Created session ${sessionId.slice(0, 8)}; could not load it yet: ${error?.message || error}` });
6061
+ }
6062
+ }
6063
+ this.scheduleSessionsRefresh(0);
6064
+ }
6065
+
6014
6066
  async createSession(options = {}) {
6015
6067
  try {
6016
6068
  const requestOptions = this.applyActiveGroupDefault(options);
6017
6069
  const created = await this.transport.createSession(requestOptions);
6018
6070
  await this.placeCreatedSessionInGroup(created, requestOptions.groupId ?? null);
6019
- await this.refreshSessions();
6020
- await this.loadSession(created.sessionId);
6021
- this.revealCreatedSession();
6022
- this.dispatch({ type: "ui/status", text: `Created session ${created.sessionId.slice(0, 8)}` });
6071
+ await this.openCreatedSession(created, requestOptions);
6023
6072
  return created;
6024
6073
  } catch (error) {
6025
6074
  this.dispatch({ type: "ui/status", text: error?.message || String(error) || "Failed to create session" });
@@ -6039,13 +6088,7 @@ export class PilotSwarmUiController {
6039
6088
  // session that was just created, and a lost count only reorders a list.
6040
6089
  this._recordAgentPickerUse(agentName);
6041
6090
  await this.placeCreatedSessionInGroup(created, requestOptions.groupId ?? null);
6042
- await this.refreshSessions();
6043
- await this.loadSession(created.sessionId);
6044
- this.revealCreatedSession();
6045
- this.dispatch({
6046
- type: "ui/status",
6047
- text: `Created ${formatAgentDisplayTitle(agentName, options.title)} session ${created.sessionId.slice(0, 8)}`,
6048
- });
6091
+ await this.openCreatedSession(created, requestOptions);
6049
6092
  return created;
6050
6093
  } catch (error) {
6051
6094
  this.dispatch({ type: "ui/status", text: error?.message || String(error) || "Failed to create session" });
@@ -2,6 +2,7 @@ import { formatHumanDurationSeconds, formatTimestamp, shortModelName, shortSessi
2
2
  import { isCanvasActionContent, parseCanvasActionContent } from "./canvas-actions.js";
3
3
  import { formatCompactionActivityRuns } from "./context-usage.js";
4
4
  import { canonicalSystemTitle } from "./system-titles.js";
5
+ import { buildSessionWarning } from "./session-errors.js";
5
6
 
6
7
  export const DEFAULT_HISTORY_EVENT_LIMIT = 300;
7
8
  export const HISTORY_EVENT_LIMIT_STEPS = [
@@ -16,6 +17,7 @@ export const HISTORY_EVENT_LIMIT_STEPS = [
16
17
  // noisy sessions (thousands of tool/orchestration events between messages)
17
18
  // load transcript pages instead of raw-stream pages.
18
19
  export const CHAT_HISTORY_EVENT_TYPES = [
20
+ "session.error",
19
21
  "user.message",
20
22
  "assistant.message",
21
23
  // Needed to distinguish interim assistant output from the final answer
@@ -412,7 +414,8 @@ function areMessagesEquivalent(left, right) {
412
414
  // event per rev), and share identical text by design (the artifact://
413
415
  // link) — the redelivery time-window below would eat rev N-1 on every
414
416
  // reload, making the transcript disagree with what live append showed.
415
- if (left.kind === "canvas-update" || right.kind === "canvas-update") {
417
+ if (left.kind === "canvas-update" || right.kind === "canvas-update"
418
+ || left.kind === "session-warning" || right.kind === "session-warning") {
416
419
  return left.kind === right.kind && left.id === right.id;
417
420
  }
418
421
 
@@ -1211,6 +1214,10 @@ export function buildHistoryModel(events = [], options = {}) {
1211
1214
 
1212
1215
  for (const event of events) {
1213
1216
  storedEvents.push(event);
1217
+ if (event.eventType === "session.error") {
1218
+ const warning = buildSessionWarning(event);
1219
+ if (warning) chat.push(warning);
1220
+ }
1214
1221
  if (["user.message", "session.turn_completed", "session.turn_stopped", "session.epoch_committed"].includes(event.eventType)) {
1215
1222
  settleAssistantResponses(chat, event);
1216
1223
  }
@@ -1308,6 +1315,13 @@ export function appendEventToHistory(history, event) {
1308
1315
  stoppedMessageIds: Array.isArray(history?.stoppedMessageIds) ? history.stoppedMessageIds : [],
1309
1316
  };
1310
1317
 
1318
+ if (event.eventType === "session.error") {
1319
+ const warning = buildSessionWarning(event);
1320
+ if (warning && !next.chat.some(item => item.id === warning.id)) {
1321
+ next.chat = clampHistoryItems([...next.chat, warning], loadedEventLimit);
1322
+ }
1323
+ }
1324
+
1311
1325
  if (["user.message", "session.turn_completed", "session.turn_stopped", "session.epoch_committed"].includes(event.eventType)) {
1312
1326
  settleAssistantResponses(next.chat, event);
1313
1327
  }
@@ -16,3 +16,5 @@ export { isThemeLight } from "./themes/helpers.js";
16
16
  export * from "./themes/index.js";
17
17
  export { validateCanvasAction, formatCanvasActionPrompt, parseCanvasActionContent, isCanvasActionContent, createCanvasActionLimiter, CANVAS_ACTION_PREFIX } from "./canvas-actions.js";
18
18
  export * from "./table-layout.js";
19
+
20
+ export * from "./moa.js";
@@ -0,0 +1,47 @@
1
+ // One personal desktop layout, containing references and geometry only.
2
+ export const MOA_MAX_PANELS = 16;
3
+ export const MOA_BREAKPOINT = 920;
4
+ const object = (v) => v && typeof v === "object" && !Array.isArray(v);
5
+ const id = (v) => typeof v === "string" && /^[a-zA-Z0-9_-]{1,100}$/.test(v);
6
+ export const emptyMoaPanel = () => ({ id: crypto.randomUUID(), type: "empty" });
7
+ export const moaLeaves = (n) => !n ? [] : n.type === "split" ? [...moaLeaves(n.first), ...moaLeaves(n.second)] : [n];
8
+ export function normalizeMoaLayout(value) {
9
+ if (!object(value)) throw new Error("Invalid MoA layout.");
10
+ const seen = new Set(); let panels = 0; let nodes = 0;
11
+ function walk(n, depth = 0) {
12
+ if (!object(n) || depth > 15 || ++nodes > 31 || !id(n.id) || seen.has(n.id)) throw new Error("Invalid MoA panel tree.");
13
+ seen.add(n.id);
14
+ if (n.type === "split") {
15
+ if (!["row", "column"].includes(n.direction) || !Number.isFinite(n.ratio)) throw new Error("Invalid MoA split.");
16
+ return { id: n.id, type: "split", direction: n.direction, ratio: Math.min(90, Math.max(10, n.ratio)), first: walk(n.first, depth + 1), second: walk(n.second, depth + 1) };
17
+ }
18
+ if (++panels > MOA_MAX_PANELS) throw new Error(`A MoA supports up to ${MOA_MAX_PANELS} panels.`);
19
+ if (n.type === "empty") return { id: n.id, type: "empty" };
20
+ if (!["chat", "canvas"].includes(n.type) || !id(n.sessionId)) throw new Error("Invalid MoA session reference.");
21
+ if (n.type === "canvas" && (!Number.isInteger(n.slot) || n.slot < 1 || n.slot > 5)) throw new Error("Invalid canvas slot.");
22
+ return { id: n.id, type: n.type, sessionId: n.sessionId, ...(n.type === "canvas" ? { slot: n.slot } : {}) };
23
+ }
24
+ return { name: typeof value.name === "string" ? value.name.trim().slice(0, 64) || "Untitled MoA" : "Untitled MoA", tree: value.tree == null ? null : walk(value.tree) };
25
+ }
26
+ export function normalizeMoa(value) {
27
+ const safeTree = layout => { try { return normalizeMoaLayout(layout).tree; } catch { return null; } };
28
+ // Version 2 has one layout. A deliberately cleared layout must stay empty.
29
+ if (value?.version === 2 || (object(value) && Object.hasOwn(value, "tree"))) {
30
+ return { version: 2, tree: safeTree(value), ...(Number.isFinite(value.aspectRatio) && value.aspectRatio >= 0.2 && value.aspectRatio <= 8 ? { aspectRatio: value.aspectRatio } : {}) };
31
+ }
32
+ // Migrate the selected legacy dashboard when populated; otherwise keep
33
+ // the first populated dashboard rather than a previously selected blank tab.
34
+ const slots = Array.isArray(value?.slots) ? value.slots.slice(0, 5) : [];
35
+ const active = Number.isInteger(value?.activeSlot) ? Math.max(0, Math.min(4, value.activeSlot)) : 0;
36
+ const tree = safeTree(slots[active]) || slots.map(safeTree).find(Boolean) || null;
37
+ return { version: 2, tree };
38
+ }
39
+ export function replaceMoaNode(tree, nodeId, next) {
40
+ if (!tree) return null;
41
+ if (tree.id === nodeId) return next;
42
+ if (tree.type !== "split") return tree;
43
+ const first = replaceMoaNode(tree.first, nodeId, next), second = replaceMoaNode(tree.second, nodeId, next);
44
+ if (!first) return second;
45
+ if (!second) return first;
46
+ return first === tree.first && second === tree.second ? tree : { ...tree, first, second };
47
+ }
@@ -1,3 +1,5 @@
1
+ import { normalizeMoa } from "./moa.js";
2
+ import { retainSessionWarnings } from "./session-errors.js";
1
3
  import { buildSessionTree, isManuallyOrderableSession } from "./session-tree.js";
2
4
  import { FOCUS_REGIONS } from "./commands.js";
3
5
  import { DEFAULT_HISTORY_EVENT_LIMIT, dedupeChatMessages } from "./history.js";
@@ -317,62 +319,66 @@ function isSameOrOlderSessionUpdate(previousSession, nextSession) {
317
319
  return previousAt > 0 && nextAt > 0 && nextAt <= previousAt;
318
320
  }
319
321
 
320
- // A run that has ended for good. These are authoritative and must always land,
321
- // even from an update that looks stale stranding a finished session as
322
- // "running" is far worse than a brief wrong status.
322
+ // Completion can come from orchestration metadata without another custom
323
+ // status write. Allow it at the same version, but never at an older version.
323
324
  const TERMINAL_SESSION_STATUSES = new Set(["completed", "failed", "cancelled", "error"]);
324
325
 
325
- // Session state is written from several concurrent sources: the session-list
326
- // poll, live events, and a per-session detail fetch. They do not agree
327
- // instant-to-instant, and the list poll in particular can report a status the
328
- // orchestration has already moved on from — observed live as:
329
- //
330
- // sessions/loaded running + waiting -> waiting prevAt == incomingAt
331
- // sessions/merged waiting + running -> running (105ms later)
332
- //
333
- // while the server's own session row and orchestration both read "running"
334
- // throughout. Consumers gated on status === "running" the live-activity
335
- // strip, the composer's Stop button blink off for the width of that gap.
336
- //
337
- // Refuse ANY non-terminal downgrade out of "running" unless the incoming
338
- // update is genuinely newer. Deliberately not restricted to idle-like
339
- // statuses: the status actually observed clobbering a live run was "waiting",
340
- // and an earlier version of this guard missed the bug by excluding it.
341
- // Terminal statuses are exempt so a finished run can never be stranded.
342
- //
343
- // "Genuinely newer" is decided by the server's monotonic statusVersion when
344
- // both sides carry one — wall-clock updatedAt is only the fallback. The
345
- // timestamp heuristic alone can WEDGE a finished turn as "running" forever:
346
- // client-side event merges (turn_completed, context usage) inflate the held
347
- // session's updatedAt past the server row's idle-write timestamp, so every
348
- // subsequent idle poll compares as stale and only a terminal status could
349
- // ever land. Observed live (2026-07-21, local portal): server row idle at
350
- // statusVersion 5 while the client showed "Working.." indefinitely.
351
- function shouldPreserveRunningStatus(previousSession, nextSession) {
352
- const nextStatus = String(nextSession?.status ?? "").toLowerCase();
353
- if (previousSession?.status !== "running"
354
- || nextStatus === "running"
355
- || TERMINAL_SESSION_STATUSES.has(nextStatus)) {
356
- return false;
357
- }
358
- const previousVersion = Number(previousSession?.statusVersion);
359
- const nextVersion = Number(nextSession?.statusVersion);
360
- if (Number.isFinite(previousVersion) && previousVersion > 0
361
- && Number.isFinite(nextVersion) && nextVersion > 0) {
362
- // Monotonic server counter: a higher version is authoritative and
363
- // must land; a same-or-lower version is provably stale and is held
364
- // regardless of what the timestamps claim.
365
- return nextVersion <= previousVersion;
326
+ function sessionStatusVersion(session) {
327
+ const value = session?.statusVersion;
328
+ if (value == null || value === "" || typeof value === "boolean") return null;
329
+ const version = Number(value);
330
+ return Number.isSafeInteger(version) && version > 0 ? version : null;
331
+ }
332
+
333
+ // List polls and detail requests can arrive out of order. Compare the server
334
+ // counter in both directions: an old running row must not revive an idle
335
+ // session, and an old idle row must not hide a running one. Keep the version
336
+ // with its status; accepting an older counter would let the next stale row win.
337
+ function shouldPreserveSessionStatus(previousSession, nextSession) {
338
+ const previousVersion = sessionStatusVersion(previousSession);
339
+ const nextVersion = sessionStatusVersion(nextSession);
340
+ if (previousVersion != null && nextVersion != null) {
341
+ if (nextVersion < previousVersion) return true;
342
+ if (nextVersion > previousVersion) return false;
366
343
  }
367
- return isSameOrOlderSessionUpdate(previousSession, nextSession);
344
+ const previousStatus = previousSession?.status;
345
+ const nextStatus = nextSession?.status;
346
+ if (!previousStatus || !nextStatus || previousStatus === nextStatus
347
+ || TERMINAL_SESSION_STATUSES.has(nextStatus)) return false;
348
+ if (previousVersion != null && nextVersion != null) return true;
349
+ // Legacy rows and live event patches may lack a counter. Do not guess
350
+ // their order without timestamps. Preserve the terminal-status escape
351
+ // hatch, since updatedAt also changes for non-status events.
352
+ if (previousStatus === "running") return isSameOrOlderSessionUpdate(previousSession, nextSession);
353
+ // An unversioned new turn may share its timestamp with the preceding idle
354
+ // row. Equal timestamps alone are not evidence that a start is stale.
355
+ const previousAt = sessionUpdateTimestampMs(previousSession);
356
+ const nextAt = sessionUpdateTimestampMs(nextSession);
357
+ return nextStatus === "running" && previousAt > 0 && nextAt > 0 && nextAt < previousAt;
368
358
  }
369
359
 
360
+ // These values describe the same run as status. Mixing an old question or
361
+ // pause reason with a newer status can change composer routing and controls.
362
+ const SESSION_STATUS_FIELDS = new Set([
363
+ "status", "statusVersion", "updatedAt", "orchestrationStatus",
364
+ "pendingQuestion", "waitReason", "pauseState", "error", "result",
365
+ ]);
366
+
370
367
  function mergeDefinedSessionFields(previousSession = {}, nextSession = {}) {
371
368
  let merged = previousSession || {};
372
- const preserveRunning = shouldPreserveRunningStatus(previousSession, nextSession);
369
+ const preserveStatus = shouldPreserveSessionStatus(previousSession, nextSession);
370
+ const previousVersion = sessionStatusVersion(previousSession);
373
371
  for (const [key, value] of Object.entries(nextSession || {})) {
374
372
  if (value === undefined) continue;
375
- if (key === "status" && preserveRunning) continue;
373
+ if (preserveStatus && SESSION_STATUS_FIELDS.has(key)) continue;
374
+ // Repeated snapshots of one server version cannot roll its timestamp
375
+ // back and then admit an older unversioned running update.
376
+ if (key === "updatedAt" && previousVersion != null
377
+ && previousVersion === sessionStatusVersion(nextSession)
378
+ && sessionUpdateTimestampMs(nextSession) < sessionUpdateTimestampMs(previousSession)) continue;
379
+ // An absent/invalid counter must not erase the last known server version.
380
+ if (key === "statusVersion" && previousVersion != null
381
+ && sessionStatusVersion(nextSession) == null) continue;
376
382
  if (key === "pendingQuestion" && isAnsweredPendingQuestion(previousSession, value)) {
377
383
  if (merged === previousSession) {
378
384
  merged = { ...(previousSession || {}) };
@@ -995,6 +1001,10 @@ function baseReducer(state, action) {
995
1001
  if (Boolean(action.collapsed) === Boolean(state.ui.sessionDetailCollapsed)) return state;
996
1002
  return { ...state, ui: { ...state.ui, sessionDetailCollapsed: Boolean(action.collapsed) } };
997
1003
 
1004
+ case "ui/moaSaveStatus":
1005
+ return { ...state, ui: { ...state.ui, moaSaveStatus: action.status, moaDirty: action.status === "saved" && state.ui.moaRevision === action.revision ? false : state.ui.moaDirty } };
1006
+ case "ui/moa":
1007
+ return { ...state, ui: { ...state.ui, moa: normalizeMoa(action.value), moaDirty: true, moaRevision: (state.ui.moaRevision || 0) + 1 } };
998
1008
  case "profileSettings/apply": {
999
1009
  const settings = action.settings && typeof action.settings === "object" && !Array.isArray(action.settings)
1000
1010
  ? action.settings
@@ -1098,6 +1108,8 @@ function baseReducer(state, action) {
1098
1108
  sessions: selection.sessions,
1099
1109
  ui: {
1100
1110
  ...selection.ui,
1111
+ moa: !state.ui.moaDirty && Object.prototype.hasOwnProperty.call(settings, "moa") ? normalizeMoa(settings.moa) : selection.ui.moa,
1112
+ moaLoaded: true,
1101
1113
  themeId: hasTheme ? settings.themeId.trim() : selection.ui.themeId,
1102
1114
  touchScale: hasTouchScale ? settings.touchScale : selection.ui.touchScale,
1103
1115
  sessionDetailCollapsed: hasSessionDetailCollapsed
@@ -1624,7 +1636,8 @@ function baseReducer(state, action) {
1624
1636
  }
1625
1637
  for (const session of action.sessions) {
1626
1638
  const previous = state.sessions.byId[session.sessionId];
1627
- byId[session.sessionId] = mergeDefinedSessionFields(previous, session);
1639
+ byId[session.sessionId] = retainSessionWarnings(previous, mergeDefinedSessionFields(previous, session),
1640
+ state.history.bySessionId.get(session.sessionId)?.events, nowMs);
1628
1641
  }
1629
1642
  if (
1630
1643
  state.sessions.activeSessionId
@@ -1807,7 +1820,8 @@ function baseReducer(state, action) {
1807
1820
  case "sessions/merged": {
1808
1821
  if (!action.session?.sessionId) return state;
1809
1822
  const previousSession = state.sessions.byId[action.session.sessionId];
1810
- const mergedSession = mergeDefinedSessionFields(previousSession, action.session);
1823
+ const mergedSession = retainSessionWarnings(previousSession, mergeDefinedSessionFields(previousSession, action.session),
1824
+ state.history.bySessionId.get(action.session.sessionId)?.events);
1811
1825
  // A single session going running/idle changes its ANCESTORS' counts
1812
1826
  // and therefore their visual status, so both passes run over the
1813
1827
  // whole map rather than the one row. buildSessionTree below is
@@ -1939,6 +1953,13 @@ function baseReducer(state, action) {
1939
1953
  };
1940
1954
  }
1941
1955
 
1956
+ case "ui/revealCreatedSession":
1957
+ return {
1958
+ ...state,
1959
+ files: { ...state.files, fullscreen: false, paneOpen: false },
1960
+ ui: { ...state.ui, revealedCreatedSessionId: action.sessionId, fullscreenPane: null },
1961
+ };
1962
+
1942
1963
  case "ui/fullscreenPane": {
1943
1964
  const fullscreenPane = normalizeFullscreenPane(action.fullscreenPane);
1944
1965
  return {
@@ -28,6 +28,7 @@ import {
28
28
  getContextHeaderBadge,
29
29
  } from "./context-usage.js";
30
30
  import { canonicalSystemTitle } from "./system-titles.js";
31
+ import { withSessionWarnings } from "./session-errors.js";
31
32
  import {
32
33
  BUDGET_PERIODS,
33
34
  BUDGET_SERIES_DAYS,
@@ -1894,13 +1895,15 @@ export function selectActiveChat(state) {
1894
1895
  ? buildPendingQuestionMessage(session, events)
1895
1896
  : null;
1896
1897
  const answeredQuestionMessage = buildAnsweredPendingQuestionMessage(session, chat);
1897
- const sessionErrorMessage = buildSessionErrorMessage(session, events);
1898
+ // Legacy/synthetic selector callers may not have passed through the
1899
+ // reducer that captures status-only notices. Keep their fallback too.
1900
+ const sessionErrorMessage = session?.chatWarnings?.length ? null : buildSessionErrorMessage(session, events);
1898
1901
 
1899
- if ((!history || chat.length === 0) && !pendingQuestionMessage && !answeredQuestionMessage && !sessionErrorMessage) {
1902
+ if ((!history || chat.length === 0) && !pendingQuestionMessage && !answeredQuestionMessage && !sessionErrorMessage && !session?.chatWarnings?.length) {
1900
1903
  return createSplashCard(state.branding, session);
1901
1904
  }
1902
1905
 
1903
- const messages = chat.length > 0 ? [...chat] : createSplashCard(state.branding, session);
1906
+ const messages = withSessionWarnings(chat.length > 0 ? chat : createSplashCard(state.branding, session), session, events);
1904
1907
  if (pendingQuestionMessage) {
1905
1908
  messages.push(pendingQuestionMessage);
1906
1909
  }
@@ -1908,7 +1911,8 @@ export function selectActiveChat(state) {
1908
1911
  messages.push(answeredQuestionMessage);
1909
1912
  }
1910
1913
  if (sessionErrorMessage) {
1911
- messages.push(sessionErrorMessage);
1914
+ const index = messages.findIndex(message => sessionErrorMessage.createdAt != null && message.createdAt > sessionErrorMessage.createdAt);
1915
+ messages.splice(index < 0 ? messages.length : index, 0, sessionErrorMessage);
1912
1916
  }
1913
1917
  return messages;
1914
1918
  }
@@ -2520,19 +2524,23 @@ function buildChatMessageLinesUncached(message, maxWidth, options = {}) {
2520
2524
  return buildThinkingCardLines(message, maxWidth);
2521
2525
  }
2522
2526
 
2523
- // The browser gives an in-flight assistant answer its own stable surface.
2524
- // Previously it was flattened into the ordinary transcript on every
2525
- // snapshot. As partial markdown changed shape (plain lines -> table/code
2526
- // block), React could replace several adjacent nodes and the whole answer
2527
- // appeared to flash. One keyed card keeps the outer DOM and its shading in
2528
- // place while only the body grows. The TUI deliberately keeps its existing
2529
- // line-oriented rendering.
2527
+ // One stable disclosure spans live output, saved updates and the final
2528
+ // answer. Its identity is not evidence of streaming: durable history uses
2529
+ // this shell too, including turns created with PILOTSWARM_LIVE_TURN=0.
2530
+ // Only actual transient content may use preview labels/status. The native
2531
+ // TUI keeps its line-oriented rendering of these same durable messages.
2530
2532
  if ((message?.liveTurn || message?.streamSettling || message?.assistantPreview) && options.tableMode === "sentinel") {
2533
+ // streamSettling also preserves DOM through durable arrival; it does
2534
+ // not mean those saved bytes are still provisional.
2535
+ const provisional = !message?.assistantPreview && Boolean(message?.liveTurn || message?.streamSettling);
2531
2536
  // Carry raw markdown: collapsed previews never parse their hidden body.
2532
2537
  return [{
2533
2538
  kind: "assistantPreview",
2534
2539
  width: maxWidth,
2535
- text: "Message preview",
2540
+ text: provisional ? "Message preview" : "Agent update",
2541
+ statusText: !provisional ? "" : message?.liveTurn
2542
+ ? (message?.text ? "Responding" : "Thinking")
2543
+ : message?.streamSettling ? "Finishing" : "",
2536
2544
  previewKey: `assistant:${message?.liveKey || message?.messageId || message?.id || "active-turn"}`,
2537
2545
  body: decorateArtifactLinksForChat(message?.text || ""),
2538
2546
  reasoningText: String(message?.reasoningText || message?.liveReasoningText || ""),
@@ -2971,11 +2979,16 @@ let chatLinesMemo = [];
2971
2979
 
2972
2980
  export function selectChatLines(state, maxWidth = 80, options = {}) {
2973
2981
  const memoSessionId = state?.sessions?.activeSessionId ?? null;
2982
+ const memoSession = memoSessionId ? state?.sessions?.byId?.[memoSessionId] ?? null : null;
2974
2983
  const memoKey = {
2975
2984
  sessionId: memoSessionId,
2976
- session: memoSessionId ? state?.sessions?.byId?.[memoSessionId] ?? null : null,
2985
+ session: memoSession,
2977
2986
  byId: state?.sessions?.byId ?? null,
2978
2987
  chat: state?.history?.bySessionId?.get?.(memoSessionId)?.chat ?? null,
2988
+ // Raw tool activity affects warning/question reconciliation, but must
2989
+ // not re-wrap an ordinary, unchanged transcript on every event.
2990
+ events: memoSession?.chatWarnings?.length || memoSession?.error || memoSession?.pendingQuestion
2991
+ ? state?.history?.bySessionId?.get?.(memoSessionId)?.events ?? null : null,
2979
2992
  branding: state?.branding ?? null,
2980
2993
  principal: state?.auth?.principal ?? null,
2981
2994
  maxWidth,
@@ -2986,6 +2999,7 @@ export function selectChatLines(state, maxWidth = 80, options = {}) {
2986
2999
  && entry.session === memoKey.session
2987
3000
  && entry.byId === memoKey.byId
2988
3001
  && entry.chat === memoKey.chat
3002
+ && entry.events === memoKey.events
2989
3003
  && entry.viewMode === memoKey.viewMode
2990
3004
  && entry.branding === memoKey.branding
2991
3005
  && entry.principal === memoKey.principal
@@ -17,3 +17,100 @@ export function shouldKeepSessionWarning(previousSession, nextSession) {
17
17
  const nextAt = time(nextSession?.updatedAt);
18
18
  return previousAt > 0 && nextAt > 0 && nextAt <= previousAt;
19
19
  }
20
+
21
+ const timeMs = value => value instanceof Date ? value.getTime()
22
+ : typeof value === "number" ? value : Date.parse(value || "");
23
+ const errorText = event => typeof event?.data === "string" ? event.data
24
+ : String(event?.data?.message || event?.data?.error || event?.data?.text || "").trim();
25
+ const sameError = (statusText, eventText) => Boolean(eventText) && statusText.includes(eventText);
26
+
27
+ export function buildSessionWarning(event) {
28
+ const text = errorText(event);
29
+ if (!text) return null;
30
+ const failed = event?.data?.fatal === true;
31
+ return {
32
+ id: `${event.sessionId}:${event.seq}:warning`,
33
+ kind: "session-warning", role: "system", text,
34
+ warningSeq: Number(event.seq),
35
+ createdAt: timeMs(event.createdAt) || null,
36
+ cardTitle: failed ? "Error" : "Warning",
37
+ cardTitleColor: failed ? "red" : "yellow",
38
+ cardBorderColor: failed ? "red" : "yellow",
39
+ };
40
+ }
41
+
42
+ // Status-only failures have no chat event yet (some never get one). Capture
43
+ // their position ONCE in shared state, not on each render/poll. Recovery ends
44
+ // the active episode but does not erase its notice. Durable error events are
45
+ // rendered separately and survive reload/history paging.
46
+ export function retainSessionWarnings(previous, next, events = [], now = Date.now()) {
47
+ const warnings = previous?.chatWarnings || [];
48
+ const last = warnings.at(-1);
49
+ const text = String(next?.error || "").trim();
50
+ const failed = next?.status === "failed" || next?.orchestrationStatus === "Failed";
51
+ const active = Boolean(text) && (failed || !["completed", "cancelled", "terminated", "input_required"].includes(next?.status))
52
+ && !["Completed", "Terminated"].includes(next?.orchestrationStatus);
53
+ if (!active) {
54
+ if (!last?.active) return next;
55
+ return { ...next, chatWarnings: [...warnings.slice(0, -1), { ...last, active: false }] };
56
+ }
57
+ const since = last?.createdAt || 0;
58
+ const event = events.findLast(e => e.eventType === "session.error"
59
+ && timeMs(e.createdAt) > since && sameError(text, errorText(e)));
60
+ // A retained error field can outlive a terminal/wait state. A later
61
+ // running snapshot alone is not evidence of another failure episode.
62
+ if (last && !last.active && text === last.text && text === String(previous?.error || "").trim() && !event) return next;
63
+ const movedOn = last && text !== last.text && events.some(e =>
64
+ Number(e.seq) > last.afterSeq && timeMs(e.createdAt) > last.createdAt
65
+ && ["user.message", "assistant.message"].includes(e.eventType));
66
+ if (last?.active && !movedOn) {
67
+ if (last.text === text && last.failed === failed) return next;
68
+ return { ...next, chatWarnings: [...warnings.slice(0, -1), { ...last, text, failed }] };
69
+ }
70
+ const ordinal = (last?.ordinal || 0) + 1;
71
+ const previousWarnings = last?.active ? [...warnings.slice(0, -1), { ...last, active: false }] : warnings;
72
+ return { ...next, chatWarnings: [...previousWarnings, {
73
+ id: `session-error:${next.sessionId}:${ordinal}`, ordinal, text, failed, active: true,
74
+ eventSeq: event ? Number(event.seq) : null,
75
+ afterSeq: Number(events.at(-1)?.seq) || 0,
76
+ since,
77
+ createdAt: timeMs(event?.createdAt) || timeMs(next.updatedAt) || now,
78
+ }].slice(-300) };
79
+ }
80
+
81
+ export function withSessionWarnings(chat, session, events = []) {
82
+ const messages = [...chat];
83
+ for (const warning of session?.chatWarnings || []) {
84
+ // Status often wins the race against its durable event. Match only
85
+ // that boundary, never a later failure after a new user/agent message.
86
+ const following = events.filter(e => Number(e.seq) > warning.afterSeq);
87
+ const boundary = following.find(e => ["session.error", "user.message", "assistant.message"].includes(e.eventType));
88
+ // On initial load the catalog can arrive before any history at all.
89
+ // Find its matching older event by the captured failure time, without
90
+ // reusing an event from a previous error episode with identical text.
91
+ // Chat can outlive the raw-event window when tool activity is noisy.
92
+ // Reconcile from the retained warning rows, not only from raw events.
93
+ const recorded = chat.findLast(m => m.kind === "session-warning"
94
+ && timeMs(m.createdAt) > (warning.since || 0)
95
+ && timeMs(m.createdAt) <= warning.createdAt
96
+ && sameError(warning.text, m.text));
97
+ const eventSeq = warning.eventSeq ?? recorded?.warningSeq
98
+ ?? (boundary?.eventType === "session.error" && sameError(warning.text, errorText(boundary)) ? Number(boundary.seq) : null);
99
+ const index = messages.findIndex(m => m.kind === "session-warning" && m.warningSeq === eventSeq);
100
+ const notice = {
101
+ id: warning.id, kind: "session-warning", role: "system", text: warning.text,
102
+ createdAt: warning.createdAt,
103
+ cardTitle: warning.failed ? "Error" : "Warning",
104
+ cardTitleColor: warning.failed ? "red" : "yellow",
105
+ cardBorderColor: warning.failed ? "red" : "yellow",
106
+ };
107
+ if (index >= 0) {
108
+ // Preserve the mounted card while the durable row takes over.
109
+ messages[index] = { ...messages[index], ...notice, createdAt: messages[index].createdAt };
110
+ } else {
111
+ const nextIndex = messages.findIndex(m => timeMs(m.createdAt) > warning.createdAt);
112
+ messages.splice(nextIndex < 0 ? messages.length : nextIndex, 0, notice);
113
+ }
114
+ }
115
+ return messages;
116
+ }