@spexcode/spec-cli 0.6.5 → 0.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/spex.mjs +2 -1
- package/dist/claude-headless.d.ts +4 -1
- package/dist/claude-headless.js +13 -4
- package/dist/cli.js +72 -23
- package/dist/client.d.ts +2 -1
- package/dist/client.js +13 -8
- package/dist/codex-runtime-generations.d.ts +5 -0
- package/dist/codex-runtime-generations.js +112 -0
- package/dist/doctor.js +7 -1
- package/dist/gateway-hub.js +7 -5
- package/dist/gateway.d.ts +1 -0
- package/dist/gateway.js +44 -20
- package/dist/graphCache.js +2 -1
- package/dist/graphSnapshot.js +2 -1
- package/dist/harness.d.ts +15 -2
- package/dist/harness.js +174 -54
- package/dist/help.d.ts +5 -0
- package/dist/help.js +26 -6
- package/dist/index.js +3 -3
- package/dist/listen.d.ts +2 -1
- package/dist/listen.js +10 -10
- package/dist/opencode-headless.d.ts +1 -0
- package/dist/opencode-headless.js +7 -0
- package/dist/runtime-rotate.d.ts +1 -0
- package/dist/runtime-rotate.js +58 -0
- package/dist/session-follow.js +1 -1
- package/dist/session-timeline.d.ts +6 -46
- package/dist/session-timeline.js +8 -221
- package/dist/sessions.d.ts +25 -4
- package/dist/sessions.js +822 -394
- package/dist/supervise.js +3 -3
- package/package.json +6 -4
- package/dist/delivery-queue.d.ts +0 -23
- package/dist/delivery-queue.js +0 -179
- package/dist/session-cursors.d.ts +0 -14
- package/dist/session-cursors.js +0 -82
package/dist/help.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
export declare function commandHelp(name: string, verb?: string): string | null;
|
|
2
|
+
export type PublicCommand = Readonly<{
|
|
3
|
+
name: string;
|
|
4
|
+
text: string;
|
|
5
|
+
}>;
|
|
6
|
+
export declare function publicCommands(): readonly PublicCommand[];
|
|
2
7
|
export type HelpCatalogEntry = Readonly<{
|
|
3
8
|
id: string;
|
|
4
9
|
title: string;
|
package/dist/help.js
CHANGED
|
@@ -36,7 +36,7 @@ session to that node. --prompt-file <path>|- carries a long prompt without shell
|
|
|
36
36
|
reproduced against a frozen commit; a base that names no commit is refused before anything is created.
|
|
37
37
|
The successful receipt names what to read, monitor, and reply on. --ssh uses an existing gateway-to-gateway
|
|
38
38
|
communication tunnel: its full id anchors the remote project, creation stays parentless and remote, and its
|
|
39
|
-
prompt carries a runnable reply path over that same tunnel
|
|
39
|
+
prompt carries a runnable reply path over that same tunnel.`, ['project-bound']],
|
|
40
40
|
ls: [['spex session ls [SEL…] [--children[=<PARENT-SEL>]] [--status a,b] [--all] [--json]', 'spex session ls --ssh <address> <FULL-SESSION-ID> [--children=<PARENT-SEL>] [--status a,b] [--json]'],
|
|
41
41
|
'One-shot table of this project\'s live sessions, with each direct parent beside the row. --children scopes it to the caller\'s direct children; --children=<PARENT-SEL> names another parent without changing positional selector grammar. The heading summarizes the displayed scope by status. Shelved sessions ([[archive]]) are hidden; --all includes them, and naming one explicitly always shows it. An explicit id missing from the session list is diagnosed from terminal-close history: closed is a successful answer, while no live, archived, or closed history is a named miss. --ssh uses an existing gateway-to-gateway communication tunnel; its full id anchors one remote project rather than filtering the table, and archive projection stays unavailable on that peer route.', ['selector']],
|
|
42
42
|
resources: ['spex session resources [--json]', 'Read-only host/process ownership, budgets, shared refs, and findings.'],
|
|
@@ -96,8 +96,8 @@ const SESSION_HELP_GROUPS = [
|
|
|
96
96
|
{ title: 'Worker verbs (declare YOUR OWN state — a claim the graph and your supervisor act on)', verbs: ['done', 'park', 'ask', 'files', 'web'] },
|
|
97
97
|
{ title: 'Human escape hatch', verbs: ['attach'] },
|
|
98
98
|
];
|
|
99
|
-
const SESSION_WRITE_NOTE = `
|
|
100
|
-
|
|
99
|
+
const SESSION_WRITE_NOTE = `PROJECT-BOUND WRITES loudly refuse a backend serving another project's repo —
|
|
100
|
+
name the target with --api <url> to drive it on purpose.`;
|
|
101
101
|
function indent(text, spaces) {
|
|
102
102
|
const prefix = ' '.repeat(spaces);
|
|
103
103
|
return text.split('\n').map((line) => `${prefix}${line}`).join('\n');
|
|
@@ -127,7 +127,7 @@ function sessionVerbHelp(verb) {
|
|
|
127
127
|
const ENTRIES = {
|
|
128
128
|
// ── project verbs (implicit object = this project) ────────────────────────
|
|
129
129
|
graph: {
|
|
130
|
-
line: 'graph the assembled
|
|
130
|
+
line: 'graph list the assembled spec nodes: bare = readable tree · --json = full payload · --public = static graph snapshot',
|
|
131
131
|
body: `Usage: spex graph [--focus <id>] [--depth N] [--json] | spex graph --public [--out <path>] [--content-dir <path>]
|
|
132
132
|
|
|
133
133
|
The normal assembled view is merged spec tree + worktree overlay + sessions. Bare it renders the
|
|
@@ -170,15 +170,22 @@ hooks yet (CI, a cloud agent): generated and excluded, the artifacts never arriv
|
|
|
170
170
|
see: 'spex doctor (verify the materialized artifacts actually reach an agent)',
|
|
171
171
|
},
|
|
172
172
|
doctor: {
|
|
173
|
-
line: 'doctor diagnose
|
|
173
|
+
line: 'doctor diagnose project health; repair app-server only on explicit request [--contract|--conflicts]',
|
|
174
174
|
body: `Usage: spex doctor spec-health findings + delivery report: preconditions · git-hook floor ·
|
|
175
175
|
contract · hooks + handler existence · backend · footprint
|
|
176
176
|
spex doctor --contract print the composed surface:system text any agent here reads
|
|
177
177
|
spex doctor --conflicts detect double-delivery (loose artifacts beside the managed ones)
|
|
178
|
+
spex doctor repair app-server [--launcher <name>]
|
|
179
|
+
prove a fresh app-server, then switch new sessions to it
|
|
178
180
|
|
|
179
181
|
Bare doctor is the opt-in, read-only health surface: it reports altitude and breadth findings without
|
|
180
182
|
putting them in the lint gate, then audits workflow delivery. Run it directly or let the tidy workflow
|
|
181
|
-
consume the same visible diagnosis
|
|
183
|
+
consume the same visible diagnosis.
|
|
184
|
+
|
|
185
|
+
Use repair app-server only when new sessions cannot be accepted and existing work must stay connected.
|
|
186
|
+
It proves a fresh app-server before routing future Codex sessions there. Existing sessions stay on the
|
|
187
|
+
previous server while it drains; the command does not kill or move them. --launcher selects a configured
|
|
188
|
+
Codex launcher, or the configured default is used when it is one.`,
|
|
182
189
|
see: 'spex spec lint (deterministic graph/contract gate) · spex materialize (repair delivery artifacts)',
|
|
183
190
|
},
|
|
184
191
|
flat: {
|
|
@@ -495,6 +502,14 @@ export function commandHelp(name, verb) {
|
|
|
495
502
|
if (exact)
|
|
496
503
|
return `${exact}\n\nsee also: spex session (the complete drawer)\n\nmap: spex help · skills: spex guide`;
|
|
497
504
|
}
|
|
505
|
+
if (name === 'doctor' && verb === 'repair') {
|
|
506
|
+
return `Usage: spex doctor repair app-server [--launcher <name>]
|
|
507
|
+
|
|
508
|
+
Use this explicit repair when new sessions cannot be accepted but existing work must stay connected.
|
|
509
|
+
It proves a fresh app-server, then routes future Codex sessions there. Existing sessions stay bound to
|
|
510
|
+
the prior server while it drains; this command never kills or moves them. --launcher must name a
|
|
511
|
+
configured Codex launcher.\n\nsee also: spex doctor (the complete command) · spex session resources\n\nmap: spex help · skills: spex guide`;
|
|
512
|
+
}
|
|
498
513
|
const e = ENTRIES[name];
|
|
499
514
|
if (!e)
|
|
500
515
|
return null;
|
|
@@ -502,6 +517,11 @@ export function commandHelp(name, verb) {
|
|
|
502
517
|
const header = oneLiner ? `spex ${name} — ${oneLiner}\n\n` : ''; // unlisted entries (internal, help) lead with their own Usage
|
|
503
518
|
return `${header}${e.body}${e.see ? `\n\nsee also: ${e.see}` : ''}\n\nmap: spex help · skills: spex guide`;
|
|
504
519
|
}
|
|
520
|
+
export function publicCommands() {
|
|
521
|
+
return Object.entries(ENTRIES)
|
|
522
|
+
.filter(([name]) => name !== 'internal')
|
|
523
|
+
.map(([name, entry]) => ({ name, text: `${entry.line}\n${entry.body}` }));
|
|
524
|
+
}
|
|
505
525
|
// The guidance catalog consumes this registry projection rather than copying help prose or maintaining a
|
|
506
526
|
// second command list. Each returned text is exactly what the corresponding public help probe prints.
|
|
507
527
|
export function helpCatalogEntries() {
|
package/dist/index.js
CHANGED
|
@@ -751,8 +751,9 @@ app.get('/api/sessions/:id/socket', upgradeWebSocket((c) => {
|
|
|
751
751
|
}));
|
|
752
752
|
// ONE input route, `kind` the discriminator — the transport split is an implementation fact, not API surface.
|
|
753
753
|
// kind:"text" (`spex session send`, the server-side merge dispatch) appends the prompt to the
|
|
754
|
-
// target timeline, then best-effort pokes its adapter. A
|
|
755
|
-
//
|
|
754
|
+
// target timeline, then best-effort pokes its adapter. A proven-unreachable transport joined to a live
|
|
755
|
+
// registered agent is stranded and refuses before append; an unproven/dead-restartable channel stays queued.
|
|
756
|
+
// 502 means the record rejected the write.
|
|
756
757
|
// kind:"keys" is the LAST-RESORT raw face (`spex session send --keys`): an ORDERED BATCH of
|
|
757
758
|
// nav-mode key tokens over tmux send-keys, delivered in array order so tap order survives
|
|
758
759
|
// ([[nav-mode-key-ordering]]); unstable by nature — callers try a plain text send first. An unknown kind is a
|
|
@@ -878,7 +879,6 @@ superviseBridges(); // restore visible helpers after failure; their viewer subsc
|
|
|
878
879
|
superviseQueue(); // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
|
|
879
880
|
superviseTurnFailures(); // reconcile adapter-owned native failure subscriptions across backend replacement
|
|
880
881
|
superviseDelivery(); // hand over messages an earlier pass could not ([[delivery-queue]]): the retry half of dispatch
|
|
881
|
-
console.log(`spec-cli serving .spec (from git) on http://localhost:${port}`);
|
|
882
882
|
let graphWatchersClosed = false;
|
|
883
883
|
const closeGraphWatchers = () => {
|
|
884
884
|
if (graphWatchersClosed)
|
package/dist/listen.d.ts
CHANGED
package/dist/listen.js
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
// @@@ listenOrExit - the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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,
|
|
21
|
+
server.listen(port, opts.host, publishReady);
|
|
22
22
|
else
|
|
23
|
-
server.listen(port,
|
|
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
|
+
}
|
package/dist/session-follow.js
CHANGED
|
@@ -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 '
|
|
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
|
|
2
|
-
import type
|
|
3
|
-
export
|
|
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: (
|
|
43
|
-
status:
|
|
44
|
-
proposal:
|
|
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 {};
|
package/dist/session-timeline.js
CHANGED
|
@@ -1,211 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
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 = (
|
|
182
|
-
:
|
|
183
|
-
//
|
|
184
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/dist/sessions.d.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
418
|
+
type DispatchIdempotency = MessageIdempotency;
|
|
398
419
|
type DispatchAcceptCode = 'dispatch_key_reused' | 'session_merge_not_proposed';
|
|
399
420
|
type AcceptedDispatch = DispatchResult & {
|
|
400
421
|
replayed?: boolean;
|