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.
Files changed (56) hide show
  1. package/node_modules/@spexcode/{spec-cli → session-core}/dist/delivery-queue.d.ts +1 -1
  2. package/node_modules/@spexcode/{spec-cli → session-core}/dist/delivery-queue.js +18 -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 +52 -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 +50 -0
  12. package/node_modules/@spexcode/session-core/dist/runtime-session.js +286 -0
  13. package/node_modules/@spexcode/session-core/dist/session-timeline.d.ts +46 -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
@@ -3,7 +3,7 @@ export type PendingMessage = {
3
3
  text: string;
4
4
  from: string | null;
5
5
  dispatch?: {
6
- operation: 'merge';
6
+ operation: string;
7
7
  requestDigest: string;
8
8
  };
9
9
  };
@@ -1,6 +1,7 @@
1
1
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { runtimeRoot, sessionArtifactPath, sessionStoreDir } from '@spexcode/spec-core';
4
+ import { sentDispatchReceipt, settleSentDispatch } from './session-timeline.js';
4
5
  const queuePath = (id) => sessionArtifactPath(id, 'pending.json');
5
6
  const revokedSenderRoot = () => join(runtimeRoot(), '.revoked-senders');
6
7
  const revokedSenderPath = (id) => join(revokedSenderRoot(), id);
@@ -63,7 +64,7 @@ function read(id) {
63
64
  && typeof m.mid === 'string'
64
65
  && typeof m.text === 'string'
65
66
  && (m.dispatch === undefined
66
- || (m.dispatch?.operation === 'merge'
67
+ || (typeof m.dispatch?.operation === 'string'
67
68
  && typeof m.dispatch?.requestDigest === 'string')));
68
69
  }
