pilotswarm 0.5.19 → 0.5.21

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.
@@ -127,6 +127,15 @@ function classifyNavigationLoadError(error) {
127
127
  return "network";
128
128
  }
129
129
 
130
+ // A 404/NOT_FOUND on a per-session data loop is TERMINAL: the session was
131
+ // deleted (here or elsewhere) and every retry will 404 forever. 403 stays
132
+ // retryable — a transient auth blip must not evict a live pane.
133
+ function isSessionGoneError(error) {
134
+ const status = Number(error?.status);
135
+ const code = String(error?.code || "").toUpperCase();
136
+ return status === 404 || code === "NOT_FOUND";
137
+ }
138
+
130
139
  async function loadSessionCatalogPageWindow(transport) {
131
140
  if (typeof transport.listSessionsPage !== "function") {
132
141
  return transport.listSessions();
@@ -1992,6 +2001,25 @@ export class PilotSwarmUiController {
1992
2001
  this.activeSessionSubscriptionId = null;
1993
2002
  }
1994
2003
 
2004
+ /**
2005
+ * A session no longer exists server-side (deleted locally or by another
2006
+ * client; the server answered 404). Unbind everything referencing it so
2007
+ * the detail/events/orchestration-stats loops stop instead of 404-spamming
2008
+ * on every refresh tick until reload.
2009
+ */
2010
+ handleSessionGone(sessionId) {
2011
+ if (!sessionId) return;
2012
+ if (this.activeSessionSubscriptionId === sessionId) {
2013
+ this.detachActiveSession();
2014
+ }
2015
+ if (this.activeSessionDetailSessionId === sessionId && this.activeSessionDetailTimer) {
2016
+ clearTimeout(this.activeSessionDetailTimer);
2017
+ this.activeSessionDetailTimer = null;
2018
+ this.activeSessionDetailSessionId = null;
2019
+ }
2020
+ this.dispatch({ type: "sessions/gone", sessionId });
2021
+ }
2022
+
1995
2023
  detachLogStream() {
1996
2024
  if (this.logUnsubscribe) {
1997
2025
  this.logUnsubscribe();
@@ -2213,7 +2241,10 @@ export class PilotSwarmUiController {
2213
2241
  let newEvents;
2214
2242
  try {
2215
2243
  newEvents = await this.transport.getSessionEvents(sessionId, afterSeq, CATCH_UP_PAGE_LIMIT);
2216
- } catch {
2244
+ } catch (error) {
2245
+ if (isSessionGoneError(error)) {
2246
+ this.handleSessionGone(sessionId);
2247
+ }
2217
2248
  return null;
2218
2249
  }
2219
2250
  if (!Array.isArray(newEvents) || newEvents.length >= CATCH_UP_PAGE_LIMIT) {
@@ -2280,7 +2311,16 @@ export class PilotSwarmUiController {
2280
2311
  }
2281
2312
 
2282
2313
  const loadPromise = (async () => {
2283
- const events = await this.transport.getSessionEvents(sessionId, undefined, requestedLimit);
2314
+ let events;
2315
+ try {
2316
+ events = await this.transport.getSessionEvents(sessionId, undefined, requestedLimit);
2317
+ } catch (error) {
2318
+ if (isSessionGoneError(error)) {
2319
+ this.handleSessionGone(sessionId);
2320
+ return null;
2321
+ }
2322
+ throw error;
2323
+ }
2284
2324
  const history = {
2285
2325
  ...buildHistoryModel(events, { requestedLimit }),
2286
2326
  lastSeq: events[events.length - 1]?.seq || 0,
@@ -2403,6 +2443,10 @@ export class PilotSwarmUiController {
2403
2443
  });
2404
2444
  return this.getState().orchestration.bySessionId?.[sessionId] || null;
2405
2445
  } catch (error) {
2446
+ if (isSessionGoneError(error)) {
2447
+ this.handleSessionGone(sessionId);
2448
+ return null;
2449
+ }
2406
2450
  this.dispatch({
2407
2451
  type: "orchestration/statsError",
2408
2452
  sessionId,
@@ -3440,7 +3484,16 @@ export class PilotSwarmUiController {
3440
3484
  return;
3441
3485
  }
3442
3486
  const afterSeq = Number(existing.lastSeq || 0);
3443
- const events = await this.transport.getSessionEvents(sessionId, afterSeq, 200);
3487
+ let events = null;
3488
+ try {
3489
+ events = await this.transport.getSessionEvents(sessionId, afterSeq, 200);
3490
+ } catch (error) {
3491
+ if (isSessionGoneError(error)) {
3492
+ this.handleSessionGone(sessionId);
3493
+ return;
3494
+ }
3495
+ throw error;
3496
+ }
3444
3497
  if (!Array.isArray(events) || events.length === 0) return;
3445
3498
  for (const event of events) {
3446
3499
  this.mergeSessionEvent(sessionId, event);
@@ -3481,7 +3534,16 @@ export class PilotSwarmUiController {
3481
3534
 
3482
3535
  async syncSessionDetail(sessionId) {
3483
3536
  if (typeof this.transport.getSession !== "function" || !sessionId) return;
3484
- const session = await this.transport.getSession(sessionId);
3537
+ let session = null;
3538
+ try {
3539
+ session = await this.transport.getSession(sessionId);
3540
+ } catch (error) {
3541
+ if (isSessionGoneError(error)) {
3542
+ this.handleSessionGone(sessionId);
3543
+ return;
3544
+ }
3545
+ throw error;
3546
+ }
3485
3547
  if (!session) return;
3486
3548
  const previousSession = this.getState().sessions.byId[sessionId] || null;
3487
3549
  const patch = buildSessionMergePatch(previousSession, session);
@@ -4589,11 +4651,16 @@ export class PilotSwarmUiController {
4589
4651
  type: "ui/modal",
4590
4652
  modal: {
4591
4653
  type: "terminatePicker",
4592
- title: `Terminate (${shortSessionIdValue(sessionId)})`,
4654
+ title: `Lifecycle (${shortSessionIdValue(sessionId)})`,
4593
4655
  sessionId,
4594
4656
  previousFocus: state.ui.focusRegion,
4595
4657
  sessionTitle: String(session.title || "").trim(),
4596
4658
  state: String(session.state || "").trim(),
4659
+ // Regenerate (epoch rebirth) is a single-session, non-system,
4660
+ // non-group action; the picker surfaces it above the terminal
4661
+ // dispositions when the transport supports it. Service sessions
4662
+ // (⚗ machinery, e.g. the distiller itself) are never regenerated.
4663
+ canRegenerate: typeof this.transport.regenerateSession === "function" && !session.serviceKind,
4597
4664
  },
4598
4665
  });
4599
4666
  }
@@ -4615,6 +4682,8 @@ export class PilotSwarmUiController {
4615
4682
  await this.cancelActiveSession();
4616
4683
  } else if (action === "delete") {
4617
4684
  await this.deleteActiveSession();
4685
+ } else if (action === "regenerate") {
4686
+ await this.regenerateActiveSession();
4618
4687
  }
4619
4688
  }
4620
4689
 
@@ -5087,6 +5156,8 @@ export class PilotSwarmUiController {
5087
5156
  await this.completeActiveSession("Completed by user", { confirmed: true });
5088
5157
  } else if (modal.action === "deleteSession") {
5089
5158
  await this.deleteActiveSession({ confirmed: true });
5159
+ } else if (modal.action === "regenerateSession") {
5160
+ await this.regenerateActiveSession({ confirmed: true, ...(modal.extras || {}) });
5090
5161
  } else if (modal.action === "deleteArtifact") {
5091
5162
  await this.deleteSelectedArtifact({ confirmed: true });
5092
5163
  }
@@ -6565,6 +6636,88 @@ export class PilotSwarmUiController {
6565
6636
  await this.refreshSessions();
6566
6637
  }
6567
6638
 
6639
+ /**
6640
+ * Regenerate the active session's context in place (epoch rebirth): archive
6641
+ * the transcript, distill it into a resume package, and rebuild the Copilot
6642
+ * session at the next turn boundary. NON-destructive — facts, artifacts,
6643
+ * children, sharing, schedule, and chat history are preserved; only the
6644
+ * LLM's working context is compacted and recreated. Enqueue-then-observe:
6645
+ * the outcome arrives asynchronously as session.regenerate_* / epoch events.
6646
+ */
6647
+ /**
6648
+ * Merge extra inputs (distilling instructions, distill mode) into the open
6649
+ * confirm modal. The portal's regenerate confirm renders a textarea + mode
6650
+ * select bound here; the TUI renders the same modal as a plain confirm and
6651
+ * simply submits the defaults — graceful degradation, no TUI-side wiring.
6652
+ */
6653
+ updateConfirmExtras(patch) {
6654
+ const modal = this.getState().ui.modal;
6655
+ if (!modal || modal.type !== "confirm") return;
6656
+ this.dispatch({
6657
+ type: "ui/modal",
6658
+ modal: { ...modal, extras: { ...(modal.extras || {}), ...(patch || {}) } },
6659
+ });
6660
+ }
6661
+
6662
+ async regenerateActiveSession({ confirmed = false, instructions = "", distillMode = "llm" } = {}) {
6663
+ const state = this.getState();
6664
+ const sessionId = state.sessions.activeSessionId;
6665
+ if (!sessionId) return;
6666
+ if (typeof this.transport.regenerateSession !== "function") {
6667
+ this.dispatch({ type: "ui/status", text: "Session regeneration is not supported by this deployment" });
6668
+ return;
6669
+ }
6670
+ const activeSession = state.sessions.byId[sessionId];
6671
+ if (activeSession?.isGroup) {
6672
+ this.dispatch({ type: "ui/status", text: "Groups are containers; regenerate sessions individually." });
6673
+ return;
6674
+ }
6675
+ if (activeSession?.isSystem) {
6676
+ this.dispatch({ type: "ui/status", text: "System sessions cannot be regenerated." });
6677
+ return;
6678
+ }
6679
+ if (activeSession?.serviceKind) {
6680
+ this.dispatch({ type: "ui/status", text: "Service sessions are runtime machinery and cannot be regenerated." });
6681
+ return;
6682
+ }
6683
+ if (!confirmed) {
6684
+ const label = activeSession?.title || sessionId.slice(0, 8);
6685
+ this.dispatch({
6686
+ type: "ui/modal",
6687
+ modal: {
6688
+ type: "confirm",
6689
+ title: "Regenerate Session",
6690
+ message: `Regenerate context for "${label}"? The transcript is archived and distilled, then the session rebuilds fresh from it at the next turn boundary. Facts, artifacts, sub-agents, sharing, schedule, and chat history are preserved. As an operator action this overrides the rate limits (cooldown / minimum age).`,
6691
+ confirmLabel: "Regenerate",
6692
+ action: "regenerateSession",
6693
+ sessionId,
6694
+ previousFocus: state.ui.focusRegion,
6695
+ // Distillation inputs the portal's renderer binds via
6696
+ // updateConfirmExtras; plain-confirm renderers submit these
6697
+ // defaults untouched.
6698
+ extras: { instructions: "", distillMode: "llm" },
6699
+ },
6700
+ });
6701
+ return;
6702
+ }
6703
+ try {
6704
+ // Operator action behind a confirm — force past the soft rate limits
6705
+ // (cooldown / min-age). Hard gates (system session, regen in flight)
6706
+ // still apply server-side.
6707
+ const trimmed = String(instructions || "").trim();
6708
+ await this.transport.regenerateSession(sessionId, {
6709
+ force: true,
6710
+ ...(trimmed ? { instructions: trimmed.slice(0, 4000) } : {}),
6711
+ ...(distillMode === "deterministic" ? { distillMode: "deterministic" } : {}),
6712
+ });
6713
+ this.dispatch({ type: "ui/status", text: `Regeneration requested for ${sessionId.slice(0, 8)} — rebuilding at the next boundary` });
6714
+ } catch (error) {
6715
+ this.dispatch({ type: "ui/status", text: `Regenerate failed: ${error?.message || String(error)}` });
6716
+ return;
6717
+ }
6718
+ await this.refreshSessions();
6719
+ }
6720
+
6568
6721
  /**
6569
6722
  * Stop the active session's in-flight LLM turn without touching session
6570
6723
  * lifecycle. Applies to user AND system sessions; only group/container
@@ -6820,6 +6973,7 @@ export class PilotSwarmUiController {
6820
6973
  for (const session of eligible) {
6821
6974
  try {
6822
6975
  await this.transport.deleteSession(session.sessionId);
6976
+ this.handleSessionGone(session.sessionId);
6823
6977
  succeeded += 1;
6824
6978
  } catch (error) {
6825
6979
  failures.push(`${session.sessionId.slice(0, 8)}: ${error?.message || String(error)}`);
@@ -6925,6 +7079,7 @@ export class PilotSwarmUiController {
6925
7079
  return;
6926
7080
  }
6927
7081
  await this.transport.deleteSession(sessionId);
7082
+ this.handleSessionGone(sessionId);
6928
7083
  this.dispatch({ type: "ui/status", text: `Deleted ${sessionId.slice(0, 8)}` });
6929
7084
  await this.refreshSessions();
6930
7085
  }
@@ -7140,6 +7295,9 @@ export class PilotSwarmUiController {
7140
7295
  case UI_COMMANDS.DELETE_SESSION:
7141
7296
  await this.deleteActiveSession();
7142
7297
  return;
7298
+ case UI_COMMANDS.REGENERATE_SESSION:
7299
+ await this.regenerateActiveSession();
7300
+ return;
7143
7301
  case UI_COMMANDS.PIN_SESSION:
7144
7302
  this.togglePinActiveSession();
7145
7303
  return;
@@ -100,16 +100,25 @@ export function shortModelName(model) {
100
100
  return value.includes(":") ? value.split(":").slice(1).join(":") : value;
101
101
  }
102
102
 
103
- export function formatTimestamp(value) {
103
+ export function formatTimestamp(value, now = new Date()) {
104
104
  if (!value) return "";
105
105
  try {
106
106
  const date = value instanceof Date ? value : new Date(value);
107
- return date.toLocaleTimeString(undefined, {
107
+ const time = date.toLocaleTimeString(undefined, {
108
108
  hour: "2-digit",
109
109
  minute: "2-digit",
110
110
  second: "2-digit",
111
111
  hour12: false,
112
112
  });
113
+ // Same-day messages show time only; anything older carries its date
114
+ // so a transcript spanning days stays unambiguous.
115
+ const sameLocalDay = date.getFullYear() === now.getFullYear()
116
+ && date.getMonth() === now.getMonth()
117
+ && date.getDate() === now.getDate();
118
+ if (sameLocalDay) return time;
119
+ const day = date.toLocaleDateString("en-GB", { month: "short", day: "numeric" });
120
+ const year = date.getFullYear() === now.getFullYear() ? "" : ` ${date.getFullYear()}`;
121
+ return `${day}${year} ${time}`;
113
122
  } catch {
114
123
  return "";
115
124
  }
@@ -18,8 +18,46 @@ export const CHAT_HISTORY_EVENT_TYPES = [
18
18
  "user.message",
19
19
  "assistant.message",
20
20
  "system.message",
21
+ // Session regeneration boundary — rendered as an inline epoch divider in
22
+ // the transcript, so it must survive backward chat-history paging.
23
+ "session.epoch_committed",
24
+ // A refused regeneration — surfaced inline so the optimistic "regeneration
25
+ // accepted" ack is corrected by the real outcome (e.g. cooldown/too_young).
26
+ "session.regenerate_refused",
21
27
  ];
22
28
 
29
+ // Build the inline transcript divider marking a session-regeneration epoch
30
+ // flip (proposal M2). The epoch_committed seq IS the epoch boundary, so the
31
+ // divider lands between the archived (old-epoch) turns and the fresh ones.
32
+ function buildEpochDividerItem(event) {
33
+ const data = event?.data && typeof event.data === "object" ? event.data : {};
34
+ const epoch = Number.isFinite(data.toEpoch) ? data.toEpoch : (Number(data.epoch) || 0);
35
+ const turnsArchived = Number.isFinite(data.turnsArchived) ? data.turnsArchived : null;
36
+ return {
37
+ id: `${event.sessionId}:${event.seq}:epoch`,
38
+ kind: "epoch-divider",
39
+ role: "epoch-divider",
40
+ epoch,
41
+ turnsArchived,
42
+ time: formatTimestamp(event.createdAt),
43
+ createdAt: event.createdAt instanceof Date ? event.createdAt.getTime() : new Date(event.createdAt).getTime(),
44
+ };
45
+ }
46
+
47
+ // A refused regeneration attempt, rendered inline so the truth (e.g. cooldown,
48
+ // too_young) corrects the optimistic "regeneration accepted" the tool returns.
49
+ function buildRegenRefusedItem(event) {
50
+ const data = event?.data && typeof event.data === "object" ? event.data : {};
51
+ return {
52
+ id: `${event.sessionId}:${event.seq}:regen-refused`,
53
+ kind: "regen-refused",
54
+ role: "regen-refused",
55
+ reason: typeof data.reason === "string" ? data.reason : "unknown",
56
+ time: formatTimestamp(event.createdAt),
57
+ createdAt: event.createdAt instanceof Date ? event.createdAt.getTime() : new Date(event.createdAt).getTime(),
58
+ };
59
+ }
60
+
23
61
  function clampHistoryItems(items, maxItems) {
24
62
  const list = Array.isArray(items) ? items.filter(Boolean) : [];
25
63
  const safeMax = Math.max(DEFAULT_HISTORY_EVENT_LIMIT, Number(maxItems) || DEFAULT_HISTORY_EVENT_LIMIT);
@@ -311,6 +349,13 @@ function deriveChatRole(event, fallbackRole, text) {
311
349
  return fallbackRole;
312
350
  }
313
351
 
352
+ function sharesClientMessageId(left, right) {
353
+ const leftIds = Array.isArray(left?.clientMessageIds) ? left.clientMessageIds : [];
354
+ const rightIds = Array.isArray(right?.clientMessageIds) ? right.clientMessageIds : [];
355
+ if (leftIds.length === 0 || rightIds.length === 0) return null; // unknown
356
+ return leftIds.some((id) => rightIds.includes(id));
357
+ }
358
+
314
359
  function areMessagesEquivalent(left, right) {
315
360
  if (!left || !right) return false;
316
361
  if (left.role !== right.role) return false;
@@ -319,6 +364,13 @@ function areMessagesEquivalent(left, right) {
319
364
  const rightText = comparableMessageText(right);
320
365
  if (!leftText || !rightText || leftText !== rightText) return false;
321
366
 
367
+ // clientMessageIds are authoritative when both sides carry them: a
368
+ // duroxide activity retry re-records the SAME queue message (same ids)
369
+ // regardless of how many seconds the retry took, while a user deliberately
370
+ // re-sending identical text mints fresh ids and must stay two bubbles.
371
+ const sharedId = sharesClientMessageId(left, right);
372
+ if (sharedId != null) return sharedId;
373
+
322
374
  const leftTime = Number(left.createdAt || 0);
323
375
  const rightTime = Number(right.createdAt || 0);
324
376
  if (left.optimistic || right.optimistic) return true;
@@ -347,7 +399,24 @@ export function dedupeChatMessages(chat = []) {
347
399
 
348
400
  const previousTime = Number(previous?.createdAt || 0);
349
401
  const currentTime = Number(message?.createdAt || 0);
350
- deduped[deduped.length - 1] = currentTime >= previousTime ? message : previous;
402
+ const winner = currentTime >= previousTime ? message : previous;
403
+ // Two durable copies of the same user message = the runtime
404
+ // re-delivered it to the model after a mid-turn worker retry.
405
+ // Collapse to one bubble stamped with the LATEST delivery time and
406
+ // mark it so the transcript can show a redelivery glyph.
407
+ if (winner.role === "user" && !previous?.optimistic && !message?.optimistic) {
408
+ const firstDeliveredAt = Math.min(
409
+ Number(previous?.firstDeliveredAt || previousTime || Infinity),
410
+ Number(message?.firstDeliveredAt || currentTime || Infinity),
411
+ );
412
+ deduped[deduped.length - 1] = {
413
+ ...winner,
414
+ redelivered: true,
415
+ ...(Number.isFinite(firstDeliveredAt) ? { firstDeliveredAt } : {}),
416
+ };
417
+ continue;
418
+ }
419
+ deduped[deduped.length - 1] = winner;
351
420
  }
352
421
 
353
422
  return deduped;
@@ -870,6 +939,14 @@ export function buildHistoryModel(events = [], options = {}) {
870
939
  }
871
940
  continue;
872
941
  }
942
+ if (event.eventType === "session.epoch_committed") {
943
+ chat.push(buildEpochDividerItem(event));
944
+ continue;
945
+ }
946
+ if (event.eventType === "session.regenerate_refused") {
947
+ chat.push(buildRegenRefusedItem(event));
948
+ continue;
949
+ }
873
950
  const activityItem = formatActivity(event);
874
951
  if (activityItem) activity.push(activityItem);
875
952
  }
@@ -967,6 +1044,16 @@ export function appendEventToHistory(history, event) {
967
1044
  }
968
1045
  return next;
969
1046
  }
1047
+ if (event.eventType === "session.epoch_committed") {
1048
+ next.chat.push(buildEpochDividerItem(event));
1049
+ next.chat = clampHistoryItems(next.chat, loadedEventLimit);
1050
+ return next;
1051
+ }
1052
+ if (event.eventType === "session.regenerate_refused") {
1053
+ next.chat.push(buildRegenRefusedItem(event));
1054
+ next.chat = clampHistoryItems(next.chat, loadedEventLimit);
1055
+ return next;
1056
+ }
970
1057
  const activityItem = formatActivity(event);
971
1058
  if (activityItem) {
972
1059
  next.activity.push(activityItem);
@@ -1027,6 +1027,32 @@ export function appReducer(state, action) {
1027
1027
  },
1028
1028
  };
1029
1029
 
1030
+ case "sessions/gone": {
1031
+ // Terminal eviction: the server answered 404 for this session (or
1032
+ // we just deleted it). Drop the row and release the active-session
1033
+ // latch so panes unbind and the data loops stop retrying — the
1034
+ // sessions/loaded active-session carve-out below would otherwise
1035
+ // resurrect the row forever.
1036
+ const goneId = action.sessionId;
1037
+ if (!goneId) return state;
1038
+ const hadRow = Boolean(state.sessions.byId[goneId]);
1039
+ const wasActive = state.sessions.activeSessionId === goneId;
1040
+ if (!hadRow && !wasActive) return state;
1041
+ const byId = { ...state.sessions.byId };
1042
+ delete byId[goneId];
1043
+ const selectedIds = Array.isArray(state.sessions.selectedIds)
1044
+ ? state.sessions.selectedIds.filter((id) => id !== goneId)
1045
+ : state.sessions.selectedIds;
1046
+ return {
1047
+ ...state,
1048
+ sessions: {
1049
+ ...state.sessions,
1050
+ byId,
1051
+ selectedIds,
1052
+ activeSessionId: wasActive ? null : state.sessions.activeSessionId,
1053
+ },
1054
+ };
1055
+ }
1030
1056
  case "sessions/loaded": {
1031
1057
  const byId = {};
1032
1058
  let anyChanged = false;
@@ -513,8 +513,16 @@ function canPinSessionRow(session) {
513
513
 
514
514
  function buildSelectedSessionMetaRuns(session, mode) {
515
515
  const runs = [];
516
+ // Live regeneration chip: the orchestration publishes regenStage in
517
+ // customStatus while the pipeline runs (archiving → distilling →
518
+ // flipping) and getSession spreads it onto the session view. Magenta to
519
+ // match the epoch divider; disappears when the flip lands.
520
+ if (typeof session?.regenStage === "string" && session.regenStage) {
521
+ runs.push({ text: `↻ regen:${session.regenStage}`, color: "magenta" });
522
+ }
516
523
  const statusLabel = getSessionRowStatusLabel(session);
517
524
  if (statusLabel) {
525
+ if (runs.length > 0) runs.push({ text: " · ", color: "gray" });
518
526
  runs.push({ text: statusLabel, color: sessionStatusColor(session, mode) });
519
527
  }
520
528
 
@@ -584,6 +592,10 @@ function buildSessionRowView(entry, session, state, totalDescendantCounts, visib
584
592
  prefixRuns.push({ text: "🗂 ", color: "cyan", bold: true });
585
593
  } else if (session?.isSystem) {
586
594
  prefixRuns.push({ text: "⚙ ", color: "yellow", bold: true });
595
+ } else if (session?.serviceKind) {
596
+ // Service session (tree-scoped machinery, e.g. the regen distiller):
597
+ // the alembic marks it as read-only distillation machinery.
598
+ prefixRuns.push({ text: "⚗ ", color: "magenta", bold: true });
587
599
  } else {
588
600
  const icon = sessionStatusIcon(session, mode);
589
601
  prefixRuns.push({
@@ -1396,11 +1408,13 @@ function buildChatMessagePrefix(message, options = {}) {
1396
1408
  : "PilotSwarm";
1397
1409
 
1398
1410
  // Delivery glyph for user messages:
1399
- // ○ pending — client outbox, not yet durable
1400
- // ✓ queued — durably enqueued, waiting for orchestration to drain
1401
- // x cancelling — durable cancel requested, waiting for runtime outcome
1402
- // x rejected — server refused the send (authz); auto-dropped shortly
1403
- // ✓✓ sent — persisted as user.message in CMS, LLM has it
1411
+ // ○ pending — client outbox, not yet durable
1412
+ // ✓ queued — durably enqueued, waiting for orchestration to drain
1413
+ // x cancelling — durable cancel requested, waiting for runtime outcome
1414
+ // x rejected — server refused the send (authz); auto-dropped shortly
1415
+ // ✓✓ sent — persisted as user.message in CMS, LLM has it
1416
+ // ✓✓↻ redelivered — the runtime retried the turn and re-delivered this
1417
+ // message to the model; timestamp shows the LATEST delivery
1404
1418
  let glyph = null;
1405
1419
  let glyphColor = null;
1406
1420
  if (message?.pendingPhase === "pending") {
@@ -1418,6 +1432,11 @@ function buildChatMessagePrefix(message, options = {}) {
1418
1432
  // may not have acted on it. Amber prohibition ("no parking") sign.
1419
1433
  glyph = "⊘";
1420
1434
  glyphColor = "yellow";
1435
+ } else if (message?.redelivered) {
1436
+ // Delivered twice (worker retry replayed the turn). Amber so the
1437
+ // retry is visible without reading as a failure.
1438
+ glyph = "✓✓↻";
1439
+ glyphColor = "yellow";
1421
1440
  } else {
1422
1441
  // Real durable user.message in transcript — show the "sent" double-check.
1423
1442
  glyph = "✓✓";
@@ -2280,8 +2299,14 @@ export function selectChatLines(state, maxWidth = 80, options = {}) {
2280
2299
  };
2281
2300
  const lines = [];
2282
2301
  for (const [index, message] of messages.entries()) {
2283
- const messageLines = buildChatMessageLines(message, maxWidth, buildOptions);
2284
- appendChatBlockLines(lines, messageLines);
2302
+ if (message?.kind === "epoch-divider") {
2303
+ lines.push(buildEpochDividerLine(message, maxWidth));
2304
+ } else if (message?.kind === "regen-refused") {
2305
+ lines.push(buildRegenRefusedLine(message, maxWidth));
2306
+ } else {
2307
+ const messageLines = buildChatMessageLines(message, maxWidth, buildOptions);
2308
+ appendChatBlockLines(lines, messageLines);
2309
+ }
2285
2310
  const nextMessage = messages[index + 1];
2286
2311
  if (
2287
2312
  nextMessage
@@ -2294,6 +2319,55 @@ export function selectChatLines(state, maxWidth = 80, options = {}) {
2294
2319
  return lines.length > 0 ? lines : [{ text: "No messages yet.", color: "gray" }];
2295
2320
  }
2296
2321
 
2322
+ // A centered inline rule ("──── label ────") for transcript markers. The dash
2323
+ // runs are CAPPED (not stretched to maxWidth): the web portal wraps by pixel
2324
+ // width, and a maxWidth-long "─" run overflows a narrower pane and wraps the
2325
+ // rule mid-label. A short symmetric rule reads as a divider on every width, and
2326
+ // when the label alone will not fit it is rendered bare (wraps as plain text,
2327
+ // never as dangling dash fragments).
2328
+ function buildRuleLine(label, color, maxWidth) {
2329
+ const safeWidth = Math.max(24, Number(maxWidth) || 80);
2330
+ const room = safeWidth - label.length;
2331
+ if (room < 2) return [{ text: label.trim(), color, bold: true }];
2332
+ const perSide = Math.min(6, Math.floor(room / 2));
2333
+ if (perSide < 1) return [{ text: label.trim(), color, bold: true }];
2334
+ return [
2335
+ { text: "─".repeat(perSide), color },
2336
+ { text: label, color, bold: true },
2337
+ { text: "─".repeat(perSide), color },
2338
+ ];
2339
+ }
2340
+
2341
+ // The inline transcript divider for a session-regeneration epoch flip — magenta,
2342
+ // with the new epoch and the count of archived turns (proposal M2).
2343
+ function buildEpochDividerLine(message, maxWidth) {
2344
+ const turns = Number.isFinite(message?.turnsArchived) ? message.turnsArchived : null;
2345
+ const label = ` ↻ context regenerated · epoch ${message?.epoch ?? "?"}`
2346
+ + `${turns != null ? ` · ${turns} turn${turns === 1 ? "" : "s"} archived` : ""} `;
2347
+ return buildRuleLine(label, "magenta", maxWidth);
2348
+ }
2349
+
2350
+ // Friendly (compact) text for the orchestration's regenerate_refused reasons
2351
+ // (lifecycle.ts). Kept short so the inline rule fits on one line.
2352
+ const REGEN_REFUSED_REASONS = {
2353
+ cooldown: "on cooldown (once per 6h)",
2354
+ too_young: "too soon (needs 5+ turns)",
2355
+ already_pending: "already in progress",
2356
+ is_system: "not allowed for system sessions",
2357
+ not_owner: "owner only",
2358
+ not_parent: "parent only",
2359
+ };
2360
+
2361
+ // The inline notice for a refused regeneration. Yellow (vs the magenta success
2362
+ // divider) so a no-op attempt reads as a warning, correcting the tool's
2363
+ // optimistic "regeneration accepted" acknowledgement.
2364
+ function buildRegenRefusedLine(message, maxWidth) {
2365
+ const reason = String(message?.reason || "unknown");
2366
+ const text = REGEN_REFUSED_REASONS[reason] || reason.replace(/_/g, " ");
2367
+ const label = ` ↻ regeneration refused · ${text} `;
2368
+ return buildRuleLine(label, "yellow", maxWidth);
2369
+ }
2370
+
2297
2371
  export function selectOutboxOverlayLines(state, maxWidth = 80, options = {}) {
2298
2372
  const messages = selectActiveOutboxMessages(state);
2299
2373
  if (!messages || messages.length === 0) return [];
@@ -3990,7 +4064,7 @@ function buildNodeMapCell(session, brandingTitle, width, active) {
3990
4064
  ? canonicalSystemTitle(session, brandingTitle)
3991
4065
  : (session?.title || shortSessionId(session?.sessionId)))
3992
4066
  : shortSessionId(session?.sessionId);
3993
- const prefix = session?.isSystem ? "⚙ " : `${sessionStatusIcon(session) || "."} `;
4067
+ const prefix = session?.isSystem ? "⚙ " : session?.serviceKind ? "⚗ " : `${sessionStatusIcon(session) || "."} `;
3994
4068
  const text = padDisplayText(`${prefix}${label}`, width);
3995
4069
 
3996
4070
  if (active) {
@@ -5149,10 +5223,28 @@ function buildSessionStatsLines(state, session, maxWidth) {
5149
5223
  }));
5150
5224
  lines.push(plainInspectorLine(""));
5151
5225
 
5152
- // Persistence card
5226
+ // Persistence card. Epoch (session-regeneration incarnation) is always
5227
+ // shown — 0 for a session that has never regenerated — so the current
5228
+ // epoch is visible at a glance; regen counters appear once it has.
5229
+ const regenCount = Number(summary.regenCount) || 0;
5230
+ const lastRegen = summary.lastRegenStats && typeof summary.lastRegenStats === "object" ? summary.lastRegenStats : null;
5231
+ const currentEpoch = Number.isFinite(lastRegen?.toEpoch) ? lastRegen.toEpoch : regenCount;
5232
+ // Distillation provenance: "fast" = deterministic package; otherwise the
5233
+ // distiller model label (strip the provider: prefix for width).
5234
+ const distillLabel = lastRegen
5235
+ ? (lastRegen.distillMode === "deterministic" || (!lastRegen.distillMode && !lastRegen.distillerModel)
5236
+ ? "fast"
5237
+ : String(lastRegen.distillerModel || "llm").replace(/^[^:]*:/, ""))
5238
+ : null;
5239
+ const lastRegenLabel = lastRegen
5240
+ ? `${lastRegen.turnsArchived ?? 0} turn${lastRegen.turnsArchived === 1 ? "" : "s"} · ${(Number(lastRegen.totalMs) / 1000).toFixed(1)}s${distillLabel ? ` · ${distillLabel}` : ""}`
5241
+ : null;
5153
5242
  lines.push(...buildMessageCardLines({
5154
5243
  title: "Persistence",
5155
5244
  body: formatKeyValueTable([
5245
+ ["Epoch", String(currentEpoch)],
5246
+ ["Regens", regenCount > 0 ? String(regenCount) : null],
5247
+ ["Last Regen", lastRegenLabel],
5156
5248
  ["Snapshot", formatCompactBytes(summary.snapshotSizeBytes)],
5157
5249
  ["Uncompressed", summary.rawSizeBytes ? formatCompactBytes(summary.rawSizeBytes) : null],
5158
5250
  ["Compression", formatCompressionRatio(summary.rawSizeBytes, summary.snapshotSizeBytes)],