spexcode 0.6.5 → 0.6.7

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.
Files changed (56) hide show
  1. package/node_modules/@spexcode/{spec-cli → session-core}/dist/delivery-queue.d.ts +2 -1
  2. package/node_modules/@spexcode/{spec-cli → session-core}/dist/delivery-queue.js +29 -2
  3. package/node_modules/@spexcode/session-core/dist/index.d.ts +5 -0
  4. package/node_modules/@spexcode/session-core/dist/index.js +5 -0
  5. package/node_modules/@spexcode/session-core/dist/internal.d.ts +3 -0
  6. package/node_modules/@spexcode/session-core/dist/internal.js +3 -0
  7. package/node_modules/@spexcode/session-core/dist/message.d.ts +22 -0
  8. package/node_modules/@spexcode/session-core/dist/message.js +53 -0
  9. package/node_modules/@spexcode/session-core/dist/record-lock.d.ts +7 -0
  10. package/node_modules/@spexcode/session-core/dist/record-lock.js +152 -0
  11. package/node_modules/@spexcode/session-core/dist/runtime-session.d.ts +62 -0
  12. package/node_modules/@spexcode/session-core/dist/runtime-session.js +326 -0
  13. package/node_modules/@spexcode/session-core/dist/session-timeline.d.ts +47 -0
  14. package/node_modules/@spexcode/session-core/dist/session-timeline.js +216 -0
  15. package/node_modules/@spexcode/session-core/package.json +33 -0
  16. package/node_modules/@spexcode/spec-cli/bin/spex.mjs +2 -1
  17. package/node_modules/@spexcode/spec-cli/dist/claude-headless.d.ts +4 -1
  18. package/node_modules/@spexcode/spec-cli/dist/claude-headless.js +13 -4
  19. package/node_modules/@spexcode/spec-cli/dist/cli.js +72 -23
  20. package/node_modules/@spexcode/spec-cli/dist/client.d.ts +2 -1
  21. package/node_modules/@spexcode/spec-cli/dist/client.js +13 -8
  22. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +5 -0
  23. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +112 -0
  24. package/node_modules/@spexcode/spec-cli/dist/doctor.js +7 -1
  25. package/node_modules/@spexcode/spec-cli/dist/gateway-hub.js +7 -5
  26. package/node_modules/@spexcode/spec-cli/dist/gateway.d.ts +1 -0
  27. package/node_modules/@spexcode/spec-cli/dist/gateway.js +44 -20
  28. package/node_modules/@spexcode/spec-cli/dist/graphCache.js +2 -1
  29. package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +2 -1
  30. package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +15 -2
  31. package/node_modules/@spexcode/spec-cli/dist/harness.js +174 -54
  32. package/node_modules/@spexcode/spec-cli/dist/help.d.ts +5 -0
  33. package/node_modules/@spexcode/spec-cli/dist/help.js +26 -6
  34. package/node_modules/@spexcode/spec-cli/dist/index.js +3 -3
  35. package/node_modules/@spexcode/spec-cli/dist/listen.d.ts +2 -1
  36. package/node_modules/@spexcode/spec-cli/dist/listen.js +10 -10
  37. package/node_modules/@spexcode/spec-cli/dist/opencode-headless.d.ts +1 -0
  38. package/node_modules/@spexcode/spec-cli/dist/opencode-headless.js +7 -0
  39. package/node_modules/@spexcode/spec-cli/dist/runtime-rotate.d.ts +1 -0
  40. package/node_modules/@spexcode/spec-cli/dist/runtime-rotate.js +58 -0
  41. package/node_modules/@spexcode/spec-cli/dist/session-follow.js +1 -1
  42. package/node_modules/@spexcode/spec-cli/dist/session-timeline.d.ts +6 -46
  43. package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +8 -221
  44. package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +25 -4
  45. package/node_modules/@spexcode/spec-cli/dist/sessions.js +822 -394
  46. package/node_modules/@spexcode/spec-cli/dist/supervise.js +3 -3
  47. package/node_modules/@spexcode/spec-cli/package.json +6 -4
  48. package/node_modules/@spexcode/spec-core/dist/git.d.ts +4 -0
  49. package/node_modules/@spexcode/spec-core/dist/git.js +32 -0
  50. package/node_modules/@spexcode/spec-core/dist/layout.d.ts +4 -0
  51. package/node_modules/@spexcode/spec-core/package.json +1 -1
  52. package/node_modules/@spexcode/spec-eval/package.json +2 -2
  53. package/node_modules/@spexcode/spec-forge/package.json +2 -2
  54. package/package.json +3 -3
  55. /package/node_modules/@spexcode/{spec-cli → session-core}/dist/session-cursors.d.ts +0 -0
  56. /package/node_modules/@spexcode/{spec-cli → session-core}/dist/session-cursors.js +0 -0