69
70
  catch {
@@ -160,7 +161,22 @@ export async function drain(id, insert, timeoutMs = 5_000) {
160
161
  }
161
162
  let ok = false;
162
163
  try {
163
- ok = await insert(queued[0]);
164
+ const head = queued[0];
165
+ if (head.dispatch) {
166
+ const receipt = sentDispatchReceipt(id, head.dispatch.operation, head.dispatch.requestDigest);
167
+ if (!receipt || receipt.mid !== head.mid || !receipt.delivery
168
+ || receipt.delivery.text !== head.text || receipt.delivery.from !== head.from)
169
+ return { delivered, remaining: queued.length };
170
+ if (receipt.delivered)
171
+ ok = true;
172
+ else {
173
+ ok = await insert(head);
174
+ if (ok)
175
+ settleSentDispatch(id, head.mid);
176
+ }
177
+ }
178
+ else
179
+ ok = await insert(head);
164
180
  }
165
181
  catch {
166
182
  ok = false;
@@ -0,0 +1,5 @@
1
+ export { acceptMessage, MessageKeyConflict, type AcceptMessageOptions, type MessageIdempotency, type PreparedMessage } from './message.js';
2
+ export { drain, owesDelivery, pendingMessages, type PendingMessage } from './delivery-queue.js';
3
+ export { publishRuntimeSessionState, readRuntimeSession, registerRuntimeSession, runtimeSessionChildren, RuntimeSessionConflict, type RuntimeSessionRecord, type RuntimeSessionRegistration, type RuntimeSessionState, } from './runtime-session.js';
4
+ export { advanceFollow, followedSessions, followCursor, readCursors, unreadSince, type Cursors } from './session-cursors.js';
5
+ export { currentHumanTurn, lastHumanSendVia, recordStatus, timelineEvents, timelineStamp, timelineTail, type SessionTurn, type TimelineEvent, } from './session-timeline.js';
@@ -0,0 +1,5 @@
1
+ export { acceptMessage, MessageKeyConflict } from './message.js';
2
+ export { drain, owesDelivery, pendingMessages } from './delivery-queue.js';
3
+ export { publishRuntimeSessionState, readRuntimeSession, registerRuntimeSession, runtimeSessionChildren, RuntimeSessionConflict, } from './runtime-session.js';
4
+ export { advanceFollow, followedSessions, followCursor, readCursors, unreadSince } from './session-cursors.js';
5
+ export { currentHumanTurn, lastHumanSendVia, recordStatus, timelineEvents, timelineStamp, timelineTail, } from './session-timeline.js';
@@ -0,0 +1,3 @@
1
+ export { enqueue, ensurePendingWhileLocked, pendingSnapshot, replacePendingWhileLocked, revokePendingFromWhileLocked, revokeSenderDelivery, senderDeliveryRevoked, withDeliveryLocks, } from './delivery-queue.js';
2
+ export { appendSent, sentDispatchReceipt, settleSentDispatch, type SentDispatchReceipt, type SentDispatchState, } from './session-timeline.js';
3
+ export { trySessionRecordLockSync, withSessionRecordLock, withSessionRecordLocks, withSessionRecordLockSync, } from './record-lock.js';
@@ -0,0 +1,3 @@
1
+ export { enqueue, ensurePendingWhileLocked, pendingSnapshot, replacePendingWhileLocked, revokePendingFromWhileLocked, revokeSenderDelivery, senderDeliveryRevoked, withDeliveryLocks, } from './delivery-queue.js';
2
+ export { appendSent, sentDispatchReceipt, settleSentDispatch, } from './session-timeline.js';
3
+ export { trySessionRecordLockSync, withSessionRecordLock, withSessionRecordLocks, withSessionRecordLockSync, } from './record-lock.js';
@@ -0,0 +1,22 @@
1
+ import { type SentDispatchReceipt } from './session-timeline.js';
2
+ export type MessageIdempotency = SentDispatchReceipt;
3
+ export type PreparedMessage = {
4
+ text: string;
5
+ replyVia?: 'note';
6
+ };
7
+ export type AcceptMessageOptions = {
8
+ target: string;
9
+ text: string;
10
+ from?: string;
11
+ idempotency?: MessageIdempotency;
12
+ validate?: () => Promise<void>;
13
+ prepare: () => Promise<PreparedMessage>;
14
+ };
15
+ export declare class MessageKeyConflict extends Error {
16
+ readonly code = "dispatch_key_reused";
17
+ constructor(operation: string);
18
+ }
19
+ export declare function acceptMessage(options: AcceptMessageOptions): Promise<{
20
+ mid: string;
21
+ replayed: boolean;
22
+ }>;
@@ -0,0 +1,52 @@
1
+ import { ensurePendingWhileLocked, enqueue, senderDeliveryRevoked, withDeliveryLocks } from './delivery-queue.js';
2
+ import { appendSent, sentDispatchReceipt } from './session-timeline.js';
3
+ import { withSessionRecordLocks } from './record-lock.js';
4
+ export class MessageKeyConflict extends Error {
5
+ code = 'dispatch_key_reused';
6
+ constructor(operation) {
7
+ super(`idempotency key is already bound to another ${operation} payload`);
8
+ this.name = 'MessageKeyConflict';
9
+ }
10
+ }
11
+ const keyedPending = (receipt, mid, delivery) => ({
12
+ mid,
13
+ text: delivery.text,
14
+ from: delivery.from,
15
+ dispatch: { operation: receipt.operation, requestDigest: receipt.requestDigest },
16
+ });
17
+ // The durable acceptance boundary. Product validation and prompt composition run while the record fence is
18
+ // held, but the package alone owns append + debt publication and keyed crash recovery.
19
+ export async function acceptMessage(options) {
20
+ const from = options.from ?? null;
21
+ let result = null;
22
+ await withSessionRecordLocks([options.target, ...(from ? [from] : [])], async () => {
23
+ if (from && senderDeliveryRevoked(from))
24
+ throw new Error(`sender session ${from} is closed; prompt NOT delivered`);
25
+ await options.validate?.();
26
+ const accept = async () => {
27
+ if (options.idempotency) {
28
+ const prior = sentDispatchReceipt(options.target, options.idempotency.operation, options.idempotency.requestDigest);
29
+ if (prior) {
30
+ if (prior.payloadHash !== options.idempotency.payloadHash)
31
+ throw new MessageKeyConflict(options.idempotency.operation);
32
+ if (prior.delivery && !prior.delivered)
33
+ ensurePendingWhileLocked(options.target, keyedPending(options.idempotency, prior.mid, prior.delivery));
34
+ result = { mid: prior.mid, replayed: true };
35
+ return;
36
+ }
37
+ }
38
+ const prepared = await options.prepare();
39
+ const receipt = options.idempotency
40
+ ? { ...options.idempotency, delivery: { text: prepared.text, from } }
41
+ : undefined;
42
+ const { mid } = appendSent(options.target, options.text, from, prepared.replyVia, receipt);
43
+ enqueue(options.target, receipt ? keyedPending(receipt, mid, receipt.delivery) : { mid, text: prepared.text, from });
44
+ result = { mid, replayed: false };
45
+ };
46
+ if (options.idempotency)
47
+ await withDeliveryLocks([options.target], accept);
48
+ else
49
+ await accept();
50
+ });
51
+ return result;
52
+ }
@@ -0,0 +1,7 @@
1
+ export declare class SessionRecordLockTimeout extends Error {
2
+ constructor(id: string);
3
+ }
4
+ export declare function withSessionRecordLock<T>(id: string, body: () => Promise<T>, signal?: AbortSignal): Promise<T>;
5
+ export declare function withSessionRecordLockSync<T>(id: string, body: () => T): T;
6
+ export declare function trySessionRecordLockSync(id: string): (() => void) | null;
7
+ export declare function withSessionRecordLocks<T>(rawIds: string[], body: () => Promise<T>, index?: number, ids?: string[]): Promise<T>;
@@ -0,0 +1,152 @@
1
+ import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { runtimeRoot } from '@spexcode/spec-core';
4
+ const lockRoot = () => join(runtimeRoot(), '.session-locks');
5
+ const lockPath = (id) => join(lockRoot(), `${id}.lock`);
6
+ const syncPause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
7
+ export class SessionRecordLockTimeout extends Error {
8
+ constructor(id) {
9
+ super(`session ${id}: lifecycle transition lock timed out; refusing a stale write`);
10
+ this.name = 'SessionRecordLockTimeout';
11
+ }
12
+ }
13
+ function acquireSync(id, timeoutMs = 30_000) {
14
+ mkdirSync(lockRoot(), { recursive: true });
15
+ const path = lockPath(id), deadline = Date.now() + timeoutMs;
16
+ for (;;) {
17
+ try {
18
+ const fd = openSync(path, 'wx');
19
+ writeSync(fd, String(process.pid));
20
+ closeSync(fd);
21
+ return () => { try {
22
+ unlinkSync(path);
23
+ }
24
+ catch { /* another recovery already removed it */ } };
25
+ }
26
+ catch (error) {
27
+ if (error.code !== 'EEXIST')
28
+ throw error;
29
+ let owner = 0;
30
+ try {
31
+ owner = Number(readFileSync(path, 'utf8').trim()) || 0;
32
+ }
33
+ catch { /* race */ }
34
+ if (owner && owner !== process.pid) {
35
+ try {
36
+ process.kill(owner, 0);
37
+ }
38
+ catch {
39
+ try {
40
+ unlinkSync(path);
41
+ }
42
+ catch { /* race */ }
43
+ ;
44
+ continue;
45
+ }
46
+ }
47
+ if (Date.now() >= deadline)
48
+ throw new SessionRecordLockTimeout(id);
49
+ syncPause(10);
50
+ }
51
+ }
52
+ }
53
+ const aborted = (signal) => signal.reason instanceof Error
54
+ ? signal.reason
55
+ : Object.assign(new Error('The operation was aborted'), { name: 'AbortError', code: 'ABORT_ERR' });
56
+ async function pause(signal) {
57
+ if (!signal) {
58
+ await new Promise((resolve) => setTimeout(resolve, 10));
59
+ return;
60
+ }
61
+ if (signal.aborted)
62
+ throw aborted(signal);
63
+ await new Promise((resolve, reject) => {
64
+ const timer = setTimeout(done, 10);
65
+ const abort = () => { clearTimeout(timer); signal.removeEventListener('abort', abort); reject(aborted(signal)); };
66
+ function done() { signal.removeEventListener('abort', abort); resolve(); }
67
+ signal.addEventListener('abort', abort, { once: true });
68
+ });
69
+ }
70
+ async function acquire(id, timeoutMs = 30_000, signal) {
71
+ mkdirSync(lockRoot(), { recursive: true });
72
+ const path = lockPath(id), deadline = Date.now() + timeoutMs;
73
+ for (;;) {
74
+ if (signal?.aborted)
75
+ throw aborted(signal);
76
+ try {
77
+ const fd = openSync(path, 'wx');
78
+ writeSync(fd, String(process.pid));
79
+ closeSync(fd);
80
+ return () => { try {
81
+ unlinkSync(path);
82
+ }
83
+ catch { /* another recovery already removed it */ } };
84
+ }
85
+ catch (error) {
86
+ if (error.code !== 'EEXIST')
87
+ throw error;
88
+ let owner = 0;
89
+ try {
90
+ owner = Number(readFileSync(path, 'utf8').trim()) || 0;
91
+ }
92
+ catch { /* race */ }
93
+ if (owner && owner !== process.pid) {
94
+ try {
95
+ process.kill(owner, 0);
96
+ }
97
+ catch {
98
+ try {
99
+ unlinkSync(path);
100
+ }
101
+ catch { /* race */ }
102
+ ;
103
+ continue;
104
+ }
105
+ }
106
+ if (Date.now() >= deadline)
107
+ throw new SessionRecordLockTimeout(id);
108
+ await pause(signal);
109
+ }
110
+ }
111
+ }
112
+ export async function withSessionRecordLock(id, body, signal) {
113
+ const release = await acquire(id, 30_000, signal);
114
+ try {
115
+ return await body();
116
+ }
117
+ finally {
118
+ release();
119
+ }
120
+ }
121
+ export function withSessionRecordLockSync(id, body) {
122
+ const release = acquireSync(id);
123
+ try {
124
+ return body();
125
+ }
126
+ finally {
127
+ release();
128
+ }
129
+ }
130
+ export function trySessionRecordLockSync(id) {
131
+ mkdirSync(lockRoot(), { recursive: true });
132
+ const path = lockPath(id);
133
+ try {
134
+ const fd = openSync(path, 'wx');
135
+ writeSync(fd, String(process.pid));
136
+ closeSync(fd);
137
+ return () => { try {
138
+ unlinkSync(path);
139
+ }
140
+ catch { /* another recovery already removed it */ } };
141
+ }
142
+ catch (error) {
143
+ if (error.code === 'EEXIST')
144
+ return null;
145
+ throw error;
146
+ }
147
+ }
148
+ export async function withSessionRecordLocks(rawIds, body, index = 0, ids = [...new Set(rawIds)].sort()) {
149
+ if (index >= ids.length)
150
+ return body();
151
+ return withSessionRecordLock(ids[index], () => withSessionRecordLocks(ids, body, index + 1, ids));
152
+ }
@@ -0,0 +1,50 @@
1
+ import { type SessionLifecycle, type SessionProposal } from '@spexcode/spec-core';
2
+ export type RuntimeSessionRegistration = {
3
+ sessionId: string;
4
+ runtimeOwner: string;
5
+ worktreePath: string;
6
+ branch: string | null;
7
+ parentSessionId?: string | null;
8
+ title?: string | null;
9
+ node?: string | null;
10
+ runtimeMetadata?: Record<string, string>;
11
+ createdAt?: number;
12
+ };
13
+ export type RuntimeSessionState = {
14
+ sessionId: string;
15
+ runtimeOwner: string;
16
+ revision: string;
17
+ runtimeState: string;
18
+ lifecycle: SessionLifecycle;
19
+ proposal?: SessionProposal | null;
20
+ note?: string | null;
21
+ };
22
+ export type RuntimeSessionRecord = {
23
+ sessionId: string;
24
+ runtimeOwner: string;
25
+ runtimeState: string | null;
26
+ revision: string | null;
27
+ worktreePath: string;
28
+ branch: string | null;
29
+ parentSessionId: string | null;
30
+ title: string | null;
31
+ node: string | null;
32
+ runtimeMetadata: Record<string, string>;
33
+ lifecycle: SessionLifecycle;
34
+ proposal: SessionProposal | null;
35
+ note: string | null;
36
+ createdAt: number;
37
+ };
38
+ export declare class RuntimeSessionConflict extends Error {
39
+ readonly code = "runtime_session_conflict";
40
+ constructor(message: string);
41
+ }
42
+ export declare function registerRuntimeSession(input: RuntimeSessionRegistration): Promise<{
43
+ replayed: boolean;
44
+ }>;
45
+ export declare function publishRuntimeSessionState(input: RuntimeSessionState): Promise<{
46
+ notified: string[];
47
+ replayed: boolean;
48
+ }>;
49
+ export declare function readRuntimeSession(sessionId: string): RuntimeSessionRecord | null;
50
+ export declare function runtimeSessionChildren(parentSessionId: string, runtimeOwner?: string): RuntimeSessionRecord[];
@@ -0,0 +1,286 @@
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
+ function scalar(value, field) {
18
+ const normalized = value.trim();
19
+ if (!normalized)
20
+ throw new RuntimeSessionConflict(`${field} must be a non-empty string`);
21
+ return normalized;
22
+ }
23
+ function metadata(value) {
24
+ if (!value)
25
+ return {};
26
+ const entries = Object.entries(value).map(([rawKey, rawValue]) => {
27
+ const key = scalar(rawKey, 'runtimeMetadata key');
28
+ if (typeof rawValue !== 'string')
29
+ throw new RuntimeSessionConflict(`runtimeMetadata.${key} must be a string`);
30
+ return [key, rawValue];
31
+ }).sort(([left], [right]) => left.localeCompare(right));
32
+ if (new Set(entries.map(([key]) => key)).size !== entries.length)
33
+ throw new RuntimeSessionConflict('runtimeMetadata keys must be unique after normalization');
34
+ return Object.fromEntries(entries);
35
+ }
36
+ function metadataKey(value) {
37
+ return JSON.stringify(metadata(value));
38
+ }
39
+ function readRaw(id) {
40
+ const entry = readRecordEntry(id);
41
+ if (entry.kind === 'absent')
42
+ return null;
43
+ if (entry.kind === 'corrupt')
44
+ throw new RuntimeSessionConflict(`session ${id} record is corrupt: ${entry.error}`);
45
+ return entry.raw;
46
+ }
47
+ function runtimeRecord(raw) {
48
+ const runtimeOwner = raw.runtime_owner?.trim();
49
+ if (!runtimeOwner)
50
+ throw new RuntimeSessionConflict(`session ${raw.session_id} is not owned by an external runtime`);
51
+ if (!isSessionLifecycle(raw.status))
52
+ throw new RuntimeSessionConflict(`session ${raw.session_id} has invalid lifecycle ${JSON.stringify(raw.status)}`);
53
+ if (!(raw.proposal == null || raw.proposal === '' || isSessionProposal(raw.proposal)))
54
+ throw new RuntimeSessionConflict(`session ${raw.session_id} has invalid proposal ${JSON.stringify(raw.proposal)}`);
55
+ return {
56
+ sessionId: raw.session_id,
57
+ runtimeOwner,
58
+ runtimeState: raw.runtime_state?.trim() || null,
59
+ revision: raw.runtime_revision?.trim() || null,
60
+ worktreePath: raw.worktree_path,
61
+ branch: raw.branch || null,
62
+ parentSessionId: raw.parent || null,
63
+ title: raw.title || null,
64
+ node: raw.node || null,
65
+ runtimeMetadata: metadata(raw.runtime_metadata),
66
+ lifecycle: raw.status,
67
+ proposal: isSessionProposal(raw.proposal) ? raw.proposal : null,
68
+ note: raw.note || null,
69
+ createdAt: Number(raw.createdAt) || 0,
70
+ };
71
+ }
72
+ function writeRaw(raw) {
73
+ const dir = sessionStoreDir(raw.session_id);
74
+ mkdirSync(dir, { recursive: true });
75
+ const path = sessionRecordPath(raw.session_id);
76
+ const tmp = join(dir, `.session.json.${process.pid}.${randomUUID()}.tmp`);
77
+ writeFileSync(tmp, JSON.stringify(raw, null, 2) + '\n');
78
+ renameSync(tmp, path);
79
+ }
80
+ function readWatches(target) {
81
+ let parsed;
82
+ try {
83
+ parsed = JSON.parse(readFileSync(watchPath(target), 'utf8'));
84
+ }
85
+ catch (error) {
86
+ if (error.code === 'ENOENT')
87
+ return [];
88
+ throw new RuntimeSessionConflict(`session ${target} watch record is unreadable: ${error instanceof Error ? error.message : String(error)}`);
89
+ }
90
+ if (!Array.isArray(parsed))
91
+ throw new RuntimeSessionConflict(`session ${target} watch record is not an array`);
92
+ return parsed.map((candidate, index) => {
93
+ if (!candidate || typeof candidate !== 'object')
94
+ throw new RuntimeSessionConflict(`session ${target} watch row ${index} is invalid`);
95
+ const row = candidate;
96
+ if (typeof row.watcher !== 'string' || typeof row.createdAt !== 'string' || !Array.isArray(row.sources)
97
+ || row.sources.some((source) => source !== 'manual' && source !== 'parent')
98
+ || (row.snapshotPending !== undefined && typeof row.snapshotPending !== 'string'))
99
+ throw new RuntimeSessionConflict(`session ${target} watch row ${index} is invalid`);
100
+ return row;
101
+ });
102
+ }
103
+ function writeWatches(target, entries) {
104
+ const dir = sessionStoreDir(target);
105
+ mkdirSync(dir, { recursive: true });
106
+ const path = watchPath(target);
107
+ const tmp = join(dir, `.watchers.json.${process.pid}.${randomUUID()}.tmp`);
108
+ writeFileSync(tmp, JSON.stringify(entries, null, 2) + '\n');
109
+ renameSync(tmp, path);
110
+ }
111
+ function registrationRaw(input) {
112
+ const sessionId = scalar(input.sessionId, 'sessionId');
113
+ const runtimeOwner = scalar(input.runtimeOwner, 'runtimeOwner');
114
+ return {
115
+ session_id: sessionId,
116
+ governed: false,
117
+ worktree_path: scalar(input.worktreePath, 'worktreePath'),
118
+ branch: input.branch ?? '',
119
+ node: input.node?.trim() || '',
120
+ title: input.title?.trim() || '',
121
+ name: '',
122
+ parent: input.parentSessionId?.trim() || '',
123
+ status: 'active',
124
+ proposal: '',
125
+ merges: 0,
126
+ note: '',
127
+ sortkey: null,
128
+ createdAt: input.createdAt ?? Date.now(),
129
+ harness: runtimeOwner,
130
+ harness_session_id: sessionId,
131
+ stopped: false,
132
+ archived: false,
133
+ cold_proof: '',
134
+ adapter_recovery: '',
135
+ launcher: '',
136
+ launch_cmd: '',
137
+ create_request_id: '',
138
+ create_payload_hash: '',
139
+ runtime_owner: runtimeOwner,
140
+ runtime_state: 'registered',
141
+ runtime_revision: '',
142
+ runtime_metadata: metadata(input.runtimeMetadata),
143
+ launch_readiness_pending: '',
144
+ };
145
+ }
146
+ function sameRegistration(raw, input) {
147
+ return raw.runtime_owner === input.runtimeOwner.trim()
148
+ && raw.worktree_path === input.worktreePath.trim()
149
+ && (raw.branch || null) === (input.branch || null)
150
+ && (raw.parent || null) === (input.parentSessionId?.trim() || null)
151
+ && metadataKey(raw.runtime_metadata) === metadataKey(input.runtimeMetadata);
152
+ }
153
+ export async function registerRuntimeSession(input) {
154
+ const id = scalar(input.sessionId, 'sessionId');
155
+ const parent = input.parentSessionId?.trim() || null;
156
+ return withSessionRecordLocks([id, ...(parent ? [parent] : [])], async () => {
157
+ if (parent) {
158
+ const parentRaw = readRaw(parent);
159
+ if (!parentRaw)
160
+ throw new RuntimeSessionConflict(`parent runtime session ${parent} is not registered`);
161
+ if (parentRaw.runtime_owner !== input.runtimeOwner.trim())
162
+ throw new RuntimeSessionConflict(`parent runtime session ${parent} belongs to another runtime`);
163
+ }
164
+ const existing = readRaw(id);
165
+ if (existing) {
166
+ if (!sameRegistration(existing, input))
167
+ throw new RuntimeSessionConflict(`session ${id} is already registered with different runtime coordinates`);
168
+ return { replayed: true };
169
+ }
170
+ writeRaw(registrationRaw(input));
171
+ if (parent)
172
+ writeWatches(id, [{
173
+ watcher: parent,
174
+ createdAt: new Date().toISOString(),
175
+ sources: ['parent'],
176
+ snapshotPending: randomUUID(),
177
+ }]);
178
+ return { replayed: false };
179
+ });
180
+ }
181
+ function stateMessage(record) {
182
+ const proposal = record.lifecycle === 'awaiting' && record.proposal ? `/${record.proposal}` : '';
183
+ const note = record.note ? ` — ${record.note}` : '';
184
+ return `[spex watch] ${record.sessionId} is ${record.runtimeState ?? record.lifecycle}${proposal}${note}`;
185
+ }
186
+ function pendingFor(receipt, mid) {
187
+ return {
188
+ mid,
189
+ text: receipt.delivery.text,
190
+ from: receipt.delivery.from,
191
+ dispatch: { operation: receipt.operation, requestDigest: receipt.requestDigest },
192
+ };
193
+ }
194
+ export async function publishRuntimeSessionState(input) {
195
+ const id = scalar(input.sessionId, 'sessionId');
196
+ const owner = scalar(input.runtimeOwner, 'runtimeOwner');
197
+ const revision = scalar(input.revision, 'revision');
198
+ const runtimeState = scalar(input.runtimeState, 'runtimeState');
199
+ const initial = readRaw(id);
200
+ if (!initial)
201
+ throw new RuntimeSessionConflict(`runtime session ${id} is not registered`);
202
+ const watchers = readWatches(id).map((entry) => entry.watcher);
203
+ return withSessionRecordLocks([id, ...watchers], async () => withDeliveryLocks(watchers, async () => {
204
+ const raw = readRaw(id);
205
+ if (!raw)
206
+ throw new RuntimeSessionConflict(`runtime session ${id} disappeared during publication`);
207
+ if (raw.runtime_owner !== owner)
208
+ throw new RuntimeSessionConflict(`runtime session ${id} belongs to ${raw.runtime_owner || 'no external runtime'}, not ${owner}`);
209
+ const proposal = input.proposal ?? null;
210
+ const note = input.note ?? null;
211
+ const sameRevision = raw.runtime_revision === revision;
212
+ const sameState = raw.runtime_state === runtimeState && raw.status === input.lifecycle
213
+ && (raw.proposal || null) === proposal && (raw.note || null) === note;
214
+ if (sameRevision && !sameState)
215
+ throw new RuntimeSessionConflict(`runtime revision ${revision} for session ${id} is already bound to another state`);
216
+ const candidate = runtimeRecord({
217
+ ...raw,
218
+ status: input.lifecycle,
219
+ proposal: proposal ?? '',
220
+ note: note ?? '',
221
+ runtime_state: runtimeState,
222
+ runtime_revision: revision,
223
+ });
224
+ const message = stateMessage(candidate);
225
+ const historical = watchers.map((watcher) => {
226
+ const operation = `runtime-state:${id}`;
227
+ const requestDigest = digest(`${id}\0${watcher}\0${revision}`);
228
+ const payloadHash = digest(`${operation}\0${requestDigest}\0${message}`);
229
+ const prior = sentDispatchReceipt(watcher, operation, requestDigest);
230
+ if (prior && prior.payloadHash !== payloadHash)
231
+ throw new RuntimeSessionConflict(`runtime revision ${revision} notification is already bound to different bytes`);
232
+ return { watcher, operation, requestDigest, payloadHash, prior };
233
+ });
234
+ const historicalReplay = !sameRevision && historical.length > 0 && historical.every(({ prior }) => prior);
235
+ const replayed = sameRevision || historicalReplay;
236
+ if (!sameRevision) {
237
+ if (!historicalReplay) {
238
+ writeRaw({
239
+ ...raw,
240
+ status: input.lifecycle,
241
+ proposal: proposal ?? '',
242
+ note: note ?? '',
243
+ runtime_state: runtimeState,
244
+ runtime_revision: revision,
245
+ });
246
+ recordStatus(id, input.lifecycle, proposal, note);
247
+ }
248
+ }
249
+ const notified = [];
250
+ for (const { watcher, operation, requestDigest, payloadHash, prior } of historical) {
251
+ const receipt = {
252
+ operation,
253
+ requestDigest,
254
+ payloadHash,
255
+ delivery: { text: message, from: id },
256
+ };
257
+ if (prior) {
258
+ if (!prior.delivered)
259
+ ensurePendingWhileLocked(watcher, pendingFor(receipt, prior.mid));
260
+ }
261
+ else {
262
+ const { mid } = appendSent(watcher, message, id, undefined, receipt);
263
+ enqueue(watcher, pendingFor(receipt, mid));
264
+ }
265
+ notified.push(watcher);
266
+ }
267
+ const entries = readWatches(id);
268
+ if (entries.some((entry) => entry.snapshotPending)) {
269
+ writeWatches(id, entries.map(({ snapshotPending: _pending, ...entry }) => entry));
270
+ }
271
+ return { notified, replayed };
272
+ }));
273
+ }
274
+ export function readRuntimeSession(sessionId) {
275
+ const raw = readRaw(scalar(sessionId, 'sessionId'));
276
+ return raw?.runtime_owner ? runtimeRecord(raw) : null;
277
+ }
278
+ export function runtimeSessionChildren(parentSessionId, runtimeOwner) {
279
+ const parent = scalar(parentSessionId, 'parentSessionId');
280
+ return listSessionIds().flatMap((id) => {
281
+ const record = readRuntimeSession(id);
282
+ if (!record || record.parentSessionId !== parent || (runtimeOwner && record.runtimeOwner !== runtimeOwner))
283
+ return [];
284
+ return [record];
285
+ }).sort((left, right) => left.createdAt - right.createdAt || left.sessionId.localeCompare(right.sessionId));
286
+ }