pilotswarm 0.5.19 → 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 +2 -2
- package/ui/core/src/controller.js +68 -4
- package/ui/core/src/formatting.js +11 -2
- package/ui/core/src/history.js +32 -1
- package/ui/core/src/reducer.js +26 -0
- package/ui/core/src/selectors.js +12 -5
- package/web/dist/assets/{index-DgcjQGly.js → index-4Y-kstkw.js} +2 -2
- package/web/dist/assets/{pilotswarm-DgoJR1qM.js → pilotswarm-BAEO8V1c.js} +53 -53
- package/web/dist/index.html +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pilotswarm",
|
|
3
|
-
"version": "0.5.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/ui/core/src/history.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/ui/core/src/reducer.js
CHANGED
|
@@ -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;
|
package/ui/core/src/selectors.js
CHANGED
|
@@ -1396,11 +1396,13 @@ function buildChatMessagePrefix(message, options = {}) {
|
|
|
1396
1396
|
: "PilotSwarm";
|
|
1397
1397
|
|
|
1398
1398
|
// Delivery glyph for user messages:
|
|
1399
|
-
// ○
|
|
1400
|
-
// ✓
|
|
1401
|
-
// x
|
|
1402
|
-
// x
|
|
1403
|
-
// ✓✓
|
|
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 = "✓✓";
|