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
@@ -1,11 +1,6 @@
1
- // @@@ listenOrExit - the shared "I own this port; if I cannot bind it, I have failed" contract for the two
2
- // public-port listeners: the supervisor's raw-TCP proxy (supervise.ts) and the dashboard/public gateway
3
- // (gateway.ts). A bind failure is the ONE thing neither may survive it is the opposite of the keep-serving
4
- // process guard, which rides out transient throws once the port is already held. So instead of leaving the
5
- // listen error unhandled (under `serve` the supervisor's uncaughtException guard would SWALLOW it into a
6
- // portless zombie on a random child port; under `dashboard`, with no guard, it would crash with a raw stack),
7
- // we attach one handler that fails loudly the same way on both surfaces: name the busy port and the repair,
8
- // reap any child booted for this bind so none is orphaned, and exit non-zero.
1
+ // @@@ listenOrExit ([[listener-readiness]]) - the one public-listener transition: before `listening`, a bind
2
+ // failure is loud and fatal; after it, publication side effects and user-visible ready lines may run. Keeping
3
+ // both halves here prevents a private child or pre-bind caller from announcing a surface it does not own.
9
4
  //
10
5
  // http.Server / https.Server both extend net.Server, so this one signature covers every caller.
11
6
  export function listenOrExit(server, port, opts) {
@@ -17,8 +12,13 @@ export function listenOrExit(server, port, opts) {
17
12
  console.error(`spec-cli: ${opts.label} cannot bind — ${why}. Free :${port} (e.g. lsof -i :${port}) or pick another port, then retry.`);
18
13
  process.exit(1);
19
14
  });
15
+ const publishReady = () => {
16
+ opts.onListen?.();
17
+ for (const line of Array.isArray(opts.ready) ? opts.ready : [opts.ready])
18
+ console.log(line);
19
+ };
20
20
  if (opts.host)
21
- server.listen(port, opts.host, opts.onListen);
21
+ server.listen(port, opts.host, publishReady);
22
22
  else
23
- server.listen(port, opts.onListen);
23
+ server.listen(port, publishReady);
24
24
  }
@@ -1,4 +1,5 @@
1
1
  import { type DispatchResult, type HarnessDeliveryRecord } from './harness.js';
2
2
  export declare function opencodeHeadlessLaunchCommand(opencodeCmd?: string): string;
3
3
  export declare function opencodeHeadlessWakeCommand(opencodeCmd: string, harnessSessionId: string | null | undefined, text: string, outcomePath?: string): string;
4
+ export declare function opencodeHeadlessColdRuntime(rec: Pick<HarnessDeliveryRecord, 'session'>): Promise<DispatchResult>;
4
5
  export declare function spawnOpenCodeHeadlessTurn(rec: HarnessDeliveryRecord, text: string, opencodeCmd: string, socketPath: string): Promise<DispatchResult>;
@@ -97,6 +97,13 @@ export function opencodeHeadlessWakeCommand(opencodeCmd, harnessSessionId, text,
97
97
  ];
98
98
  return turnHome([...runPrelude(opencodeCmd), ...resume].join('\n'), outcomePath);
99
99
  }
