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.
- package/mcp/dist/src/tools/debug.d.ts.map +1 -1
- package/mcp/dist/src/tools/debug.js +2 -0
- package/mcp/dist/src/tools/debug.js.map +1 -1
- package/mcp/dist/src/tools/observability.d.ts.map +1 -1
- package/mcp/dist/src/tools/observability.js +5 -2
- package/mcp/dist/src/tools/observability.js.map +1 -1
- package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
- package/mcp/dist/src/tools/sessions.js +48 -3
- package/mcp/dist/src/tools/sessions.js.map +1 -1
- package/package.json +2 -2
- package/tui/src/app.js +5 -0
- package/tui/src/node-sdk-transport.js +8 -0
- package/ui/core/src/commands.js +1 -0
- package/ui/core/src/controller.js +163 -5
- package/ui/core/src/formatting.js +11 -2
- package/ui/core/src/history.js +88 -1
- package/ui/core/src/reducer.js +26 -0
- package/ui/core/src/selectors.js +101 -9
- package/ui/react/src/web-app.js +65 -13
- package/web/dist/assets/{index-DgcjQGly.js → index-BDMVG8w6.js} +3 -3
- package/web/dist/assets/pilotswarm-CjMx44so.js +90 -0
- package/web/dist/index.html +2 -2
- package/web/runtime.js +4 -0
- package/web/dist/assets/pilotswarm-DgoJR1qM.js +0 -90
|
@@ -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);
|
|
@@ -4589,11 +4651,16 @@ export class PilotSwarmUiController {
|
|
|
4589
4651
|
type: "ui/modal",
|
|
4590
4652
|
modal: {
|
|
4591
4653
|
type: "terminatePicker",
|
|
4592
|
-
title: `
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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);
|
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
|
@@ -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
|
-
// ○
|
|
1400
|
-
// ✓
|
|
1401
|
-
// x
|
|
1402
|
-
// x
|
|
1403
|
-
// ✓✓
|
|
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
|
-
|
|
2284
|
-
|
|
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)],
|