pilotswarm 0.5.18 → 0.5.20

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pilotswarm",
3
- "version": "0.5.18",
3
+ "version": "0.5.20",
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",
@@ -78,7 +78,7 @@
78
78
  "hono": "^4.12.10",
79
79
  "ink": "^6.8.0",
80
80
  "jose": "^6.2.2",
81
- "pilotswarm-sdk": "^0.5.18",
81
+ "pilotswarm-sdk": "^0.5.20",
82
82
  "react": "^19.2.4",
83
83
  "react-dom": "^19.2.4",
84
84
  "ws": "^8.18.2"
@@ -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);
@@ -6820,6 +6882,7 @@ export class PilotSwarmUiController {
6820
6882
  for (const session of eligible) {
6821
6883
  try {
6822
6884
  await this.transport.deleteSession(session.sessionId);
6885
+ this.handleSessionGone(session.sessionId);
6823
6886
  succeeded += 1;
6824
6887
  } catch (error) {
6825
6888
  failures.push(`${session.sessionId.slice(0, 8)}: ${error?.message || String(error)}`);
@@ -6925,6 +6988,7 @@ export class PilotSwarmUiController {
6925
6988
  return;
6926
6989
  }
6927
6990
  await this.transport.deleteSession(sessionId);
6991
+ this.handleSessionGone(sessionId);
6928
6992
  this.dispatch({ type: "ui/status", text: `Deleted ${sessionId.slice(0, 8)}` });
6929
6993
  await this.refreshSessions();
6930
6994
  }
@@ -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
  }
@@ -311,6 +311,13 @@ function deriveChatRole(event, fallbackRole, text) {
311
311
  return fallbackRole;
312
312
  }
313
313
 
314
+ function sharesClientMessageId(left, right) {
315
+ const leftIds = Array.isArray(left?.clientMessageIds) ? left.clientMessageIds : [];
316
+ const rightIds = Array.isArray(right?.clientMessageIds) ? right.clientMessageIds : [];
317
+ if (leftIds.length === 0 || rightIds.length === 0) return null; // unknown
318
+ return leftIds.some((id) => rightIds.includes(id));
319
+ }
320
+
314
321
  function areMessagesEquivalent(left, right) {
315
322
  if (!left || !right) return false;
316
323
  if (left.role !== right.role) return false;
@@ -319,6 +326,13 @@ function areMessagesEquivalent(left, right) {
319
326
  const rightText = comparableMessageText(right);
320
327
  if (!leftText || !rightText || leftText !== rightText) return false;
321
328
 
329
+ // clientMessageIds are authoritative when both sides carry them: a
330
+ // duroxide activity retry re-records the SAME queue message (same ids)
331
+ // regardless of how many seconds the retry took, while a user deliberately
332
+ // re-sending identical text mints fresh ids and must stay two bubbles.
333
+ const sharedId = sharesClientMessageId(left, right);
334
+ if (sharedId != null) return sharedId;
335
+
322
336
  const leftTime = Number(left.createdAt || 0);
323
337
  const rightTime = Number(right.createdAt || 0);
324
338
  if (left.optimistic || right.optimistic) return true;
@@ -347,7 +361,24 @@ export function dedupeChatMessages(chat = []) {
347
361
 
348
362
  const previousTime = Number(previous?.createdAt || 0);
349
363
  const currentTime = Number(message?.createdAt || 0);
350
- deduped[deduped.length - 1] = currentTime >= previousTime ? message : previous;
364
+ const winner = currentTime >= previousTime ? message : previous;
365
+ // Two durable copies of the same user message = the runtime
366
+ // re-delivered it to the model after a mid-turn worker retry.
367
+ // Collapse to one bubble stamped with the LATEST delivery time and
368
+ // mark it so the transcript can show a redelivery glyph.
369
+ if (winner.role === "user" && !previous?.optimistic && !message?.optimistic) {
370
+ const firstDeliveredAt = Math.min(
371
+ Number(previous?.firstDeliveredAt || previousTime || Infinity),
372
+ Number(message?.firstDeliveredAt || currentTime || Infinity),
373
+ );
374
+ deduped[deduped.length - 1] = {
375
+ ...winner,
376
+ redelivered: true,
377
+ ...(Number.isFinite(firstDeliveredAt) ? { firstDeliveredAt } : {}),
378
+ };
379
+ continue;
380
+ }
381
+ deduped[deduped.length - 1] = winner;
351
382
  }
352
383
 
353
384
  return deduped;
@@ -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;
@@ -1396,11 +1396,13 @@ function buildChatMessagePrefix(message, options = {}) {
1396
1396
  : "PilotSwarm";
1397
1397
 
1398
1398
  // 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
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
1404
+ // ✓✓↻ redelivered — the runtime retried the turn and re-delivered this
1405
+ // message to the model; timestamp shows the LATEST delivery
1404
1406
  let glyph = null;
1405
1407
  let glyphColor = null;
1406
1408
  if (message?.pendingPhase === "pending") {
@@ -1418,6 +1420,11 @@ function buildChatMessagePrefix(message, options = {}) {
1418
1420
  // may not have acted on it. Amber prohibition ("no parking") sign.
1419
1421
  glyph = "⊘";
1420
1422
  glyphColor = "yellow";
1423
+ } else if (message?.redelivered) {
1424
+ // Delivered twice (worker retry replayed the turn). Amber so the
1425
+ // retry is visible without reading as a failure.
1426
+ glyph = "✓✓↻";
1427
+ glyphColor = "yellow";
1421
1428
  } else {
1422
1429
  // Real durable user.message in transcript — show the "sent" double-check.
1423
1430
  glyph = "✓✓";