spexcode 0.6.5 → 0.6.6
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/node_modules/@spexcode/{spec-cli → session-core}/dist/delivery-queue.d.ts +1 -1
- package/node_modules/@spexcode/{spec-cli → session-core}/dist/delivery-queue.js +18 -2
- package/node_modules/@spexcode/session-core/dist/index.d.ts +5 -0
- package/node_modules/@spexcode/session-core/dist/index.js +5 -0
- package/node_modules/@spexcode/session-core/dist/internal.d.ts +3 -0
- package/node_modules/@spexcode/session-core/dist/internal.js +3 -0
- package/node_modules/@spexcode/session-core/dist/message.d.ts +22 -0
- package/node_modules/@spexcode/session-core/dist/message.js +52 -0
- package/node_modules/@spexcode/session-core/dist/record-lock.d.ts +7 -0
- package/node_modules/@spexcode/session-core/dist/record-lock.js +152 -0
- package/node_modules/@spexcode/session-core/dist/runtime-session.d.ts +50 -0
- package/node_modules/@spexcode/session-core/dist/runtime-session.js +286 -0
- package/node_modules/@spexcode/session-core/dist/session-timeline.d.ts +46 -0
- package/node_modules/@spexcode/session-core/dist/session-timeline.js +216 -0
- package/node_modules/@spexcode/session-core/package.json +33 -0
- package/node_modules/@spexcode/spec-cli/bin/spex.mjs +2 -1
- package/node_modules/@spexcode/spec-cli/dist/claude-headless.d.ts +4 -1
- package/node_modules/@spexcode/spec-cli/dist/claude-headless.js +13 -4
- package/node_modules/@spexcode/spec-cli/dist/cli.js +72 -23
- package/node_modules/@spexcode/spec-cli/dist/client.d.ts +2 -1
- package/node_modules/@spexcode/spec-cli/dist/client.js +13 -8
- package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +5 -0
- package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +112 -0
- package/node_modules/@spexcode/spec-cli/dist/doctor.js +7 -1
- package/node_modules/@spexcode/spec-cli/dist/gateway-hub.js +7 -5
- package/node_modules/@spexcode/spec-cli/dist/gateway.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/gateway.js +44 -20
- package/node_modules/@spexcode/spec-cli/dist/graphCache.js +2 -1
- package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +2 -1
- package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +15 -2
- package/node_modules/@spexcode/spec-cli/dist/harness.js +174 -54
- package/node_modules/@spexcode/spec-cli/dist/help.d.ts +5 -0
- package/node_modules/@spexcode/spec-cli/dist/help.js +26 -6
- package/node_modules/@spexcode/spec-cli/dist/index.js +3 -3
- package/node_modules/@spexcode/spec-cli/dist/listen.d.ts +2 -1
- package/node_modules/@spexcode/spec-cli/dist/listen.js +10 -10
- package/node_modules/@spexcode/spec-cli/dist/opencode-headless.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/opencode-headless.js +7 -0
- package/node_modules/@spexcode/spec-cli/dist/runtime-rotate.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/runtime-rotate.js +58 -0
- package/node_modules/@spexcode/spec-cli/dist/session-follow.js +1 -1
- package/node_modules/@spexcode/spec-cli/dist/session-timeline.d.ts +6 -46
- package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +8 -221
- package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +25 -4
- package/node_modules/@spexcode/spec-cli/dist/sessions.js +822 -394
- package/node_modules/@spexcode/spec-cli/dist/supervise.js +3 -3
- package/node_modules/@spexcode/spec-cli/package.json +6 -4
- package/node_modules/@spexcode/spec-core/dist/git.d.ts +4 -0
- package/node_modules/@spexcode/spec-core/dist/git.js +32 -0
- package/node_modules/@spexcode/spec-core/dist/layout.d.ts +4 -0
- package/node_modules/@spexcode/spec-core/package.json +1 -1
- package/node_modules/@spexcode/spec-eval/package.json +2 -2
- package/node_modules/@spexcode/spec-forge/package.json +2 -2
- package/package.json +3 -3
- /package/node_modules/@spexcode/{spec-cli → session-core}/dist/session-cursors.d.ts +0 -0
- /package/node_modules/@spexcode/{spec-cli → session-core}/dist/session-cursors.js +0 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { SessionLifecycle, SessionProposal } from '@spexcode/spec-core';
|
|
2
|
+
export type TimelineEvent = {
|
|
3
|
+
ts: string;
|
|
4
|
+
kind: 'status';
|
|
5
|
+
status: SessionLifecycle;
|
|
6
|
+
proposal: SessionProposal | null;
|
|
7
|
+
note: string | null;
|
|
8
|
+
display?: string;
|
|
9
|
+
} | {
|
|
10
|
+
ts: string;
|
|
11
|
+
kind: 'sent';
|
|
12
|
+
mid: string;
|
|
13
|
+
text: string;
|
|
14
|
+
from: string | null;
|
|
15
|
+
replyVia?: 'note';
|
|
16
|
+
};
|
|
17
|
+
export type SentDispatchReceipt = {
|
|
18
|
+
operation: string;
|
|
19
|
+
requestDigest: string;
|
|
20
|
+
payloadHash: string;
|
|
21
|
+
delivery?: {
|
|
22
|
+
text: string;
|
|
23
|
+
from: string | null;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
export declare function recordStatus(id: string, status: SessionLifecycle, proposal: SessionProposal | null, note: string | null): void;
|
|
27
|
+
export declare function appendSent(id: string, text: string, from: string | null, replyVia?: 'note', dispatchReceipt?: SentDispatchReceipt): {
|
|
28
|
+
mid: string;
|
|
29
|
+
};
|
|
30
|
+
export type SentDispatchState = {
|
|
31
|
+
mid: string;
|
|
32
|
+
payloadHash: string;
|
|
33
|
+
delivery: SentDispatchReceipt['delivery'] | null;
|
|
34
|
+
delivered: boolean;
|
|
35
|
+
};
|
|
36
|
+
export declare function sentDispatchReceipt(id: string, operation: SentDispatchReceipt['operation'], requestDigest: string): SentDispatchState | null;
|
|
37
|
+
export declare function settleSentDispatch(id: string, mid: string): void;
|
|
38
|
+
export declare function timelineEvents(id: string): TimelineEvent[];
|
|
39
|
+
export declare function timelineStamp(id: string): string | null;
|
|
40
|
+
export declare function lastHumanSendVia(id: string): 'note' | null;
|
|
41
|
+
export type SessionTurn = Readonly<{
|
|
42
|
+
token: string;
|
|
43
|
+
acceptedAt: string;
|
|
44
|
+
}>;
|
|
45
|
+
export declare function currentHumanTurn(id: string): SessionTurn | null;
|
|
46
|
+
export declare function timelineTail(id: string, limit?: number): TimelineEvent[];
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, appendFileSync, mkdirSync, statSync, readdirSync, openSync, closeSync, readSync } from 'node:fs';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { sessionStoreDir, sessionArtifactPath } from '@spexcode/spec-core';
|
|
5
|
+
const timelinePath = (id) => sessionArtifactPath(id, 'timeline.ndjson');
|
|
6
|
+
const segmentsDir = (id) => sessionArtifactPath(id, 'timeline');
|
|
7
|
+
const SEGMENT = /^(\d+)\.ndjson$/;
|
|
8
|
+
const SEGMENT_NAME_WIDTH = 12;
|
|
9
|
+
const TAIL_BLOCK_BYTES = 64 * 1024;
|
|
10
|
+
// One logical timeline is legacy timeline.ndjson followed by immutable numbered segments. The directory
|
|
11
|
+
// listing is its only index: numbering is append order, so there is no mutable manifest to repair.
|
|
12
|
+
function segmentFiles(id) {
|
|
13
|
+
try {
|
|
14
|
+
const dir = segmentsDir(id);
|
|
15
|
+
const names = readdirSync(dir).filter((name) => SEGMENT.test(name)).sort((a, b) => {
|
|
16
|
+
const an = BigInt(SEGMENT.exec(a)[1]), bn = BigInt(SEGMENT.exec(b)[1]);
|
|
17
|
+
return an < bn ? -1 : an > bn ? 1 : 0;
|
|
18
|
+
});
|
|
19
|
+
return names.map((name) => join(dir, name));
|
|
20
|
+
}
|
|
21
|
+
catch { /* no numbered segments yet */ }
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
function timelineFiles(id) {
|
|
25
|
+
const files = [];
|
|
26
|
+
const legacy = timelinePath(id);
|
|
27
|
+
if (existsSync(legacy))
|
|
28
|
+
files.push(legacy);
|
|
29
|
+
files.push(...segmentFiles(id));
|
|
30
|
+
return files;
|
|
31
|
+
}
|
|
32
|
+
const segmentLimit = () => {
|
|
33
|
+
const configured = Number(process.env.SPEXCODE_TIMELINE_SEGMENT_BYTES);
|
|
34
|
+
return Number.isFinite(configured) ? Math.max(1024, Math.floor(configured)) : 4 * 1024 * 1024;
|
|
35
|
+
};
|
|
36
|
+
function activeSegment(id, bytes) {
|
|
37
|
+
const dir = segmentsDir(id);
|
|
38
|
+
mkdirSync(dir, { recursive: true });
|
|
39
|
+
const segments = segmentFiles(id);
|
|
40
|
+
const current = segments.at(-1);
|
|
41
|
+
if (!current)
|
|
42
|
+
return join(dir, `${String(1).padStart(SEGMENT_NAME_WIDTH, '0')}.ndjson`);
|
|
43
|
+
try {
|
|
44
|
+
if (statSync(current).size === 0 || statSync(current).size + bytes <= segmentLimit())
|
|
45
|
+
return current;
|
|
46
|
+
}
|
|
47
|
+
catch { /* a vanished active segment is recreated under its next number */ }
|
|
48
|
+
const n = BigInt(SEGMENT.exec(basename(current))[1]) + 1n;
|
|
49
|
+
return join(dir, `${String(n).padStart(SEGMENT_NAME_WIDTH, '0')}.ndjson`);
|
|
50
|
+
}
|
|
51
|
+
function append(id, ev) {
|
|
52
|
+
mkdirSync(sessionStoreDir(id), { recursive: true });
|
|
53
|
+
const line = JSON.stringify(ev) + '\n';
|
|
54
|
+
appendFileSync(activeSegment(id, Buffer.byteLength(line)), line);
|
|
55
|
+
}
|
|
56
|
+
function parseLines(lines) {
|
|
57
|
+
return lines.map((l) => {
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(l);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}).filter((e) => e != null && (e.kind === 'status' || e.kind === 'sent' || e.kind === 'dispatch-settled'));
|
|
65
|
+
}
|
|
66
|
+
function tailPublicEvents(path, limit) {
|
|
67
|
+
let fd = null;
|
|
68
|
+
try {
|
|
69
|
+
const size = statSync(path).size;
|
|
70
|
+
fd = openSync(path, 'r');
|
|
71
|
+
let start = size;
|
|
72
|
+
let text = '';
|
|
73
|
+
while (start > 0) {
|
|
74
|
+
const next = Math.max(0, start - TAIL_BLOCK_BYTES);
|
|
75
|
+
const buf = Buffer.alloc(start - next);
|
|
76
|
+
readSync(fd, buf, 0, buf.length, next);
|
|
77
|
+
text = buf.toString('utf8') + text;
|
|
78
|
+
const publicCount = parseLines(text.split('\n').filter(Boolean)).filter((event) => event.kind !== 'dispatch-settled').length;
|
|
79
|
+
if (publicCount >= limit || next === 0)
|
|
80
|
+
break;
|
|
81
|
+
start = next;
|
|
82
|
+
}
|
|
83
|
+
return parseLines(text.split('\n').filter(Boolean))
|
|
84
|
+
.filter((event) => event.kind !== 'dispatch-settled')
|
|
85
|
+
.slice(-limit);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
if (fd !== null)
|
|
92
|
+
closeSync(fd);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Record a lifecycle value that has already landed in session.json. TypeScript state writers call this
|
|
96
|
+
// synchronously before returning, so a later write cannot erase an intermediate declaration note from the
|
|
97
|
+
// conversation. Best-effort: history is an accessory to the state machine, and failing to write it must never
|
|
98
|
+
// break the transition that already happened.
|
|
99
|
+
export function recordStatus(id, status, proposal, note) {
|
|
100
|
+
try {
|
|
101
|
+
append(id, { ts: new Date().toISOString(), kind: 'status', status, proposal, note });
|
|
102
|
+
}
|
|
103
|
+
catch { /* the record already moved; the history line is the only loss */ }
|
|
104
|
+
}
|
|
105
|
+
// The DELIVERY ([[dispatch]]): appending this line IS the send, so unlike a status line it must fail LOUD —
|
|
106
|
+
// the caller reports the throw rather than a false success. `text` is the message BEFORE any mechanism insert
|
|
107
|
+
// (hints are transport, not conversation); `replyVia` is the effective channel the prompt seam chose. Returns
|
|
108
|
+
// the new line's `mid`, which a best-effort poke carries.
|
|
109
|
+
export function appendSent(id, text, from, replyVia, dispatchReceipt) {
|
|
110
|
+
const mid = randomUUID();
|
|
111
|
+
append(id, { ts: new Date().toISOString(), kind: 'sent', mid, text, from, ...(replyVia ? { replyVia } : {}), ...(dispatchReceipt ? { dispatchReceipt } : {}) });
|
|
112
|
+
return { mid };
|
|
113
|
+
}
|
|
114
|
+
export function sentDispatchReceipt(id, operation, requestDigest) {
|
|
115
|
+
let found = null;
|
|
116
|
+
const settled = new Set();
|
|
117
|
+
for (const path of timelineFiles(id)) {
|
|
118
|
+
for (const event of parseLines(readFileSync(path, 'utf8').split('\n').filter(Boolean))) {
|
|
119
|
+
if (event.kind === 'sent' && !found && event.dispatchReceipt?.operation === operation && event.dispatchReceipt.requestDigest === requestDigest) {
|
|
120
|
+
found = { mid: event.mid, payloadHash: event.dispatchReceipt.payloadHash, delivery: event.dispatchReceipt.delivery ?? null };
|
|
121
|
+
}
|
|
122
|
+
else if (event.kind === 'dispatch-settled' && event.operation === operation && event.requestDigest === requestDigest) {
|
|
123
|
+
settled.add(event.mid);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return found ? { ...found, delivered: settled.has(found.mid) } : null;
|
|
128
|
+
}
|
|
129
|
+
export function settleSentDispatch(id, mid) {
|
|
130
|
+
let receipt = null;
|
|
131
|
+
let settled = false;
|
|
132
|
+
for (const path of timelineFiles(id)) {
|
|
133
|
+
for (const event of parseLines(readFileSync(path, 'utf8').split('\n').filter(Boolean))) {
|
|
134
|
+
if (event.kind === 'sent' && event.mid === mid && event.dispatchReceipt?.delivery)
|
|
135
|
+
receipt = event.dispatchReceipt;
|
|
136
|
+
if (event.kind === 'dispatch-settled' && event.mid === mid)
|
|
137
|
+
settled = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!receipt || settled)
|
|
141
|
+
return;
|
|
142
|
+
append(id, { ts: new Date().toISOString(), kind: 'dispatch-settled', operation: receipt.operation, requestDigest: receipt.requestDigest, mid });
|
|
143
|
+
}
|
|
144
|
+
// The unowned read: any process may take it with nothing but filesystem access, and taking it perturbs
|
|
145
|
+
// nothing. Index = event position, which is what a cursor names ([[session-cursors]]).
|
|
146
|
+
export function timelineEvents(id) {
|
|
147
|
+
try {
|
|
148
|
+
return timelineFiles(id).flatMap((path) => parseLines(readFileSync(path, 'utf8').split('\n').filter(Boolean)))
|
|
149
|
+
.flatMap((stored) => {
|
|
150
|
+
if (stored.kind === 'dispatch-settled')
|
|
151
|
+
return [];
|
|
152
|
+
if (stored.kind === 'status')
|
|
153
|
+
return [stored];
|
|
154
|
+
const { dispatchReceipt: _receipt, ...event } = stored;
|
|
155
|
+
return [event];
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// the same L0 read taken as CHEAPLY as it can be: a follower ([[session-follow]]) ticks over many logs, so it
|
|
163
|
+
// stats first and parses only what grew. null = no log yet (a session that has authored nothing).
|
|
164
|
+
export function timelineStamp(id) {
|
|
165
|
+
try {
|
|
166
|
+
const path = timelineFiles(id).at(-1);
|
|
167
|
+
if (!path)
|
|
168
|
+
return null;
|
|
169
|
+
const s = statSync(path);
|
|
170
|
+
return `${path}:${s.size}:${s.mtimeMs}`;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// the channel of the LAST HUMAN send (from == null): 'note' when the note-reply hint rode along, else null.
|
|
177
|
+
// This is what makes the reply-channel hints SYMMETRIC ([[session-timeline]]): a human send with no note flag
|
|
178
|
+
// arriving after a note-send is the "back at a terminal" transition, and the delivery gets the counter-insert.
|
|
179
|
+
// Derived from the durable log — no new state, and it survives a server restart. Agent senders (`from` set)
|
|
180
|
+
// say nothing about where the HUMAN is reading, so they neither set nor clear it.
|
|
181
|
+
export function lastHumanSendVia(id) {
|
|
182
|
+
const evs = timelineEvents(id);
|
|
183
|
+
for (let i = evs.length - 1; i >= 0; i--) {
|
|
184
|
+
const e = evs[i];
|
|
185
|
+
if (e.kind === 'sent' && e.from == null)
|
|
186
|
+
return e.replyVia === 'note' ? 'note' : null;
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
export function currentHumanTurn(id) {
|
|
191
|
+
const evs = timelineEvents(id);
|
|
192
|
+
for (let i = evs.length - 1; i >= 0; i--) {
|
|
193
|
+
const e = evs[i];
|
|
194
|
+
if (e.kind === 'sent' && e.from == null)
|
|
195
|
+
return { token: e.mid, acceptedAt: e.ts };
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
// Durable tail read with no board/governance policy. A runtime that owns a compatible session directory can
|
|
200
|
+
// consume the same protocol; product surfaces decide separately whether that id is visible to them.
|
|
201
|
+
export function timelineTail(id, limit = 500) {
|
|
202
|
+
const wanted = Math.max(1, limit);
|
|
203
|
+
const tail = [];
|
|
204
|
+
for (const path of timelineFiles(id).reverse()) {
|
|
205
|
+
const remaining = wanted - tail.length;
|
|
206
|
+
if (remaining <= 0)
|
|
207
|
+
break;
|
|
208
|
+
tail.unshift(...tailPublicEvents(path, remaining));
|
|
209
|
+
}
|
|
210
|
+
return tail.map((e) => {
|
|
211
|
+
if (e.kind === 'status')
|
|
212
|
+
return e;
|
|
213
|
+
const { dispatchReceipt: _receipt, ...event } = e;
|
|
214
|
+
return event;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@spexcode/session-core",
|
|
3
|
+
"version": "0.6.6",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "SpexCode's durable file-based session communication protocol.",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist"
|
|
8
|
+
],
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./dist/index.js",
|
|
11
|
+
"./internal": "./dist/internal.js",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "node ../../scripts/build-dist.mjs",
|
|
22
|
+
"prepublishOnly": "node ../../scripts/release-publish.mjs --from-package-publish",
|
|
23
|
+
"test": "npm run build && tsx --import ../../scripts/test-home.mjs --test src/*.test.ts && node --test scripts/public-boundary.test.mjs"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@spexcode/spec-core": "0.6.6"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^20.16.0",
|
|
30
|
+
"tsx": "^4.19.2",
|
|
31
|
+
"typescript": "^5.6.3"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -16,7 +16,7 @@ const workspace = join(pkg, '..')
|
|
|
16
16
|
// carry unresolved conflict markers, so hooks keep the retryable exit-75 contract during a merge.
|
|
17
17
|
const sourceRoot = join(pkg, 'src')
|
|
18
18
|
if (existsSync(sourceRoot)) {
|
|
19
|
-
const srcRoots = [sourceRoot, join(pkg, '..', 'packages', 'spec-core', 'src'), join(pkg, '..', 'spec-eval', 'src'), join(pkg, '..', 'spec-forge', 'src')]
|
|
19
|
+
const srcRoots = [sourceRoot, join(pkg, '..', 'packages', 'spec-core', 'src'), join(pkg, '..', 'packages', 'session-core', 'src'), join(pkg, '..', 'spec-eval', 'src'), join(pkg, '..', 'spec-forge', 'src')]
|
|
20
20
|
const conflicted = srcRoots.flatMap((root) => {
|
|
21
21
|
if (!existsSync(root)) return []
|
|
22
22
|
return readdirSync(root, { recursive: true })
|
|
@@ -39,6 +39,7 @@ if (existsSync(sourceRoot)) {
|
|
|
39
39
|
const runtimeEntries = [
|
|
40
40
|
cli,
|
|
41
41
|
join(workspace, 'packages', 'spec-core', 'dist', 'index.js'),
|
|
42
|
+
join(workspace, 'packages', 'session-core', 'dist', 'index.js'),
|
|
42
43
|
join(workspace, 'spec-eval', 'dist', 'index.js'),
|
|
43
44
|
join(workspace, 'spec-forge', 'dist', 'index.js'),
|
|
44
45
|
]
|
|
@@ -5,7 +5,10 @@ type ClaudeHeadlessDeliveryRecord = HarnessDeliveryRecord & {
|
|
|
5
5
|
export declare const claudeHeadlessSock: (id: string) => string;
|
|
6
6
|
export declare function claudeHeadlessLaunchCommand(id: string, runtimeDir: string, claudeCmd: string): string;
|
|
7
7
|
export declare const deliverViaClaudeHeadless: (rec: ClaudeHeadlessDeliveryRecord, text: string) => Promise<DispatchResult>;
|
|
8
|
-
export declare const interruptClaudeHeadless: (rec: HarnessDeliveryRecord) => Promise<
|
|
8
|
+
export declare const interruptClaudeHeadless: (rec: HarnessDeliveryRecord) => Promise<{
|
|
9
|
+
ok: boolean;
|
|
10
|
+
}>;
|
|
11
|
+
export declare function claudeHeadlessColdRuntime(rec: Pick<HarnessDeliveryRecord, 'session'>): Promise<DispatchResult>;
|
|
9
12
|
export declare class ClaudeHeadlessController {
|
|
10
13
|
private readonly id;
|
|
11
14
|
private readonly claudeCmd;
|
|
@@ -28,10 +28,19 @@ export const deliverViaClaudeHeadless = (rec, text) => controlRequest(claudeHead
|
|
|
28
28
|
name: 'claude-headless', session: rec.session, timeoutMs: CONTROL_TIMEOUT_MS,
|
|
29
29
|
rejected: 'claude-headless control rejected the request',
|
|
30
30
|
});
|
|
31
|
-
export const interruptClaudeHeadless = (rec) =>
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
export const interruptClaudeHeadless = (rec) => rec.stopped || rec.archived
|
|
32
|
+
? Promise.resolve({ ok: true })
|
|
33
|
+
: controlRequest(claudeHeadlessSock(rec.session), { type: 'interrupt' }, {
|
|
34
|
+
name: 'claude-headless', session: rec.session, timeoutMs: CONTROL_TIMEOUT_MS,
|
|
35
|
+
rejected: 'claude-headless control rejected the request',
|
|
36
|
+
});
|
|
37
|
+
export async function claudeHeadlessColdRuntime(rec) {
|
|
38
|
+
const { listenerAt } = await import('./harness.js');
|
|
39
|
+
const probe = await listenerAt(claudeHeadlessSock(rec.session));
|
|
40
|
+
return probe === 'dead'
|
|
41
|
+
? { ok: true }
|
|
42
|
+
: { ok: false, error: `claude-headless controller is still ${probe === 'live' ? 'live' : 'unproven'}` };
|
|
43
|
+
}
|
|
35
44
|
export class ClaudeHeadlessController {
|
|
36
45
|
id;
|
|
37
46
|
claudeCmd;
|
|
@@ -6,6 +6,34 @@ import { installEvalHost } from './eval-host.js';
|
|
|
6
6
|
installEvalHost();
|
|
7
7
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
8
|
const cmd = process.argv[2];
|
|
9
|
+
function levenshtein(a, b) {
|
|
10
|
+
const row = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
11
|
+
for (let i = 1; i <= a.length; i++) {
|
|
12
|
+
let previous = row[0];
|
|
13
|
+
row[0] = i;
|
|
14
|
+
for (let j = 1; j <= b.length; j++) {
|
|
15
|
+
const current = row[j];
|
|
16
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
17
|
+
previous = current;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return row[b.length];
|
|
21
|
+
}
|
|
22
|
+
function nearestPublicCommand(input, commands) {
|
|
23
|
+
const query = input.toLowerCase();
|
|
24
|
+
if (!/^[a-z0-9-]+$/.test(query))
|
|
25
|
+
return null;
|
|
26
|
+
const similarity = (a, b) => 1 - levenshtein(a, b) / Math.max(a.length, b.length);
|
|
27
|
+
const words = (text) => text.toLowerCase().match(/[a-z0-9-]+/g) ?? [];
|
|
28
|
+
let best = null;
|
|
29
|
+
for (const command of commands) {
|
|
30
|
+
const [headline = '', ...detail] = command.text.split('\n');
|
|
31
|
+
const score = Math.max(similarity(query, command.name), ...words(headline).map((word) => similarity(query, word)), ...words(detail.join('\n')).map((word) => similarity(query, word) * 0.8));
|
|
32
|
+
if (!best || score > best.score)
|
|
33
|
+
best = { name: command.name, score };
|
|
34
|
+
}
|
|
35
|
+
return best && best.score >= 0.8 ? best.name : null;
|
|
36
|
+
}
|
|
9
37
|
if (cmd === '--version' || cmd === '-v') {
|
|
10
38
|
const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
11
39
|
console.log(manifest.version);
|
|
@@ -31,14 +59,15 @@ async function assertDaemonDependencies(command) {
|
|
|
31
59
|
process.exit(1);
|
|
32
60
|
}
|
|
33
61
|
// Registered before any await so a fatal top-level error lands here. Errors we OWN — BackendError, the
|
|
34
|
-
// loud malformed-config ConfigError, the --api/--port UsageError, the write-guard GuardError
|
|
62
|
+
// loud malformed-config ConfigError, the --api/--port UsageError, the write-guard GuardError, and a
|
|
63
|
+
// GitWorkspaceError that teaches a fresh directory how to continue — are
|
|
35
64
|
// matched BY NAME (to avoid importing them) and rendered as a one-line `spex: <message>` (a user's
|
|
36
65
|
// config typo or a refused cross-project write must read as their situation, not a SpexCode stack dump);
|
|
37
66
|
// anything else prints in full so a real bug keeps its trace. A synchronous throw inside an awaited call
|
|
38
67
|
// (loadConfig on a malformed spexcode.json) surfaces as uncaughtException, not unhandledRejection, so BOTH
|
|
39
68
|
// paths route through the same printer.
|
|
40
69
|
function fatal(e) {
|
|
41
|
-
if (e instanceof Error && ['BackendError', 'ConfigError', 'UsageError', 'GuardError', 'DashboardAssetError'].includes(e.name))
|
|
70
|
+
if (e instanceof Error && ['BackendError', 'ConfigError', 'UsageError', 'GuardError', 'DashboardAssetError', 'GitWorkspaceError'].includes(e.name))
|
|
42
71
|
console.error(`spex: ${e.message}`);
|
|
43
72
|
else
|
|
44
73
|
console.error(e);
|
|
@@ -68,6 +97,13 @@ function flushExit(code = 0) {
|
|
|
68
97
|
const has = (name) => process.argv.includes(`--${name}`);
|
|
69
98
|
// bare positionals after argv index `from`, skipping flags and their values (selectors for ls/watch).
|
|
70
99
|
const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--prompt-file', '--timeout', '--reason', '--out', '--content-dir', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--limit', '--session', '--depth', '--focus', '--keys', '--ssh', '--allow-stop', '--allow-resume', '--ttl-ms', '--wait-ms', '--adapter', '--thread', '--tmux', '--worktree', '--branch', '--to', '--name', '--base', '--path', '--owner', '--details', '--variant', '--cli', '--count', '--ids']);
|
|
100
|
+
const EXPLICIT_BACKEND_ROUTE_FLAGS = ['api', 'port', 'password', 'insecure'];
|
|
101
|
+
const EXPLICIT_BACKEND_VALUE_FLAGS = EXPLICIT_BACKEND_ROUTE_FLAGS
|
|
102
|
+
.filter((name) => VALUE_FLAGS.has(`--${name}`))
|
|
103
|
+
.map((name) => `--${name}`);
|
|
104
|
+
const EXPLICIT_BACKEND_BARE_FLAGS = EXPLICIT_BACKEND_ROUTE_FLAGS
|
|
105
|
+
.filter((name) => !VALUE_FLAGS.has(`--${name}`))
|
|
106
|
+
.map((name) => `--${name}`);
|
|
71
107
|
function positionals(from) {
|
|
72
108
|
const out = [];
|
|
73
109
|
for (let i = from; i < process.argv.length; i++) {
|
|
@@ -81,7 +117,7 @@ function positionals(from) {
|
|
|
81
117
|
}
|
|
82
118
|
return out;
|
|
83
119
|
}
|
|
84
|
-
function
|
|
120
|
+
function rejectFlags(command, from, allowed, attached = []) {
|
|
85
121
|
const known = new Set(allowed.map((name) => `--${name}`));
|
|
86
122
|
for (let i = from; i < process.argv.length; i++) {
|
|
87
123
|
const token = process.argv[i];
|
|
@@ -97,6 +133,12 @@ function rejectUnknownFlags(command, from, allowed, attached = []) {
|
|
|
97
133
|
i++;
|
|
98
134
|
}
|
|
99
135
|
}
|
|
136
|
+
function rejectUnknownFlags(command, from, allowed, attached = []) {
|
|
137
|
+
rejectFlags(command, from, allowed, attached);
|
|
138
|
+
}
|
|
139
|
+
function rejectUnknownBackendFlags(command, from, allowed, attached = []) {
|
|
140
|
+
rejectFlags(command, from, [...allowed, ...EXPLICIT_BACKEND_ROUTE_FLAGS], attached);
|
|
141
|
+
}
|
|
100
142
|
// `--children` deliberately has an optional value only in its attached form. A separated following token
|
|
101
143
|
// remains a normal ls selector, so the long-standing `ls --children <child-SEL>` grammar keeps its meaning.
|
|
102
144
|
function childrenScopeOption() {
|
|
@@ -125,8 +167,8 @@ function sessionSendUsage(detail, keys = false) {
|
|
|
125
167
|
process.exit(2);
|
|
126
168
|
}
|
|
127
169
|
function parseSessionSendArgs(args) {
|
|
128
|
-
const valueFlags = new Set([
|
|
129
|
-
const bareFlags = new Set(
|
|
170
|
+
const valueFlags = new Set([...EXPLICIT_BACKEND_VALUE_FLAGS, '--keys', '--ssh']);
|
|
171
|
+
const bareFlags = new Set(EXPLICIT_BACKEND_BARE_FLAGS);
|
|
130
172
|
const values = new Map();
|
|
131
173
|
const positionals = [];
|
|
132
174
|
let endOfOptions = false;
|
|
@@ -210,8 +252,8 @@ function sessionTargetUsage(verb, detail) {
|
|
|
210
252
|
}
|
|
211
253
|
function parseSessionTargetArgs(verb, args) {
|
|
212
254
|
const values = new Map();
|
|
213
|
-
const valueFlags = new Set([
|
|
214
|
-
const bareFlags = new Set(verb === 'show' ? ['--capture', '--json'
|
|
255
|
+
const valueFlags = new Set([...EXPLICIT_BACKEND_VALUE_FLAGS, '--ssh']);
|
|
256
|
+
const bareFlags = new Set([...EXPLICIT_BACKEND_BARE_FLAGS, ...(verb === 'show' ? ['--capture', '--json'] : [])]);
|
|
215
257
|
const positionals = [];
|
|
216
258
|
for (let i = 0; i < args.length; i++) {
|
|
217
259
|
const token = args[i];
|
|
@@ -253,6 +295,7 @@ const SIGNPOSTS = {
|
|
|
253
295
|
blob: 'spex evidence put|get',
|
|
254
296
|
issues: 'spex issue — ls (was: bare issues) · show · open · reply · close · promote; on|off|status → the `issues.enabled` key in spexcode.json; `issues nudge` → spex internal nudge',
|
|
255
297
|
forge: 'spex issue links [--pending] [--store <host>] (--host is now --store)',
|
|
298
|
+
runtime: 'spex doctor repair app-server',
|
|
256
299
|
new: 'spex session new',
|
|
257
300
|
ls: 'spex session ls',
|
|
258
301
|
watch: 'spex session watch',
|
|
@@ -395,7 +438,7 @@ async function stateKit() {
|
|
|
395
438
|
// reach here — the signpost table above already exited.)
|
|
396
439
|
if (cmd && cmd !== 'help' && (has('help') || process.argv.includes('-h'))) {
|
|
397
440
|
const { commandHelp, overviewHelp } = await import('./help.js');
|
|
398
|
-
console.log(commandHelp(cmd, cmd === 'session' ? process.argv[3] : undefined) ?? overviewHelp());
|
|
441
|
+
console.log(commandHelp(cmd, cmd === 'session' || cmd === 'doctor' ? process.argv[3] : undefined) ?? overviewHelp());
|
|
399
442
|
process.exit(0);
|
|
400
443
|
}
|
|
401
444
|
if (cmd === 'serve') {
|
|
@@ -955,7 +998,7 @@ else if (cmd === 'session') {
|
|
|
955
998
|
}
|
|
956
999
|
const newPositionals = positionals(4);
|
|
957
1000
|
const peerAnchor = parseSessionPeerAnchor('new', newPositionals);
|
|
958
|
-
|
|
1001
|
+
rejectUnknownBackendFlags('spex session new', 4, ['prompt', 'prompt-file', 'launcher', 'name', 'base', 'ssh']);
|
|
959
1002
|
if (peerAnchor && newPositionals.length > 2)
|
|
960
1003
|
sessionPeerAnchorUsage('new', '--ssh accepts one full-id anchor and one inline prompt at most');
|
|
961
1004
|
const { createSession, ownSessionId, withPeerSenderHint } = await import('./sessions.js');
|
|
@@ -1023,7 +1066,7 @@ else if (cmd === 'session') {
|
|
|
1023
1066
|
// The backend's default projection excludes cold archives. --all and an explicit selector request the
|
|
1024
1067
|
// history projection so an operator can still inspect or unarchive one deliberately.
|
|
1025
1068
|
const selectors = positionals(4);
|
|
1026
|
-
|
|
1069
|
+
rejectUnknownBackendFlags('spex session ls', 4, ['status', 'all', 'json', 'ssh', 'children'], ['children']);
|
|
1027
1070
|
const peerAnchor = parseSessionPeerAnchor('ls', selectors);
|
|
1028
1071
|
const children = childrenScopeOption();
|
|
1029
1072
|
if (peerAnchor && selectors.length !== 1)
|
|
@@ -1089,7 +1132,7 @@ else if (cmd === 'session') {
|
|
|
1089
1132
|
}
|
|
1090
1133
|
}
|
|
1091
1134
|
else if (sub === 'resources') {
|
|
1092
|
-
|
|
1135
|
+
rejectUnknownBackendFlags('spex session resources', 4, ['json']);
|
|
1093
1136
|
const { clientResources } = await import('./client.js');
|
|
1094
1137
|
const report = await clientResources();
|
|
1095
1138
|
if (has('json'))
|
|
@@ -1368,7 +1411,7 @@ else if (cmd === 'session') {
|
|
|
1368
1411
|
console.log(`closed ${full}`);
|
|
1369
1412
|
}
|
|
1370
1413
|
else if (sub === 'quarantine') {
|
|
1371
|
-
|
|
1414
|
+
rejectUnknownBackendFlags('spex session quarantine', 4, ['adapter', 'thread', 'tmux', 'worktree', 'branch', 'restore']);
|
|
1372
1415
|
if (!id) {
|
|
1373
1416
|
console.error('usage: spex session quarantine <ID> --adapter <harness> [--thread <native-id>] --tmux <session-id> --worktree <absent-path> --branch <absent-branch> (--thread is adapter-native; omit it for Claude)');
|
|
1374
1417
|
process.exit(2);
|
|
@@ -1390,7 +1433,7 @@ else if (cmd === 'session') {
|
|
|
1390
1433
|
}
|
|
1391
1434
|
}
|
|
1392
1435
|
else if (sub === 'reparent') {
|
|
1393
|
-
|
|
1436
|
+
rejectUnknownBackendFlags('spex session reparent', 4, ['to']);
|
|
1394
1437
|
const children = positionals(4);
|
|
1395
1438
|
const to = flag('to');
|
|
1396
1439
|
if (!children.length || !to) {
|
|
@@ -1425,8 +1468,8 @@ else if (cmd === 'session') {
|
|
|
1425
1468
|
console.error(`spex session send --keys: nothing delivered to ${full} (offline, unknown session, or no valid key token)`);
|
|
1426
1469
|
process.exit(1);
|
|
1427
1470
|
}
|
|
1428
|
-
//
|
|
1429
|
-
//
|
|
1471
|
+
// A send is accepted at its timeline append, except a proven-unreachable transport attached to a live
|
|
1472
|
+
// registered agent: that stranded combination refuses before it can add unclaimable queue debt.
|
|
1430
1473
|
// BIDIRECTIONAL: stamp the SENDER (this send process's OWN session — the only process that knows it, via
|
|
1431
1474
|
// ownSessionId from CLAUDE_CODE_SESSION_ID) + a one-line reply hint into the delivered
|
|
1432
1475
|
// message, so the recipient can reply over the SAME send. The sender's row (hence its display label) is
|
|
@@ -1455,8 +1498,12 @@ else if (cmd === 'session') {
|
|
|
1455
1498
|
const r = sendArgs.sshAddress
|
|
1456
1499
|
? await c.clientSendThroughPeer(sendArgs.sshAddress, full, text, from)
|
|
1457
1500
|
: await c.clientSend(full, text, from);
|
|
1458
|
-
|
|
1459
|
-
|
|
1501
|
+
if (r.ok) {
|
|
1502
|
+
console.log('sent');
|
|
1503
|
+
process.exit(0);
|
|
1504
|
+
}
|
|
1505
|
+
console.error(`dispatch failed: ${r.error}`);
|
|
1506
|
+
process.exit(1);
|
|
1460
1507
|
}
|
|
1461
1508
|
else if (sub === 'show') {
|
|
1462
1509
|
// the session RECORD as one per-id read (status · node · branch · launcher · the full originating
|
|
@@ -1637,11 +1684,11 @@ else if (cmd === 'internal') {
|
|
|
1637
1684
|
}
|
|
1638
1685
|
else if (sub === 'codex-launch') {
|
|
1639
1686
|
// BACKEND-owned codex thread. On the shared per-project app-server: thread/start { cwd = this worktree }
|
|
1640
|
-
// (codex loads that worktree's config/hooks/AGENTS.md),
|
|
1641
|
-
//
|
|
1642
|
-
// thread id. The launch script then `resume`s it in the visible TUI.
|
|
1687
|
+
// (codex loads that worktree's config/hooks/AGENTS.md), fire the launch prompt as the FIRST turn —
|
|
1688
|
+
// materializing the rollout — then stage the id + exact-payload proof for the session lifecycle owner and
|
|
1689
|
+
// print the thread id. The launch script then `resume`s it in the visible TUI.
|
|
1643
1690
|
const { codexStartThread, codexTurn, waitForCodexRollout, codexBinary, codexSupportsBypassHookTrust, codexLauncherThreadPolicy } = await import('./harness.js');
|
|
1644
|
-
const {
|
|
1691
|
+
const { stageHarnessLaunchProof } = await import('./sessions.js');
|
|
1645
1692
|
const sock = process.argv[4], cwd = process.argv[5];
|
|
1646
1693
|
const prompt = process.argv.slice(6).join(' ');
|
|
1647
1694
|
if (!sock || !cwd) {
|
|
@@ -1680,7 +1727,7 @@ else if (cmd === 'internal') {
|
|
|
1680
1727
|
}
|
|
1681
1728
|
const sid = process.env.SPEXCODE_SESSION_ID;
|
|
1682
1729
|
if (sid)
|
|
1683
|
-
|
|
1730
|
+
stageHarnessLaunchProof(sid, r.threadId, prompt);
|
|
1684
1731
|
console.log(r.threadId);
|
|
1685
1732
|
}
|
|
1686
1733
|
else if (sub === 'opencode-capture') {
|
|
@@ -1854,6 +1901,8 @@ else if (cmd === 'internal') {
|
|
|
1854
1901
|
}
|
|
1855
1902
|
}
|
|
1856
1903
|
else {
|
|
1857
|
-
|
|
1904
|
+
const { publicCommands } = await import('./help.js');
|
|
1905
|
+
const suggestion = nearestPublicCommand(cmd, publicCommands());
|
|
1906
|
+
console.error(`spex: unknown command '${cmd}'${suggestion ? ` — try: spex ${suggestion}` : ''} (try: spex help)`);
|
|
1858
1907
|
process.exit(2);
|
|
1859
1908
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type CockpitReview } from './cockpit.js';
|
|
2
|
+
import type { SessionEvalRevision } from '@spexcode/spec-eval/sessioneval';
|
|
2
3
|
import { type Session, type SessionClosure, type Resolved, type DispatchResult } from './sessions.js';
|
|
3
4
|
export declare class BackendError extends Error {
|
|
4
5
|
readonly status?: number | undefined;
|
|
@@ -48,7 +49,7 @@ type SessionEvalPage = {
|
|
|
48
49
|
unknown: number;
|
|
49
50
|
revision: string;
|
|
50
51
|
summary?: any;
|
|
51
|
-
evalRevision?:
|
|
52
|
+
evalRevision?: SessionEvalRevision;
|
|
52
53
|
};
|
|
53
54
|
export type EvalsResult = {
|
|
54
55
|
ok: true;
|
|
@@ -259,7 +259,7 @@ export async function clientCapture(id) {
|
|
|
259
259
|
return { ok: false, status: r.status, reason: (await r.text().catch(() => '')) || `status ${r.status}` };
|
|
260
260
|
}
|
|
261
261
|
// POST /api/sessions/:id/input {kind:"text"} appends the prompt to the durable timeline, then best-effort
|
|
262
|
-
// pokes the resolved adapter. HTTP failure means the append
|
|
262
|
+
// pokes the resolved adapter. HTTP failure means the append was refused, including a proven stranded transport.
|
|
263
263
|
export async function clientSend(id, text, from) {
|
|
264
264
|
await guarded('session send');
|
|
265
265
|
// `from` = the sending agent's own session id; the recipient's log records the sender ([[session-timeline]]) only when
|
|
@@ -331,30 +331,35 @@ export async function clientEvalExport(id) {
|
|
|
331
331
|
return { ok: true, body: await r.text() };
|
|
332
332
|
return { ok: false, status: r.status };
|
|
333
333
|
}
|
|
334
|
+
const evalRevisionKey = (revision) => JSON.stringify([revision.epoch, revision.generation, revision.content]);
|
|
335
|
+
const formatEvalRevision = (revision) => revision ? `${revision.epoch}@${revision.generation} (${revision.content})` : 'missing';
|
|
334
336
|
export async function clientEvals(id) {
|
|
335
337
|
const q = encodeURIComponent(`is:eval scope:${id}`);
|
|
338
|
+
const drifts = [];
|
|
336
339
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
337
340
|
const items = [];
|
|
338
341
|
let first = null;
|
|
339
|
-
let
|
|
342
|
+
let snapshot = null;
|
|
340
343
|
for (let page = 1;; page++) {
|
|
341
344
|
const r = await apiFetch(`/api/evals?q=${q}&page=${page}`);
|
|
342
345
|
if (!r.ok)
|
|
343
346
|
return { ok: false, status: r.status };
|
|
344
347
|
const current = await r.json();
|
|
345
348
|
first ??= current;
|
|
346
|
-
if (current.
|
|
347
|
-
|
|
349
|
+
if (current.pageCount > 1 && !current.evalRevision) {
|
|
350
|
+
throw new BackendError(`session eval page ${page}/${current.pageCount} for ${id} has no evalRevision; cannot assemble a consistent snapshot`);
|
|
351
|
+
}
|
|
352
|
+
snapshot ??= current.evalRevision ?? null;
|
|
353
|
+
if (current.evalRevision && snapshot && evalRevisionKey(current.evalRevision) !== evalRevisionKey(snapshot)) {
|
|
354
|
+
drifts.push(`attempt ${attempt + 1}: ${formatEvalRevision(snapshot)} -> ${formatEvalRevision(current.evalRevision)} at page ${page}`);
|
|
348
355
|
break;
|
|
349
356
|
}
|
|
350
357
|
items.push(...current.items);
|
|
351
358
|
if (page >= current.pageCount)
|
|
352
|
-
|
|
359
|
+
return { ok: true, model: { ...first, id, items } };
|
|
353
360
|
}
|
|
354
|
-
if (!changed)
|
|
355
|
-
return { ok: true, model: { ...first, id, items } };
|
|
356
361
|
}
|
|
357
|
-
throw new BackendError(`session eval
|
|
362
|
+
throw new BackendError(`session eval snapshot changed during both fetch attempts for ${id} (${drifts.join('; ')}); retry the command`);
|
|
358
363
|
}
|
|
359
364
|
// POST /api/sessions/:id/merge — a human merge intent dispatched to the session's own agent.
|
|
360
365
|
export async function clientMerge(id) {
|