100
+ export async function opencodeHeadlessColdRuntime(rec) {
101
+ const { rendezvousListening } = await import('./harness.js');
102
+ const probe = await rendezvousListening(rec.session);
103
+ return probe === 'dead'
104
+ ? { ok: true }
105
+ : { ok: false, error: `opencode-headless rendezvous is still ${probe === 'live' ? 'live' : 'unproven'}` };
106
+ }
100
107
  function readTurnOutcome(path) {
101
108
  let value;
102
109
  try {
@@ -0,0 +1 @@
1
+ export declare function runDoctorRepairAppServer(args: string[]): Promise<number>;
@@ -0,0 +1,58 @@
1
+ import { mainCheckout, runtimeRoot } from '@spexcode/spec-core';
2
+ import { rotateCodexCurrentGeneration } from './codex-runtime-generations.js';
3
+ import { codexBinary, defaultLauncher, resolveLauncher, sessionIdentityEnvVars } from './harness.js';
4
+ import { spawnDetachedRuntime } from './runtime-ownership.js';
5
+ function usageError(message) {
6
+ console.error(`spex doctor repair app-server: ${message}`);
7
+ console.error('usage: spex doctor repair app-server [--launcher <name>]');
8
+ process.exit(2);
9
+ }
10
+ function parseAppServerRepairArgs(args) {
11
+ const positionals = [];
12
+ let launcher = null;
13
+ for (let index = 0; index < args.length; index++) {
14
+ const arg = args[index];
15
+ if (arg === '--launcher') {
16
+ const value = args[++index];
17
+ if (!value || value.startsWith('--'))
18
+ usageError('--launcher requires a configured launcher name');
19
+ if (launcher !== null)
20
+ usageError('--launcher may appear only once');
21
+ launcher = value;
22
+ }
23
+ else if (arg.startsWith('--'))
24
+ usageError(`unknown flag ${arg}`);
25
+ else
26
+ positionals.push(arg);
27
+ }
28
+ if (positionals.length !== 2 || positionals[0] !== 'repair' || positionals[1] !== 'app-server')
29
+ usageError('expected repair app-server');
30
+ return { launcher };
31
+ }
32
+ export async function runDoctorRepairAppServer(args) {
33
+ const parsed = parseAppServerRepairArgs(args);
34
+ const project = mainCheckout();
35
+ const root = runtimeRoot();
36
+ const launcherName = parsed.launcher ?? defaultLauncher(project);
37
+ const launcher = resolveLauncher(launcherName, project);
38
+ if (launcher.harness !== 'codex' && launcher.harness !== 'codex-headless') {
39
+ usageError(`launcher '${launcher.name}' does not provide a switchable app-server; select a configured Codex launcher with --launcher`);
40
+ }
41
+ const command = process.env.SPEXCODE_CODEX_SERVER_CMD || codexBinary(launcher.cmd);
42
+ const env = { ...process.env };
43
+ for (const key of sessionIdentityEnvVars())
44
+ delete env[key];
45
+ const rotation = await rotateCodexCurrentGeneration(root, async (candidate) => {
46
+ await spawnDetachedRuntime({
47
+ cwd: root,
48
+ logFile: candidate.logFile,
49
+ pidFile: candidate.pidFile,
50
+ receiptFile: candidate.receiptFile,
51
+ command,
52
+ args: ['app-server', '--listen', `unix://${candidate.socketPath}`],
53
+ env,
54
+ });
55
+ });
56
+ console.log(`switched app-server ${rotation.previous.id} -> ${rotation.current.id} (launcher ${launcher.name})`);
57
+ return 0;
58
+ }
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { sessionStoreDir } from '@spexcode/spec-core';
3
- import { advanceFollow, followCursor, unreadSince } from './session-cursors.js';
3
+ import { advanceFollow, followCursor, unreadSince } from '@spexcode/session-core';
4
4
  import { timelineDisplay, timelineEvents, timelineStamp } from './session-timeline.js';
5
5
  import { sessionTitle } from './sessions.js';
6
6
  // @@@ session-follow - supervision is FOLLOWING a log past a cursor, never polling a derived board. One tick
@@ -1,51 +1,11 @@
1
- import type { Lifecycle, Proposal } from './sessions.js';
2
- import type { ExecutionTurn } from './execution-trace.js';
3
- export type TimelineEvent = {
4
- ts: string;
5
- kind: 'status';
6
- status: Lifecycle;
7
- proposal: Proposal | null;
8
- note: string | null;
9
- display?: string;
10
- } | {
11
- ts: string;
12
- kind: 'sent';
13
- mid: string;
14
- text: string;
15
- from: string | null;
16
- replyVia?: 'note';
17
- };
18
- export type SentDispatchReceipt = {
19
- operation: 'merge';
20
- requestDigest: string;
21
- payloadHash: string;
22
- delivery?: {
23
- text: string;
24
- from: string | null;
25
- };
26
- };
27
- export declare function recordStatus(id: string, status: Lifecycle, proposal: Proposal | 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;
1
+ import { type SessionLifecycle, type SessionProposal } from '@spexcode/spec-core';
2
+ import { type TimelineEvent } from '@spexcode/session-core';
3
+ export * from '@spexcode/session-core';
41
4
  type DisplayWord = 'working' | 'idle' | 'review' | 'done' | 'close-pending' | 'parked' | 'error' | 'asking' | 'queued';
42
- export declare const timelineDisplay: (e: {
43
- status: Lifecycle;
44
- proposal: Proposal | null;
5
+ export declare const timelineDisplay: (event: {
6
+ status: SessionLifecycle;
7
+ proposal: SessionProposal | null;
45
8
  }) => DisplayWord;
46
- export declare function lastHumanSendVia(id: string): 'note' | null;
47
- export declare function currentHumanTurn(id: string): ExecutionTurn | null;
48
9
  export declare function readTimeline(id: string, limit?: number): {
49
10
  events: TimelineEvent[];
50
11
  } | null;
51
- export {};
@@ -1,211 +1,11 @@
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, readAliasedRawRecord } 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 display word for an authored state — the SAME vocabulary every other surface speaks (awaiting → its
177
- // proposal's label, active → working), duplicated here as a tiny read-time map rather than importing the state
178
- // machine (sessions.ts imports THIS module for appendSent; a value import back would be a cycle — the
179
- // Lifecycle/Proposal imports above are type-only, erased at runtime).
1
+ import { readAliasedRawRecord } from '@spexcode/spec-core';
2
+ import { timelineTail } from '@spexcode/session-core';
3
+ export * from '@spexcode/session-core';
180
4
  const PROPOSAL_DISPLAY = { merge: 'review', nothing: 'done', close: 'close-pending' };
181
- export const timelineDisplay = (e) => e.status === 'awaiting' ? (PROPOSAL_DISPLAY[e.proposal ?? 'nothing'] ?? 'done')
182
- : e.status === 'active' ? 'working' : e.status;
183
- // the channel of the LAST HUMAN send (from == null): 'note' when the note-reply hint rode along, else null.
184
- // This is what makes the reply-channel hints SYMMETRIC ([[session-timeline]]): a human send with no note flag
185
- // arriving after a note-send is the "back at a terminal" transition, and the delivery gets the counter-insert.
186
- // Derived from the durable log — no new state, and it survives a server restart. Agent senders (`from` set)
187
- // say nothing about where the HUMAN is reading, so they neither set nor clear it.
188
- export function lastHumanSendVia(id) {
189
- const evs = timelineEvents(id);
190
- for (let i = evs.length - 1; i >= 0; i--) {
191
- const e = evs[i];
192
- if (e.kind === 'sent' && e.from == null)
193
- return e.replyVia === 'note' ? 'note' : null;
194
- }
195
- return null;
196
- }
197
- // The current human turn is a durable fact from the accepted-message log, not a backend-local generation.
198
- export function currentHumanTurn(id) {
199
- const evs = timelineEvents(id);
200
- for (let i = evs.length - 1; i >= 0; i--) {
201
- const e = evs[i];
202
- if (e.kind === 'sent' && e.from == null)
203
- return { token: e.mid, acceptedAt: e.ts };
204
- }
205
- return null;
206
- }
207
- // the read surface behind GET /api/sessions/:id/timeline: the last `limit` events, oldest first, each status
208
- // event carrying its composed display word. null = no such session (the route 404s).
5
+ export const timelineDisplay = (event) => event.status === 'awaiting' ? (PROPOSAL_DISPLAY[event.proposal ?? 'nothing'] ?? 'done')
6
+ : event.status === 'active' ? 'working' : event.status;
7
+ // The HTTP projection remains a SpexCode concern: it resolves aliases, hides unmanaged records, and adds the
8
+ // board's display vocabulary. The package beneath it only reads the durable file protocol.
209
9
  export function readTimeline(id, limit = 500) {
210
10
  let raw;
211
11
  try {
@@ -216,18 +16,5 @@ export function readTimeline(id, limit = 500) {
216
16
  }
217
17
  if (!raw || !raw.governed)
218
18
  return null;
219
- const wanted = Math.max(1, limit);
220
- const tail = [];
221
- for (const path of timelineFiles(id).reverse()) {
222
- const remaining = wanted - tail.length;
223
- if (remaining <= 0)
224
- break;
225
- tail.unshift(...tailPublicEvents(path, remaining));
226
- }
227
- return { events: tail.map((e) => {
228
- if (e.kind === 'status')
229
- return { ...e, display: timelineDisplay(e) };
230
- const { dispatchReceipt: _receipt, ...event } = e;
231
- return event;
232
- }) };
19
+ return { events: timelineTail(raw.session_id, limit).map((event) => event.kind === 'status' ? { ...event, display: timelineDisplay(event) } : event) };
233
20
  }
@@ -1,9 +1,9 @@
1
1
  import { type ReviewDiffFile } from '@spexcode/spec-core';
2
2
  import { type ConfigPreset, type SpecLite } from '@spexcode/spec-core';
3
- import { type Harness, type TurnFailure, type DispatchResult, type PaneProbe } from './harness.js';
3
+ import { type Harness, type TurnFailure, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js';
4
4
  import { envSessionId, type RawRecord, type SessionLifecycle, type SessionProposal } from '@spexcode/spec-core';
5
5
  import { type SessionWeb } from './session-web.js';
6
- import { type SentDispatchReceipt } from './session-timeline.js';
6
+ import { type MessageIdempotency } from '@spexcode/session-core';
7
7
  export declare const TMUX_SOCK: string;
8
8
  export type { DispatchResult };
9
9
  export type Lifecycle = SessionLifecycle;
@@ -129,7 +129,7 @@ export declare function backendLaunchAuthority(env?: {
129
129
  PORT?: string;
130
130
  }): string;
131
131
  export declare function rawLifecycleStatus(rec: Pick<SessRec, 'status' | 'launchOwner'>): string;
132
- export declare function canDrainQueued(rec: Pick<SessRec, 'status' | 'launchOwner'>, authority?: string): boolean;
132
+ export declare function canDrainQueued(rec: Pick<SessRec, 'status' | 'launchOwner' | 'stopped'>, authority?: string): boolean;
133
133
  export declare class SessionRecordUnusable extends Error {
134
134
  readonly code: 'corrupt' | 'retired';
135
135
  readonly session: string;
@@ -296,6 +296,7 @@ export declare const markDone: (proposal?: Proposal, sessionId?: string, note?:
296
296
  export declare const markError: (sessionId?: string) => boolean;
297
297
  export declare function markTurnFailure(sessionId: string | undefined, note: string): boolean;
298
298
  export declare function markHeadlessTurnFailure(sessionId: string, harness: string, exitCode: string): boolean;
299
+ export declare function stageHarnessLaunchProof(sessionId: string | undefined, harnessSessionId: string | undefined, launchPayload: string): boolean;
299
300
  export declare function markHarnessSessionId(sessionId: string | undefined, harnessSessionId: string | undefined): boolean;
300
301
  export declare function markIdle(sessionId?: string): boolean;
301
302
  export declare function mergeReadiness(proposal?: 'merge' | 'nothing'): {
@@ -344,6 +345,26 @@ export type MergeSessionResult = {
344
345
  status?: 409;
345
346
  };
346
347
  export declare function mergeSession(id: string): Promise<MergeSessionResult>;
348
+ export type SessionLeafReceipt = {
349
+ version: 1;
350
+ kind: 'session-leaf';
351
+ sessionId: string;
352
+ pid: number;
353
+ startToken: string;
354
+ };
355
+ type SessionLeafReceiptCandidate = {
356
+ ok: boolean;
357
+ receipt?: SessionLeafReceipt;
358
+ reason?: string;
359
+ };
360
+ export declare function parseSessionLeafReceipt(raw: string, sessionId: string): SessionLeafReceipt | null;
361
+ export declare function sessionLeafReceiptCandidate(sessionId: string, pid: number, panePid: number | null, procs: ProcTable | null, startBefore: string | null, startAfter: string | null): SessionLeafReceiptCandidate;
362
+ export declare function sessionLeafReceiptIdentityState(receipt: SessionLeafReceipt, registeredPid: number | null, currentStartToken: string | null, liveness: 'alive' | 'dead' | 'unknown'): 'same-live' | 'gone' | 'pid-reused' | 'unknown' | 'registration-changed' | 'registration-missing';
363
+ export type SessionLeafProcessProbe = Readonly<{
364
+ startToken(pid: number): string | null;
365
+ liveness(pid: number): 'alive' | 'dead' | 'unknown';
366
+ }>;
367
+ export declare function installSessionLeafProcessProbeForTest(probe: SessionLeafProcessProbe): () => void;
347
368
  export declare const stopSession: (id: string) => Promise<boolean>;
348
369
  export declare const archiveSession: (id: string, on?: boolean) => Promise<boolean>;
349
370
  export declare const closeSession: (id: string, rawSource?: unknown) => Promise<boolean>;
@@ -394,7 +415,7 @@ export type SessionTableScope = {
394
415
  parent: string;
395
416
  };
396
417
  export declare function formatTable(sessions: Session[], color?: boolean, scope?: SessionTableScope): string;
397
- type DispatchIdempotency = SentDispatchReceipt;
418
+ type DispatchIdempotency = MessageIdempotency;
398
419
  type DispatchAcceptCode = 'dispatch_key_reused' | 'session_merge_not_proposed';
399
420
  type AcceptedDispatch = DispatchResult & {
400
421
  replayed?: boolean;