@@ -0,0 +1,326 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { isSessionLifecycle, isSessionProposal, listSessionIds, readRecordEntry, sessionArtifactPath, sessionRecordPath, sessionStoreDir, } from '@spexcode/spec-core';
5
+ import { enqueue, ensurePendingWhileLocked, withDeliveryLocks } from './delivery-queue.js';
6
+ import { withSessionRecordLocks } from './record-lock.js';
7
+ import { appendSent, recordStatus, sentDispatchReceipt } from './session-timeline.js';
8
+ export class RuntimeSessionConflict extends Error {
9
+ code = 'runtime_session_conflict';
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = 'RuntimeSessionConflict';
13
+ }
14
+ }
15
+ const digest = (value) => createHash('sha256').update(value).digest('hex');
16
+ const watchPath = (target) => sessionArtifactPath(target, 'watchers.json');
17
+ const RUNTIME_NOTIFICATION_KIND = 'spex.runtime-state.v1';
18
+ function scalar(value, field) {
19
+ const normalized = value.trim();
20
+ if (!normalized)
21
+ throw new RuntimeSessionConflict(`${field} must be a non-empty string`);
22
+ return normalized;
23
+ }
24
+ function metadata(value) {
25
+ if (!value)
26
+ return {};
27
+ const entries = Object.entries(value).map(([rawKey, rawValue]) => {
28
+ const key = scalar(rawKey, 'runtimeMetadata key');
29
+ if (typeof rawValue !== 'string')
30
+ throw new RuntimeSessionConflict(`runtimeMetadata.${key} must be a string`);
31
+ return [key, rawValue];
32
+ }).sort(([left], [right]) => left.localeCompare(right));
33
+ if (new Set(entries.map(([key]) => key)).size !== entries.length)
34
+ throw new RuntimeSessionConflict('runtimeMetadata keys must be unique after normalization');
35
+ return Object.fromEntries(entries);
36
+ }
37
+ function metadataKey(value) {
38
+ return JSON.stringify(metadata(value));
39
+ }
40
+ function readRaw(id) {
41
+ const entry = readRecordEntry(id);
42
+ if (entry.kind === 'absent')
43
+ return null;
44
+ if (entry.kind === 'corrupt')
45
+ throw new RuntimeSessionConflict(`session ${id} record is corrupt: ${entry.error}`);
46
+ return entry.raw;
47
+ }
48
+ function runtimeRecord(raw) {
49
+ const runtimeOwner = raw.runtime_owner?.trim();
50
+ if (!runtimeOwner)
51
+ throw new RuntimeSessionConflict(`session ${raw.session_id} is not owned by an external runtime`);
52
+ if (!isSessionLifecycle(raw.status))
53
+ throw new RuntimeSessionConflict(`session ${raw.session_id} has invalid lifecycle ${JSON.stringify(raw.status)}`);
54
+ if (!(raw.proposal == null || raw.proposal === '' || isSessionProposal(raw.proposal)))
55
+ throw new RuntimeSessionConflict(`session ${raw.session_id} has invalid proposal ${JSON.stringify(raw.proposal)}`);
56
+ return {
57
+ sessionId: raw.session_id,
58
+ runtimeOwner,
59
+ runtimeState: raw.runtime_state?.trim() || null,
60
+ revision: raw.runtime_revision?.trim() || null,
61
+ worktreePath: raw.worktree_path,
62
+ branch: raw.branch || null,
63
+ parentSessionId: raw.parent || null,
64
+ title: raw.title || null,
65
+ node: raw.node || null,
66
+ runtimeMetadata: metadata(raw.runtime_metadata),
67
+ lifecycle: raw.status,
68
+ proposal: isSessionProposal(raw.proposal) ? raw.proposal : null,
69
+ note: raw.note || null,
70
+ createdAt: Number(raw.createdAt) || 0,
71
+ };
72
+ }
73
+ function writeRaw(raw) {
74
+ const dir = sessionStoreDir(raw.session_id);
75
+ mkdirSync(dir, { recursive: true });
76
+ const path = sessionRecordPath(raw.session_id);
77
+ const tmp = join(dir, `.session.json.${process.pid}.${randomUUID()}.tmp`);
78
+ writeFileSync(tmp, JSON.stringify(raw, null, 2) + '\n');
79
+ renameSync(tmp, path);
80
+ }
81
+ function readWatches(target) {
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(readFileSync(watchPath(target), 'utf8'));
85
+ }
86
+ catch (error) {
87
+ if (error.code === 'ENOENT')
88
+ return [];
89
+ throw new RuntimeSessionConflict(`session ${target} watch record is unreadable: ${error instanceof Error ? error.message : String(error)}`);
90
+ }
91
+ if (!Array.isArray(parsed))
92
+ throw new RuntimeSessionConflict(`session ${target} watch record is not an array`);
93
+ return parsed.map((candidate, index) => {
94
+ if (!candidate || typeof candidate !== 'object')
95
+ throw new RuntimeSessionConflict(`session ${target} watch row ${index} is invalid`);
96
+ const row = candidate;
97
+ if (typeof row.watcher !== 'string' || typeof row.createdAt !== 'string' || !Array.isArray(row.sources)
98
+ || row.sources.some((source) => source !== 'manual' && source !== 'parent')
99
+ || (row.snapshotPending !== undefined && typeof row.snapshotPending !== 'string'))
100
+ throw new RuntimeSessionConflict(`session ${target} watch row ${index} is invalid`);
101
+ return row;
102
+ });
103
+ }
104
+ function writeWatches(target, entries) {
105
+ const dir = sessionStoreDir(target);
106
+ mkdirSync(dir, { recursive: true });
107
+ const path = watchPath(target);
108
+ const tmp = join(dir, `.watchers.json.${process.pid}.${randomUUID()}.tmp`);
109
+ writeFileSync(tmp, JSON.stringify(entries, null, 2) + '\n');
110
+ renameSync(tmp, path);
111
+ }
112
+ function registrationRaw(input) {
113
+ const sessionId = scalar(input.sessionId, 'sessionId');
114
+ const runtimeOwner = scalar(input.runtimeOwner, 'runtimeOwner');
115
+ return {
116
+ session_id: sessionId,
117
+ governed: false,
118
+ worktree_path: scalar(input.worktreePath, 'worktreePath'),
119
+ branch: input.branch ?? '',
120
+ node: input.node?.trim() || '',
121
+ title: input.title?.trim() || '',
122
+ name: '',
123
+ parent: input.parentSessionId?.trim() || '',
124
+ status: 'active',
125
+ proposal: '',
126
+ merges: 0,
127
+ note: '',
128
+ sortkey: null,
129
+ createdAt: input.createdAt ?? Date.now(),
130
+ harness: runtimeOwner,
131
+ harness_session_id: sessionId,
132
+ stopped: false,
133
+ archived: false,
134
+ cold_proof: '',
135
+ adapter_recovery: '',
136
+ launcher: '',
137
+ launch_cmd: '',
138
+ create_request_id: '',
139
+ create_payload_hash: '',
140
+ runtime_owner: runtimeOwner,
141
+ runtime_state: 'registered',
142
+ runtime_revision: '',
143
+ runtime_metadata: metadata(input.runtimeMetadata),
144
+ launch_readiness_pending: '',
145
+ };
146
+ }
147
+ function sameRegistration(raw, input) {
148
+ return raw.runtime_owner === input.runtimeOwner.trim()
149
+ && raw.worktree_path === input.worktreePath.trim()
150
+ && (raw.branch || null) === (input.branch || null)
151
+ && (raw.parent || null) === (input.parentSessionId?.trim() || null)
152
+ && metadataKey(raw.runtime_metadata) === metadataKey(input.runtimeMetadata);
153
+ }
154
+ export async function registerRuntimeSession(input) {
155
+ const id = scalar(input.sessionId, 'sessionId');
156
+ const parent = input.parentSessionId?.trim() || null;
157
+ return withSessionRecordLocks([id, ...(parent ? [parent] : [])], async () => {
158
+ if (parent) {
159
+ const parentRaw = readRaw(parent);
160
+ if (!parentRaw)
161
+ throw new RuntimeSessionConflict(`parent runtime session ${parent} is not registered`);
162
+ if (parentRaw.runtime_owner !== input.runtimeOwner.trim())
163
+ throw new RuntimeSessionConflict(`parent runtime session ${parent} belongs to another runtime`);
164
+ }
165
+ const existing = readRaw(id);
166
+ if (existing) {
167
+ if (!sameRegistration(existing, input))
168
+ throw new RuntimeSessionConflict(`session ${id} is already registered with different runtime coordinates`);
169
+ return { replayed: true };
170
+ }
171
+ writeRaw(registrationRaw(input));
172
+ if (parent)
173
+ writeWatches(id, [{
174
+ watcher: parent,
175
+ createdAt: new Date().toISOString(),
176
+ sources: ['parent'],
177
+ snapshotPending: randomUUID(),
178
+ }]);
179
+ return { replayed: false };
180
+ });
181
+ }
182
+ function stateMessage(record) {
183
+ const proposal = record.lifecycle === 'awaiting' && record.proposal ? `/${record.proposal}` : '';
184
+ const note = record.note ? ` — ${record.note}` : '';
185
+ return `[spex watch] ${record.sessionId} is ${record.runtimeState ?? record.lifecycle}${proposal}${note}`;
186
+ }
187
+ function notificationAttributes(record) {
188
+ if (!record.revision || !record.runtimeState)
189
+ throw new RuntimeSessionConflict(`runtime session ${record.sessionId} has no publishable state revision`);
190
+ return {
191
+ kind: RUNTIME_NOTIFICATION_KIND,
192
+ runtimeOwner: record.runtimeOwner,
193
+ runtimeState: record.runtimeState,
194
+ revision: record.revision,
195
+ lifecycle: record.lifecycle,
196
+ proposal: record.proposal ?? '',
197
+ note: record.note ?? '',
198
+ };
199
+ }
200
+ function pendingFor(receipt, mid) {
201
+ return {
202
+ mid,
203
+ text: receipt.delivery.text,
204
+ from: receipt.delivery.from,
205
+ ...(receipt.delivery.attributes ? { attributes: receipt.delivery.attributes } : {}),
206
+ dispatch: { operation: receipt.operation, requestDigest: receipt.requestDigest },
207
+ };
208
+ }
209
+ export function runtimeSessionNotification(parentSessionId, message) {
210
+ const parent = scalar(parentSessionId, 'parentSessionId');
211
+ const child = message.from?.trim();
212
+ const attributes = message.attributes;
213
+ if (!child || !attributes || attributes.kind !== RUNTIME_NOTIFICATION_KIND)
214
+ return null;
215
+ const record = readRuntimeSession(child);
216
+ if (!record || record.parentSessionId !== parent || record.runtimeOwner !== attributes.runtimeOwner)
217
+ return null;
218
+ if (!attributes.revision || !attributes.runtimeState || !isSessionLifecycle(attributes.lifecycle))
219
+ return null;
220
+ if (attributes.proposal && !isSessionProposal(attributes.proposal))
221
+ return null;
222
+ return {
223
+ childSessionId: child,
224
+ runtimeOwner: attributes.runtimeOwner,
225
+ runtimeState: attributes.runtimeState,
226
+ revision: attributes.revision,
227
+ lifecycle: attributes.lifecycle,
228
+ proposal: isSessionProposal(attributes.proposal) ? attributes.proposal : null,
229
+ note: attributes.note || null,
230
+ runtimeMetadata: record.runtimeMetadata,
231
+ };
232
+ }
233
+ export async function publishRuntimeSessionState(input) {
234
+ const id = scalar(input.sessionId, 'sessionId');
235
+ const owner = scalar(input.runtimeOwner, 'runtimeOwner');
236
+ const revision = scalar(input.revision, 'revision');
237
+ const runtimeState = scalar(input.runtimeState, 'runtimeState');
238
+ const initial = readRaw(id);
239
+ if (!initial)
240
+ throw new RuntimeSessionConflict(`runtime session ${id} is not registered`);
241
+ const watchers = readWatches(id).map((entry) => entry.watcher);
242
+ return withSessionRecordLocks([id, ...watchers], async () => withDeliveryLocks(watchers, async () => {
243
+ const raw = readRaw(id);
244
+ if (!raw)
245
+ throw new RuntimeSessionConflict(`runtime session ${id} disappeared during publication`);
246
+ if (raw.runtime_owner !== owner)
247
+ throw new RuntimeSessionConflict(`runtime session ${id} belongs to ${raw.runtime_owner || 'no external runtime'}, not ${owner}`);
248
+ const proposal = input.proposal ?? null;
249
+ const note = input.note ?? null;
250
+ const sameRevision = raw.runtime_revision === revision;
251
+ const sameState = raw.runtime_state === runtimeState && raw.status === input.lifecycle
252
+ && (raw.proposal || null) === proposal && (raw.note || null) === note;
253
+ if (sameRevision && !sameState)
254
+ throw new RuntimeSessionConflict(`runtime revision ${revision} for session ${id} is already bound to another state`);
255
+ const candidate = runtimeRecord({
256
+ ...raw,
257
+ status: input.lifecycle,
258
+ proposal: proposal ?? '',
259
+ note: note ?? '',
260
+ runtime_state: runtimeState,
261
+ runtime_revision: revision,
262
+ });
263
+ const message = stateMessage(candidate);
264
+ const attributes = notificationAttributes(candidate);
265
+ const historical = watchers.map((watcher) => {
266
+ const operation = `runtime-state:${id}`;
267
+ const requestDigest = digest(`${id}\0${watcher}\0${revision}`);
268
+ const payloadHash = digest(`${operation}\0${requestDigest}\0${message}\0${JSON.stringify(attributes)}`);
269
+ const prior = sentDispatchReceipt(watcher, operation, requestDigest);
270
+ if (prior && prior.payloadHash !== payloadHash)
271
+ throw new RuntimeSessionConflict(`runtime revision ${revision} notification is already bound to different bytes`);
272
+ return { watcher, operation, requestDigest, payloadHash, prior };
273
+ });
274
+ const historicalReplay = !sameRevision && historical.length > 0 && historical.every(({ prior }) => prior);
275
+ const replayed = sameRevision || historicalReplay;
276
+ if (!sameRevision) {
277
+ if (!historicalReplay) {
278
+ writeRaw({
279
+ ...raw,
280
+ status: input.lifecycle,
281
+ proposal: proposal ?? '',
282
+ note: note ?? '',
283
+ runtime_state: runtimeState,
284
+ runtime_revision: revision,
285
+ });
286
+ recordStatus(id, input.lifecycle, proposal, note);
287
+ }
288
+ }
289
+ const notified = [];
290
+ for (const { watcher, operation, requestDigest, payloadHash, prior } of historical) {
291
+ const receipt = {
292
+ operation,
293
+ requestDigest,
294
+ payloadHash,
295
+ delivery: { text: message, from: id, attributes },
296
+ };
297
+ if (prior) {
298
+ if (!prior.delivered)
299
+ ensurePendingWhileLocked(watcher, pendingFor(receipt, prior.mid));
300
+ }
301
+ else {
302
+ const { mid } = appendSent(watcher, message, id, undefined, receipt);
303
+ enqueue(watcher, pendingFor(receipt, mid));
304
+ }
305
+ notified.push(watcher);
306
+ }
307
+ const entries = readWatches(id);
308
+ if (entries.some((entry) => entry.snapshotPending)) {
309
+ writeWatches(id, entries.map(({ snapshotPending: _pending, ...entry }) => entry));
310
+ }
311
+ return { notified, replayed };
312
+ }));
313
+ }
314
+ export function readRuntimeSession(sessionId) {
315
+ const raw = readRaw(scalar(sessionId, 'sessionId'));
316
+ return raw?.runtime_owner ? runtimeRecord(raw) : null;
317
+ }
318
+ export function runtimeSessionChildren(parentSessionId, runtimeOwner) {
319
+ const parent = scalar(parentSessionId, 'parentSessionId');
320
+ return listSessionIds().flatMap((id) => {
321
+ const record = readRuntimeSession(id);
322
+ if (!record || record.parentSessionId !== parent || (runtimeOwner && record.runtimeOwner !== runtimeOwner))
323
+ return [];
324
+ return [record];
325
+ }).sort((left, right) => left.createdAt - right.createdAt || left.sessionId.localeCompare(right.sessionId));
326
+ }
@@ -0,0 +1,47 @@
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
+ attributes?: Record<string, string>;
25
+ };
26
+ };
27
+ export declare function recordStatus(id: string, status: SessionLifecycle, proposal: SessionProposal | null, note: string | null): void;
28
+ export declare function appendSent(id: string, text: string, from: string | null, replyVia?: 'note', dispatchReceipt?: SentDispatchReceipt): {
29
+ mid: string;
30
+ };
31
+ export type SentDispatchState = {
32
+ mid: string;
33
+ payloadHash: string;
34
+ delivery: SentDispatchReceipt['delivery'] | null;
35
+ delivered: boolean;
36
+ };
37
+ export declare function sentDispatchReceipt(id: string, operation: SentDispatchReceipt['operation'], requestDigest: string): SentDispatchState | null;
38
+ export declare function settleSentDispatch(id: string, mid: string): void;
39
+ export declare function timelineEvents(id: string): TimelineEvent[];
40
+ export declare function timelineStamp(id: string): string | null;
41
+ export declare function lastHumanSendVia(id: string): 'note' | null;
42
+ export type SessionTurn = Readonly<{
43
+ token: string;
44
+ acceptedAt: string;
45
+ }>;
46
+ export declare function currentHumanTurn(id: string): SessionTurn | null;
47
+ 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.7",
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.7"
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<DispatchResult>;
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) => controlRequest(claudeHeadlessSock(rec.session), { type: 'interrupt' }, {
32
- name: 'claude-headless', session: rec.session, timeoutMs: CONTROL_TIMEOUT_MS,
33
- rejected: 'claude-headless control rejected the request',
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;