spexcode 0.6.8 → 0.7.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/@spexcode/session-application/package.json +5 -5
- package/node_modules/@spexcode/session-events/package.json +2 -2
- package/node_modules/@spexcode/session-protocol/package.json +1 -1
- package/node_modules/@spexcode/session-runtime/package.json +2 -2
- package/node_modules/@spexcode/session-selflaunch/dist/locality.d.ts +12 -1
- package/node_modules/@spexcode/session-selflaunch/dist/locality.js +62 -10
- package/node_modules/@spexcode/session-selflaunch/package.json +3 -3
- package/node_modules/@spexcode/session-topology/package.json +2 -2
- package/node_modules/@spexcode/spec-cli/bin/spex.mjs +127 -10
- package/node_modules/@spexcode/spec-cli/dist/cli.js +19 -0
- package/node_modules/@spexcode/spec-cli/dist/file-write.js +14 -2
- package/node_modules/@spexcode/spec-cli/dist/guide.js +9 -11
- package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +11 -5
- package/node_modules/@spexcode/spec-cli/dist/harness.js +185 -28
- package/node_modules/@spexcode/spec-cli/dist/host.js +5 -1
- package/node_modules/@spexcode/spec-cli/dist/index.js +21 -65
- package/node_modules/@spexcode/spec-cli/dist/machine-peer.d.ts +2 -1
- package/node_modules/@spexcode/spec-cli/dist/machine-peer.js +45 -14
- package/node_modules/@spexcode/spec-cli/dist/opencode.js +10 -2
- package/node_modules/@spexcode/spec-cli/dist/pi-harness.js +18 -5
- package/node_modules/@spexcode/spec-cli/dist/pi-headless.d.ts +2 -0
- package/node_modules/@spexcode/spec-cli/dist/pi-headless.js +27 -0
- package/node_modules/@spexcode/spec-cli/dist/session-transcript.d.ts +57 -0
- package/node_modules/@spexcode/spec-cli/dist/session-transcript.js +137 -0
- package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +4 -0
- package/node_modules/@spexcode/spec-cli/dist/sessions.js +104 -36
- package/node_modules/@spexcode/spec-cli/dist/shim-runtime.js +14 -0
- package/node_modules/@spexcode/spec-cli/dist/supervise.js +1 -1
- package/node_modules/@spexcode/spec-cli/package.json +8 -6
- package/node_modules/@spexcode/spec-core/dist/layout.d.ts +0 -1
- package/node_modules/@spexcode/spec-core/package.json +1 -1
- package/node_modules/@spexcode/spec-core/templates/spexcode.json +36 -13
- package/node_modules/@spexcode/spec-eval/package.json +2 -2
- package/node_modules/@spexcode/spec-forge/package.json +2 -2
- package/node_modules/@spexcode/transcript/dist/frames.d.ts +46 -0
- package/node_modules/@spexcode/transcript/dist/frames.js +87 -0
- package/node_modules/@spexcode/transcript/dist/index.d.ts +5 -0
- package/node_modules/@spexcode/transcript/dist/index.js +5 -0
- package/node_modules/@spexcode/transcript/dist/live.d.ts +16 -0
- package/node_modules/@spexcode/transcript/dist/live.js +65 -0
- package/node_modules/@spexcode/transcript/dist/parsers.d.ts +50 -0
- package/node_modules/@spexcode/transcript/dist/parsers.js +395 -0
- package/node_modules/@spexcode/transcript/dist/readers.d.ts +16 -0
- package/node_modules/@spexcode/transcript/dist/readers.js +375 -0
- package/node_modules/@spexcode/{spec-cli/dist/transcript-reader.d.ts → transcript/dist/turns.d.ts} +11 -5
- package/node_modules/@spexcode/transcript/dist/turns.js +11 -0
- package/node_modules/@spexcode/transcript/package.json +30 -0
- package/node_modules/smol-toml/LICENSE +24 -0
- package/node_modules/smol-toml/README.md +418 -0
- package/node_modules/smol-toml/dist/date.d.ts +41 -0
- package/node_modules/smol-toml/dist/date.js +127 -0
- package/node_modules/smol-toml/dist/error.d.ts +38 -0
- package/node_modules/smol-toml/dist/error.js +63 -0
- package/node_modules/smol-toml/dist/extract.js +69 -0
- package/node_modules/smol-toml/dist/index.cjs +734 -0
- package/node_modules/smol-toml/dist/index.d.ts +43 -0
- package/node_modules/smol-toml/dist/index.js +33 -0
- package/node_modules/smol-toml/dist/parse.d.ts +36 -0
- package/node_modules/smol-toml/dist/parse.js +149 -0
- package/node_modules/smol-toml/dist/primitive.js +238 -0
- package/node_modules/smol-toml/dist/stringify.d.ts +31 -0
- package/node_modules/smol-toml/dist/stringify.js +181 -0
- package/node_modules/smol-toml/dist/struct.js +179 -0
- package/node_modules/smol-toml/dist/util.d.ts +38 -0
- package/node_modules/smol-toml/dist/util.js +89 -0
- package/node_modules/smol-toml/package.json +68 -0
- package/package.json +3 -3
- package/node_modules/@spexcode/spec-cli/dist/execution-trace.d.ts +0 -27
- package/node_modules/@spexcode/spec-cli/dist/execution-trace.js +0 -475
- package/node_modules/@spexcode/spec-cli/dist/session-execution.d.ts +0 -10
- package/node_modules/@spexcode/spec-cli/dist/session-execution.js +0 -70
- package/node_modules/@spexcode/spec-cli/dist/transcript-reader.js +0 -251
|
@@ -39,21 +39,34 @@ export default function spexcode(pi: ExtensionAPI) {
|
|
|
39
39
|
const toolFields = (toolName: string, input: unknown) =>
|
|
40
40
|
({ tool_name: TOOL[toolName] ?? toolName, tool_input: rt.toolInput(input, "path") })
|
|
41
41
|
|
|
42
|
+
// the RUNNING turn's context, kept from the events pi hands us mid-turn: ctx.abort() is pi's own "stop the
|
|
43
|
+
// current agent operation" — the one honest interrupt — and only a turn that is actually streaming has one.
|
|
44
|
+
let turn: { abort: () => void; isIdle: () => boolean } | null = null
|
|
42
45
|
// bound as soon as the extension loads so liveness reads online early; only a governed launch carries the
|
|
43
46
|
// socket env (sessions.ts rvEnv) — a self-launched bare \`pi\` skips this.
|
|
44
|
-
const rv = rt.serveRendezvous((text: string) => pi.sendUserMessage(text, { deliverAs: "steer" })
|
|
47
|
+
const rv = rt.serveRendezvous((text: string) => pi.sendUserMessage(text, { deliverAs: "steer" }), {
|
|
48
|
+
interrupt: () => {
|
|
49
|
+
if (!turn || turn.isIdle()) throw new Error("no pi turn is running - nothing to interrupt")
|
|
50
|
+
turn.abort()
|
|
51
|
+
},
|
|
52
|
+
})
|
|
45
53
|
|
|
46
54
|
pi.on("session_start", async (_event, ctx) => {
|
|
47
55
|
sessionId = ctx.sessionManager.getSessionId() || sessionId
|
|
48
56
|
process.env.PI_SESSION_ID = sessionId // tool subprocesses inherit it → spex CLI knows its session
|
|
49
57
|
await rt.dispatchEvent("SessionStart")
|
|
50
58
|
})
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
// agent_start is the earliest event a running turn hands us — before the first model token — so an
|
|
60
|
+
// interrupt that arrives while the model is still thinking finds a turn to abort rather than nothing.
|
|
61
|
+
pi.on("agent_start", async (_event, ctx) => { turn = ctx })
|
|
62
|
+
pi.on("turn_start", async (_event, ctx) => { turn = ctx })
|
|
63
|
+
pi.on("input", async (event, ctx) => { turn = ctx; await rt.dispatchEvent("UserPromptSubmit", { prompt: event.text }) })
|
|
64
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
65
|
+
turn = ctx
|
|
53
66
|
const r = await rt.dispatchEvent("PreToolUse", toolFields(event.toolName, event.input))
|
|
54
67
|
if (rt.blocked(r)) return { block: true, reason: rt.blockReason(r, "blocked by a SpexCode hook") }
|
|
55
68
|
})
|
|
56
|
-
pi.on("tool_result", async (event) => { await rt.dispatchEvent("PostToolUse", toolFields(event.toolName, event.input)) })
|
|
69
|
+
pi.on("tool_result", async (event, ctx) => { turn = ctx; await rt.dispatchEvent("PostToolUse", toolFields(event.toolName, event.input)) })
|
|
57
70
|
// Stop rides the runtime's dispatchStop on TWO bindings that never duplicate: agent_end performs the
|
|
58
71
|
// NORMAL dispatch (pi awaits its listeners inside the run loop and drains a queued teach as the SAME
|
|
59
72
|
// awaited prompt's continuation — never the orphaned settle-time prompt whose late inject threw
|
|
@@ -69,7 +82,7 @@ export default function spexcode(pi: ExtensionAPI) {
|
|
|
69
82
|
async (reason: string) => pi.sendUserMessage(reason, { deliverAs: "steer" }),
|
|
70
83
|
"a SpexCode Stop hook blocked this stop without giving a reason",
|
|
71
84
|
)
|
|
72
|
-
pi.on("agent_end", async () => { await stop() })
|
|
85
|
+
pi.on("agent_end", async () => { turn = null; await stop() })
|
|
73
86
|
pi.on("agent_settled", async () => { if (rt.stopPending()) await stop() })
|
|
74
87
|
pi.on("session_shutdown", async () => { rv?.close() })
|
|
75
88
|
}
|
|
@@ -3,6 +3,7 @@ import type { DispatchResult, HarnessDeliveryRecord } from './harness.js';
|
|
|
3
3
|
export declare const piHeadlessSock: (id: string) => string;
|
|
4
4
|
export declare function piHeadlessLaunchCommand(id: string, runtimeDir: string, piCmd: string): string;
|
|
5
5
|
export declare const deliverViaPiHeadless: (rec: HarnessDeliveryRecord, text: string) => Promise<DispatchResult>;
|
|
6
|
+
export declare const interruptPiHeadless: (rec: HarnessDeliveryRecord) => Promise<DispatchResult>;
|
|
6
7
|
export declare function piHeadlessColdRuntime(rec: Pick<HarnessDeliveryRecord, 'session'>): Promise<DispatchResult>;
|
|
7
8
|
export declare class PiHeadlessController {
|
|
8
9
|
private readonly id;
|
|
@@ -19,6 +20,7 @@ export declare class PiHeadlessController {
|
|
|
19
20
|
private terminateTurn;
|
|
20
21
|
private waitForExit;
|
|
21
22
|
private accept;
|
|
23
|
+
private interrupt;
|
|
22
24
|
private handle;
|
|
23
25
|
private spawnTurn;
|
|
24
26
|
}
|
|
@@ -12,6 +12,7 @@ const CONTROL_TIMEOUT_MS = 30_000;
|
|
|
12
12
|
const START_TIMEOUT_MS = 30_000;
|
|
13
13
|
const TERM_EXIT_GRACE_MS = 500;
|
|
14
14
|
const KILL_EXIT_GRACE_MS = 2_000;
|
|
15
|
+
const INTERRUPT_EXIT_GRACE_MS = 15_000;
|
|
15
16
|
/** The resident controller socket is distinct from pi's per-turn rendezvous socket. */
|
|
16
17
|
export const piHeadlessSock = (id) => join(tmpdir(), `spexcode-ph-${id}.sock`);
|
|
17
18
|
export function piHeadlessLaunchCommand(id, runtimeDir, piCmd) {
|
|
@@ -21,6 +22,13 @@ export const deliverViaPiHeadless = (rec, text) => controlRequest(piHeadlessSock
|
|
|
21
22
|
name: 'pi-headless', session: rec.session, timeoutMs: CONTROL_TIMEOUT_MS,
|
|
22
23
|
rejected: 'pi-headless controller rejected the request',
|
|
23
24
|
});
|
|
25
|
+
// The controller owns the turn child, so it is the one actor that can both abort the turn natively and
|
|
26
|
+
// know when that turn is actually over — an interrupt confirmed here means no pi process serves this
|
|
27
|
+
// session any more, and the next delivery is a clean cold wake rather than a poke into an exiting agent.
|
|
28
|
+
export const interruptPiHeadless = (rec) => controlRequest(piHeadlessSock(rec.session), { type: 'interrupt' }, {
|
|
29
|
+
name: 'pi-headless', session: rec.session, timeoutMs: CONTROL_TIMEOUT_MS,
|
|
30
|
+
rejected: 'pi-headless controller rejected the interrupt',
|
|
31
|
+
});
|
|
24
32
|
// The resident controller and a running pi turn own two per-session listeners. The generic lifecycle
|
|
25
33
|
// teardown has already proved and removed the exact controller leaf before this runs; cold filing is valid
|
|
26
34
|
// only once neither listener can still accept work for this session.
|
|
@@ -137,7 +145,25 @@ export class PiHeadlessController {
|
|
|
137
145
|
});
|
|
138
146
|
});
|
|
139
147
|
}
|
|
148
|
+
// Abort the running turn through pi's own ctx.abort() (the shim answers over the child's rendezvous socket,
|
|
149
|
+
// so pi records the turn as aborted and the conversation stays resumable), then wait for that child to
|
|
150
|
+
// exit. A turn whose extension holds no context yet — pi still booting, before its first agent event —
|
|
151
|
+
// is still THIS controller's process, so it is terminated as the owner rather than left to run.
|
|
152
|
+
async interrupt() {
|
|
153
|
+
const turn = this.child;
|
|
154
|
+
if (!turn || turn.process.exitCode !== null)
|
|
155
|
+
return { ok: false, error: `no pi-headless turn is running for session ${this.id} - nothing to interrupt` };
|
|
156
|
+
const { interruptViaRendezvous } = await import('./harness.js');
|
|
157
|
+
const aborted = await interruptViaRendezvous(this.id, 'pi-headless');
|
|
158
|
+
if (!aborted.ok && !/no pi turn is running|nothing to interrupt/.test(aborted.error || ''))
|
|
159
|
+
return aborted;
|
|
160
|
+
if (!aborted.ok || !await this.waitForExit(turn, INTERRUPT_EXIT_GRACE_MS))
|
|
161
|
+
await this.terminateTurn(turn);
|
|
162
|
+
return { ok: true };
|
|
163
|
+
}
|
|
140
164
|
async handle(request) {
|
|
165
|
+
if (request.type === 'interrupt')
|
|
166
|
+
return this.interrupt();
|
|
141
167
|
if (request.type !== 'deliver')
|
|
142
168
|
return { ok: false, error: 'unknown pi-headless control request' };
|
|
143
169
|
if (!request.text)
|
|
@@ -171,6 +197,7 @@ export class PiHeadlessController {
|
|
|
171
197
|
if (this.child === turn)
|
|
172
198
|
this.child = null;
|
|
173
199
|
resolveExit(code);
|
|
200
|
+
// an aborted child leaves non-zero too; the session layer's interrupt marker reads that exit as the interrupt it was
|
|
174
201
|
if (code !== 0 && !this.closing)
|
|
175
202
|
void import('./harness.js').then(({ reportHeadlessTurnExit }) => reportHeadlessTurnExit(this.id, 'pi-headless', code, this.cwd));
|
|
176
203
|
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
import { type Harness } from './harness.js';
|
|
3
|
+
type Target = {
|
|
4
|
+
ok: true;
|
|
5
|
+
harness: Harness;
|
|
6
|
+
threadId: string;
|
|
7
|
+
} | {
|
|
8
|
+
ok: false;
|
|
9
|
+
status: 404 | 409 | 500;
|
|
10
|
+
error: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function resolveTranscriptTarget(id: string): Target;
|
|
13
|
+
export declare function readSessionTranscript(c: Context): Promise<(Response & import("hono").TypedResponse<{
|
|
14
|
+
error: string;
|
|
15
|
+
}, 400, "json">) | (Response & import("hono").TypedResponse<{
|
|
16
|
+
error: string;
|
|
17
|
+
}, 500 | 404 | 409, "json">) | (Response & import("hono").TypedResponse<{
|
|
18
|
+
readonly revision: string;
|
|
19
|
+
readonly from: number;
|
|
20
|
+
readonly to: number;
|
|
21
|
+
readonly turns: readonly {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly at: number;
|
|
24
|
+
readonly role: "user" | "assistant";
|
|
25
|
+
readonly text?: string | undefined;
|
|
26
|
+
readonly tools?: readonly {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly input?: string | undefined;
|
|
30
|
+
readonly output?: string | undefined;
|
|
31
|
+
readonly outputLines: number;
|
|
32
|
+
readonly outputBytes: number;
|
|
33
|
+
}[] | undefined;
|
|
34
|
+
}[];
|
|
35
|
+
readonly truncated: boolean;
|
|
36
|
+
readonly omittedTurns: number;
|
|
37
|
+
readonly omittedBytes: number;
|
|
38
|
+
readonly outOfOrderEvents: number;
|
|
39
|
+
}, import("hono/utils/http-status").ContentfulStatusCode, "json">) | (Response & import("hono").TypedResponse<{
|
|
40
|
+
error: string;
|
|
41
|
+
reason: string;
|
|
42
|
+
}, 409 | 422 | 501, "json">)>;
|
|
43
|
+
export declare function readSessionTranscriptTool(c: Context): Promise<(Response & import("hono").TypedResponse<{
|
|
44
|
+
error: string;
|
|
45
|
+
reason: string;
|
|
46
|
+
}, 409 | 422 | 501, "json">) | (Response & import("hono").TypedResponse<{
|
|
47
|
+
error: string;
|
|
48
|
+
}, 400, "json">) | (Response & import("hono").TypedResponse<{
|
|
49
|
+
error: string;
|
|
50
|
+
}, 500 | 404 | 409, "json">) | (Response & import("hono").TypedResponse<{
|
|
51
|
+
id: string;
|
|
52
|
+
output: string | null;
|
|
53
|
+
outputLines: number;
|
|
54
|
+
outputBytes: number;
|
|
55
|
+
}, import("hono/utils/http-status").ContentfulStatusCode, "json">)>;
|
|
56
|
+
export declare function sessionTranscriptStream(c: Context): Promise<Response>;
|
|
57
|
+
export {};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { streamSSE } from 'hono/streaming';
|
|
2
|
+
import { readAliasedRawRecord } from '@spexcode/spec-core';
|
|
3
|
+
import { defaultHarness, harnessById } from './harness.js';
|
|
4
|
+
import { TranscriptReadError, openFrameStream } from '@spexcode/transcript';
|
|
5
|
+
export function resolveTranscriptTarget(id) {
|
|
6
|
+
let raw;
|
|
7
|
+
try {
|
|
8
|
+
raw = readAliasedRawRecord(id);
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
return { ok: false, status: 500, error: `session ${id} record is unreadable: ${error instanceof Error ? error.message : String(error)}` };
|
|
12
|
+
}
|
|
13
|
+
if (!raw || !raw.governed)
|
|
14
|
+
return { ok: false, status: 404, error: `session ${id} does not exist` };
|
|
15
|
+
let harness;
|
|
16
|
+
try {
|
|
17
|
+
harness = harnessById(typeof raw.harness === 'string' && raw.harness ? raw.harness : defaultHarness.id);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
return { ok: false, status: 500, error: error instanceof Error ? error.message : String(error) };
|
|
21
|
+
}
|
|
22
|
+
const threadId = harness.exactNativeTargetId({
|
|
23
|
+
session: raw.session_id,
|
|
24
|
+
harnessSessionId: typeof raw.harness_session_id === 'string' ? raw.harness_session_id : null,
|
|
25
|
+
stopped: !!raw.stopped,
|
|
26
|
+
archived: !!raw.archived,
|
|
27
|
+
});
|
|
28
|
+
if (!threadId)
|
|
29
|
+
return { ok: false, status: 409, error: `session ${id} transcript is unavailable: native harness identity is missing` };
|
|
30
|
+
return { ok: true, harness, threadId };
|
|
31
|
+
}
|
|
32
|
+
const epoch = (value) => {
|
|
33
|
+
if (value == null || value === '')
|
|
34
|
+
return null;
|
|
35
|
+
const number = Number(value);
|
|
36
|
+
return Number.isFinite(number) && Number.isInteger(number) ? number : null;
|
|
37
|
+
};
|
|
38
|
+
const failure = (error) => {
|
|
39
|
+
if (!(error instanceof TranscriptReadError))
|
|
40
|
+
throw error;
|
|
41
|
+
return { body: { error: error.message, reason: error.reason }, status: error.reason === 'unsupported' ? 501 : error.reason === 'invalid' ? 422 : 409 };
|
|
42
|
+
};
|
|
43
|
+
// GET /api/sessions/:id/transcript?from=<ms>&to=<ms> — a closed interval, both bounds explicit so the route
|
|
44
|
+
// never guesses which stretch the caller meant.
|
|
45
|
+
export async function readSessionTranscript(c) {
|
|
46
|
+
const id = c.req.param('id') || '';
|
|
47
|
+
const fromRaw = c.req.query('from');
|
|
48
|
+
const toRaw = c.req.query('to');
|
|
49
|
+
if (fromRaw == null || toRaw == null || fromRaw === '' || toRaw === '')
|
|
50
|
+
return c.json({ error: 'transcript needs both from and to epoch milliseconds' }, 400);
|
|
51
|
+
const from = epoch(fromRaw);
|
|
52
|
+
const to = epoch(toRaw);
|
|
53
|
+
if (from === null || to === null || from >= to)
|
|
54
|
+
return c.json({ error: 'transcript interval is invalid: from and to must be integer epoch milliseconds with from < to' }, 400);
|
|
55
|
+
const target = resolveTranscriptTarget(id);
|
|
56
|
+
if (!target.ok)
|
|
57
|
+
return c.json({ error: target.error }, target.status);
|
|
58
|
+
try {
|
|
59
|
+
return c.json(await target.harness.transcript.read(target.threadId, { from, to }));
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
const { body, status } = failure(error);
|
|
63
|
+
return c.json(body, status);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// GET /api/sessions/:id/transcript/tool/:toolId?from=<ms> — one call's recorded output, read when a person
|
|
67
|
+
// opens it. The live stream withholds output bodies (below), so this is where a body comes from; the interval
|
|
68
|
+
// is `[from, now]`, the same stretch the stream reads, and a call outside it is a 404.
|
|
69
|
+
export async function readSessionTranscriptTool(c) {
|
|
70
|
+
const id = c.req.param('id') || '';
|
|
71
|
+
const toolId = c.req.param('toolId') || '';
|
|
72
|
+
const from = epoch(c.req.query('from'));
|
|
73
|
+
if (from === null || !toolId)
|
|
74
|
+
return c.json({ error: 'transcript tool needs a tool id and from as integer epoch milliseconds' }, 400);
|
|
75
|
+
const target = resolveTranscriptTarget(id);
|
|
76
|
+
if (!target.ok)
|
|
77
|
+
return c.json({ error: target.error }, target.status);
|
|
78
|
+
try {
|
|
79
|
+
const read = await target.harness.transcript.read(target.threadId, { from, to: Math.max(from + 1, Date.now()) });
|
|
80
|
+
for (const turn of read.turns)
|
|
81
|
+
for (const tool of turn.tools ?? []) {
|
|
82
|
+
if (tool.id !== toolId)
|
|
83
|
+
continue;
|
|
84
|
+
return c.json({ id: tool.id, output: tool.output ?? null, outputLines: tool.outputLines, outputBytes: tool.outputBytes });
|
|
85
|
+
}
|
|
86
|
+
return c.json({ error: `tool ${toolId} is not in this interval` }, 404);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
const { body, status } = failure(error);
|
|
90
|
+
return c.json(body, status);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const TICK_MS = 500;
|
|
94
|
+
const HEARTBEAT_TICKS = 20;
|
|
95
|
+
// GET /api/sessions/:id/transcript/stream?from=<ms> — the open interval [from, now], carried over SSE. What a
|
|
96
|
+
// frame holds, when one is worth sending, and how a subscriber merges it are [[transcript-frames]]'
|
|
97
|
+
// `openFrameStream`, shared with every other transport; this route owns only the carriage: a tick that asks
|
|
98
|
+
// the stream to publish, one `transcript` SSE event per frame it yields, and periodic `ping` heartbeats so the
|
|
99
|
+
// browser's dead-man can reopen a silent stream. Reading happens only while a client is subscribed and stops
|
|
100
|
+
// on abort, closing the cursor.
|
|
101
|
+
export async function sessionTranscriptStream(c) {
|
|
102
|
+
const id = c.req.param('id') || '';
|
|
103
|
+
const from = epoch(c.req.query('from'));
|
|
104
|
+
if (from === null)
|
|
105
|
+
return c.json({ error: 'transcript stream needs from as integer epoch milliseconds' }, 400);
|
|
106
|
+
const target = resolveTranscriptTarget(id);
|
|
107
|
+
if (!target.ok)
|
|
108
|
+
return c.json({ error: target.error }, target.status);
|
|
109
|
+
const frames = openFrameStream(target.harness.transcript, target.threadId, from);
|
|
110
|
+
return streamSSE(c, async (stream) => {
|
|
111
|
+
let aborted = false;
|
|
112
|
+
let ticks = 0;
|
|
113
|
+
const publish = async () => {
|
|
114
|
+
const frame = await frames.publish();
|
|
115
|
+
if (frame)
|
|
116
|
+
await stream.writeSSE({ event: 'transcript', data: JSON.stringify(frame) });
|
|
117
|
+
};
|
|
118
|
+
stream.onAbort(() => { aborted = true; frames.close(); });
|
|
119
|
+
try {
|
|
120
|
+
await publish();
|
|
121
|
+
while (!aborted) {
|
|
122
|
+
await stream.sleep(TICK_MS);
|
|
123
|
+
if (aborted)
|
|
124
|
+
break;
|
|
125
|
+
await publish();
|
|
126
|
+
if (++ticks % HEARTBEAT_TICKS === 0)
|
|
127
|
+
await stream.writeSSE({ event: 'ping', data: 'x' });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// EventSource reconnects a dropped stream; a native read must never take the session server down.
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
frames.close();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
@@ -342,6 +342,10 @@ export declare function markState(status: Lifecycle, opts?: {
|
|
|
342
342
|
export declare function markHumanPromptActive(sessionId: string): boolean;
|
|
343
343
|
export declare const markDone: (proposal?: Proposal, sessionId?: string, note?: string) => boolean;
|
|
344
344
|
export declare function markTurnFailure(sessionId: string | undefined, note: string): boolean;
|
|
345
|
+
export declare const INTERRUPTED_NOTE = "interrupted: the human stopped this turn; the next message continues the conversation";
|
|
346
|
+
export declare function stampInterrupt(id: string): void;
|
|
347
|
+
export declare function clearInterruptMarker(id: string): void;
|
|
348
|
+
export declare const markInterrupted: (sessionId: string) => boolean;
|
|
345
349
|
export declare function markHeadlessTurnFailure(sessionId: string, harness: string, exitCode: string): boolean;
|
|
346
350
|
export declare function existingHarnessLaunchTarget(id: string): string | null;
|
|
347
351
|
export declare function stageHarnessLaunchProof(sessionId: string | undefined, harnessSessionId: string | undefined, launchPayload: string): boolean;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFile, execFileSync, spawn } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
-
import { readFileSync, writeFileSync, existsSync, renameSync, linkSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, renameSync, linkSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync, unlinkSync } from 'node:fs';
|
|
5
5
|
import { join, dirname, isAbsolute, resolve, sep } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { rm as rmAsync, readdir as readdirAsync } from 'node:fs/promises';
|
|
@@ -1458,7 +1458,7 @@ export function withPeerSenderHint(text, sender, sshAddress, machineId) {
|
|
|
1458
1458
|
const who = sender.label && sender.label !== sender.id ? `session "${sender.label}" (${sender.id})` : `session ${sender.id}`;
|
|
1459
1459
|
return `${text}\n\n— from ${who} on machine ${machineId}. To reply: spex session send --ssh ${sshAddress} ${sender.id} "<your reply>"`;
|
|
1460
1460
|
}
|
|
1461
|
-
export const withNoteReplyHint = (text) => `${text}\n\n—
|
|
1461
|
+
export const withNoteReplyHint = (text) => `${text}\n\n— REPLY TRANSPORT: This sender cannot read normal assistant output. Before ending this turn, make your FINAL tool call a Spex declaration carrying the COMPLETE reply in --note: use \`session ask\` when waiting for a human reply; use \`done\` or \`park\` when that is the truthful state. This rule applies even when asked to only print/reply or make no tool calls.\n\nFor multi-line replies, preserve real LF characters. \`functions.exec\` runs a shell command through bash, so never interpolate \`JSON.stringify(note)\` into it; use stdin, a heredoc, or base64, then pass \`--note \"$note\"\`. Never use \`String.raw\` or literal backslash+n. Do not call any tool after the declaration.`;
|
|
1462
1462
|
export const withTerminalReplyHint = (text) => `${text}\n\n— sent from a terminal-attached client: the sender now reads your terminal output directly. Reply in your normal conversation output from here on — stop putting replies in declaration --notes (the earlier terminal-free notices no longer apply; a --note can go back to being a short status line).`;
|
|
1463
1463
|
export const slugify = (s) => (s || 'session').normalize('NFC').replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '') || 'session';
|
|
1464
1464
|
const MENTION = /\[\[(\.?[\p{L}\p{N}_-]+)\]\]/u;
|
|
@@ -1880,7 +1880,7 @@ function observeQueuedLaunchReadiness(id, harness, timeoutMs = SOCKET_READY_TIME
|
|
|
1880
1880
|
return;
|
|
1881
1881
|
if (current.status === 'queued') {
|
|
1882
1882
|
publishCanonicalLifecycle(current, 'active', null, null);
|
|
1883
|
-
writeRecord({ ...current, status: 'active', proposal: null, note: null, launchOwner: null, launchReadinessStartedAt: null });
|
|
1883
|
+
writeRecord({ ...current, status: 'active', proposal: null, note: null, stopped: false, launchOwner: null, launchReadinessStartedAt: null });
|
|
1884
1884
|
}
|
|
1885
1885
|
else if (current.launchReadinessStartedAt != null)
|
|
1886
1886
|
writeRecord({ ...current, launchReadinessStartedAt: null });
|
|
@@ -1991,7 +1991,7 @@ async function startQueuedUnlocked(id) {
|
|
|
1991
1991
|
// headline precedence stands on.
|
|
1992
1992
|
const launched = readRecord(id) || wt.rec;
|
|
1993
1993
|
publishCanonicalLifecycle(launched, 'active', null, null);
|
|
1994
|
-
writeRecord({ ...launched, status: 'active', proposal: null, note: null, launchOwner: null, launchReadinessStartedAt: readinessStartedAt });
|
|
1994
|
+
writeRecord({ ...launched, status: 'active', proposal: null, note: null, stopped: false, launchOwner: null, launchReadinessStartedAt: readinessStartedAt });
|
|
1995
1995
|
if (!h.launchPayloadProof)
|
|
1996
1996
|
removeLaunchFile(id);
|
|
1997
1997
|
// release the boot-window hold once the socket is up (then isOccupying takes over) or after the bounded
|
|
@@ -2988,7 +2988,7 @@ async function prepareSession(prompt, parent, launcher, name, context) {
|
|
|
2988
2988
|
try {
|
|
2989
2989
|
gitMutationStarted = true;
|
|
2990
2990
|
traceSessionCreate(id, requestDigest, phase, 'start', 'worktree-add');
|
|
2991
|
-
const added = await withGitAbortSignal(signal, () => gitTry(['-C', root, 'worktree', 'add', '-b', branch, path, startPoint], { extraEnv: DEFER_FOOTPRINT_REFRESH }));
|
|
2991
|
+
const added = await withGitAbortSignal(signal, () => gitTry(['-C', root, 'worktree', 'add', '--no-track', '-b', branch, path, startPoint], { extraEnv: DEFER_FOOTPRINT_REFRESH }));
|
|
2992
2992
|
traceSessionCreate(id, requestDigest, phase, 'finish', 'worktree-add');
|
|
2993
2993
|
if (added.ok)
|
|
2994
2994
|
Object.assign(owned, { path: true, worktree: true, branch: true });
|
|
@@ -3129,26 +3129,23 @@ async function waitForReady(id, harness, pending, timeoutMs = SOCKET_READY_TIMEO
|
|
|
3129
3129
|
if (harness.launchPayloadProof && !current()?.harnessSessionId) {
|
|
3130
3130
|
for (;;) {
|
|
3131
3131
|
if (hasReadableLaunchReceipt(id)) {
|
|
3132
|
-
|
|
3132
|
+
// The probe above ran outside the fence, so another consumer (the drain, a resume recovery) may have
|
|
3133
|
+
// taken the receipt first. Under the fence a receipt that is already gone with the identity bound is
|
|
3134
|
+
// that consumer's success, not a missing receipt; gone and unbound means keep waiting.
|
|
3135
|
+
const consume = () => {
|
|
3133
3136
|
readinessWakeSuppressed.add(id);
|
|
3134
3137
|
try {
|
|
3138
|
+
if (!hasReadableLaunchReceipt(id))
|
|
3139
|
+
return !!readRecord(id)?.harnessSessionId;
|
|
3135
3140
|
consumeHarnessLaunchProofUnlocked(id);
|
|
3141
|
+
return true;
|
|
3136
3142
|
}
|
|
3137
3143
|
finally {
|
|
3138
3144
|
readinessWakeSuppressed.delete(id);
|
|
3139
3145
|
}
|
|
3140
|
-
}
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
readinessWakeSuppressed.add(id);
|
|
3144
|
-
try {
|
|
3145
|
-
consumeHarnessLaunchProofUnlocked(id);
|
|
3146
|
-
}
|
|
3147
|
-
finally {
|
|
3148
|
-
readinessWakeSuppressed.delete(id);
|
|
3149
|
-
}
|
|
3150
|
-
});
|
|
3151
|
-
break;
|
|
3146
|
+
};
|
|
3147
|
+
if (recordLockHeld ? consume() : await withRecordLock(id, async () => consume()))
|
|
3148
|
+
break;
|
|
3152
3149
|
}
|
|
3153
3150
|
if (Date.now() >= deadline)
|
|
3154
3151
|
return null;
|
|
@@ -3484,9 +3481,57 @@ export function markTurnFailure(sessionId, note) {
|
|
|
3484
3481
|
return true;
|
|
3485
3482
|
});
|
|
3486
3483
|
}
|
|
3484
|
+
// @@@ interrupt projection - a CONFIRMED human interrupt ended the turn, and the record says so the way an
|
|
3485
|
+
// undeclared stop does: `asking`, with the reason, because the agent now waits for the human's next message.
|
|
3486
|
+
// Active-only like every other turn-outcome writer: a declaration that landed first (the agent answered
|
|
3487
|
+
// before the abort reached it) stays authoritative. The marker is stamped before the abort is sent so the
|
|
3488
|
+
// adapter's own exit report — a one-turn process leaves with a non-zero code when aborted — reads the same
|
|
3489
|
+
// outcome instead of filing a failed turn; it expires so a genuine failure later is never mistaken for it.
|
|
3490
|
+
export const INTERRUPTED_NOTE = 'interrupted: the human stopped this turn; the next message continues the conversation';
|
|
3491
|
+
const INTERRUPT_MARKER_TTL_MS = 15_000;
|
|
3492
|
+
const interruptMarkerPath = (id) => sessionArtifactPath(id, 'turn.interrupted');
|
|
3493
|
+
export function stampInterrupt(id) {
|
|
3494
|
+
mkdirSync(storeDir(id), { recursive: true });
|
|
3495
|
+
writeFileSync(interruptMarkerPath(id), String(Date.now()));
|
|
3496
|
+
}
|
|
3497
|
+
export function clearInterruptMarker(id) {
|
|
3498
|
+
try {
|
|
3499
|
+
unlinkSync(interruptMarkerPath(id));
|
|
3500
|
+
}
|
|
3501
|
+
catch { /* never stamped, or already consumed */ }
|
|
3502
|
+
}
|
|
3503
|
+
function consumeInterruptMarker(id) {
|
|
3504
|
+
let at = NaN;
|
|
3505
|
+
try {
|
|
3506
|
+
at = Number(readFileSync(interruptMarkerPath(id), 'utf8'));
|
|
3507
|
+
}
|
|
3508
|
+
catch {
|
|
3509
|
+
return false;
|
|
3510
|
+
}
|
|
3511
|
+
clearInterruptMarker(id);
|
|
3512
|
+
return Number.isFinite(at) && Date.now() - at <= INTERRUPT_MARKER_TTL_MS;
|
|
3513
|
+
}
|
|
3514
|
+
function projectInterruptedUnlocked(sessionId) {
|
|
3515
|
+
const rec = readLiveRecord(sessionId);
|
|
3516
|
+
if (!rec?.governed || rec.status !== 'active' || rec.stopped || rec.archived)
|
|
3517
|
+
return false;
|
|
3518
|
+
const application = configuredSessionApplicationIfCutover();
|
|
3519
|
+
if (application) {
|
|
3520
|
+
application.transitionSession(sessionId, {
|
|
3521
|
+
status: 'asking', proposal: null, note: INTERRUPTED_NOTE,
|
|
3522
|
+
recipientSessionIds: canonicalWatchRecipients(application, sessionId, 'asking'),
|
|
3523
|
+
});
|
|
3524
|
+
return true;
|
|
3525
|
+
}
|
|
3526
|
+
writeRecord({ ...rec, status: 'asking', proposal: null, note: INTERRUPTED_NOTE });
|
|
3527
|
+
return true;
|
|
3528
|
+
}
|
|
3529
|
+
export const markInterrupted = (sessionId) => withRecordLockSync(sessionId, () => projectInterruptedUnlocked(sessionId));
|
|
3487
3530
|
export function markHeadlessTurnFailure(sessionId, harness, exitCode) {
|
|
3488
3531
|
if (exitCode === '0')
|
|
3489
3532
|
return false;
|
|
3533
|
+
if (consumeInterruptMarker(sessionId))
|
|
3534
|
+
return markInterrupted(sessionId);
|
|
3490
3535
|
const outcome = /^\d+$/.test(exitCode) ? `exit code ${exitCode}` : `signal ${exitCode}`;
|
|
3491
3536
|
return markTurnFailure(sessionId, `${harness} turn exited with ${outcome}`);
|
|
3492
3537
|
}
|
|
@@ -5296,17 +5341,38 @@ export function canonicalMessageText(message, recipient) {
|
|
|
5296
5341
|
}
|
|
5297
5342
|
throw new ResourceConflict(`canonical message kind ${message.kind} cannot be delivered as session text`);
|
|
5298
5343
|
}
|
|
5299
|
-
// Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness
|
|
5300
|
-
//
|
|
5344
|
+
// Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness with a native
|
|
5345
|
+
// primitive uses it. Without one the transport decides: a HEADLESS adapter has no keyboard, so it refuses
|
|
5346
|
+
// loudly rather than emulating an interrupt with a signal that could hit the wrong process; a PANE-BACKED
|
|
5347
|
+
// TUI has an operator's keyboard by definition, so its interrupt is the key that operator would press —
|
|
5348
|
+
// C-c into its own pane, through the raw-key channel below — and only while its lifecycle is actually
|
|
5349
|
+
// active, because the same key on an idle TUI is a second Ctrl-C away from quitting it.
|
|
5301
5350
|
export async function interruptSession(id) {
|
|
5351
|
+
// The lifecycle read and the key send share ONE record lock: a declaration that lands between them would
|
|
5352
|
+
// otherwise turn "interrupt the working turn" into "Ctrl-C an idle TUI", so there is no window.
|
|
5302
5353
|
return withRecordLock(id, async () => {
|
|
5303
5354
|
const rec = readRecord(id);
|
|
5304
5355
|
if (!rec)
|
|
5305
5356
|
return { ok: false, error: `no session record for ${id} - nothing to interrupt` };
|
|
5306
5357
|
const h = harnessById(rec.harness || defaultHarness.id);
|
|
5307
|
-
if (
|
|
5358
|
+
if (h.interrupt) {
|
|
5359
|
+
// stamped BEFORE the abort: the adapter's exit report can race the confirmation, and either order must
|
|
5360
|
+
// read "interrupted" (see the interrupt projection); a refused interrupt leaves no trace behind.
|
|
5361
|
+
stampInterrupt(id);
|
|
5362
|
+
const result = await h.interrupt({ ...rec, runtimeDir: runtimeRoot() });
|
|
5363
|
+
if (!result.ok) {
|
|
5364
|
+
clearInterruptMarker(id);
|
|
5365
|
+
return result;
|
|
5366
|
+
}
|
|
5367
|
+
projectInterruptedUnlocked(id);
|
|
5368
|
+
return result;
|
|
5369
|
+
}
|
|
5370
|
+
if (h.headless)
|
|
5308
5371
|
return { ok: false, error: `harness ${h.id} has no native hard-interrupt control` };
|
|
5309
|
-
|
|
5372
|
+
if (rec.status !== 'active')
|
|
5373
|
+
return { ok: false, error: `session ${id} is not working (lifecycle ${rec.status}) - nothing to interrupt` };
|
|
5374
|
+
const sent = await sendRawKeysLocked(id, ['C-c']);
|
|
5375
|
+
return sent ? { ok: true } : { ok: false, error: `session ${id} has no live pane to interrupt` };
|
|
5310
5376
|
});
|
|
5311
5377
|
}
|
|
5312
5378
|
// @@@ rawKey - the RAW-KEYSTROKE nav path, kept DELIBERATELY on `tmux send-keys` and NEVER the rendezvous
|
|
@@ -5367,21 +5433,23 @@ function rawKeyArgs(id, key) {
|
|
|
5367
5433
|
// ORDER, so they reach the pane in exactly the order they were struck. Concurrent per-key POSTs used to race
|
|
5368
5434
|
// (browser + server + send-keys all parallel) and scramble the sequence; a single serialised batch cannot.
|
|
5369
5435
|
// An unknown token is skipped without dropping the rest; false only if the tmux session is gone or nothing sent.
|
|
5436
|
+
// the send itself, for a caller that already holds the record lock (rawKey below; interruptSession above)
|
|
5437
|
+
async function sendRawKeysLocked(id, keys) {
|
|
5438
|
+
const list = keys.filter((k) => typeof k === 'string' && k.length > 0);
|
|
5439
|
+
if (list.length === 0 || !(await alive(id)))
|
|
5440
|
+
return false;
|
|
5441
|
+
let sent = false;
|
|
5442
|
+
for (const k of list) {
|
|
5443
|
+
const args = rawKeyArgs(id, k);
|
|
5444
|
+
if (!args)
|
|
5445
|
+
continue;
|
|
5446
|
+
await tmux(args);
|
|
5447
|
+
sent = true;
|
|
5448
|
+
}
|
|
5449
|
+
return sent;
|
|
5450
|
+
}
|
|
5370
5451
|
export async function rawKey(id, key) {
|
|
5371
|
-
const sent = await withRecordLock(id,
|
|
5372
|
-
const list = (Array.isArray(key) ? key : [key]).filter((k) => typeof k === 'string' && k.length > 0);
|
|
5373
|
-
if (list.length === 0 || !(await alive(id)))
|
|
5374
|
-
return false;
|
|
5375
|
-
let sent = false;
|
|
5376
|
-
for (const k of list) {
|
|
5377
|
-
const args = rawKeyArgs(id, k);
|
|
5378
|
-
if (!args)
|
|
5379
|
-
continue;
|
|
5380
|
-
await tmux(args);
|
|
5381
|
-
sent = true;
|
|
5382
|
-
}
|
|
5383
|
-
return sent;
|
|
5384
|
-
});
|
|
5452
|
+
const sent = await withRecordLock(id, () => sendRawKeysLocked(id, Array.isArray(key) ? key : [key]));
|
|
5385
5453
|
// Raw-key remote control is transport fallback, not a lifecycle event. Freshness belongs to the
|
|
5386
5454
|
// harness turn hooks or a successfully handed-over durable prompt; navigation keys cannot forge working.
|
|
5387
5455
|
return sent;
|
|
@@ -117,6 +117,8 @@ const spexShimRuntime = (cfg) => {
|
|
|
117
117
|
|
|
118
118
|
// The per-session rendezvous control socket is a best-effort same-turn poke. The timeline remains the
|
|
119
119
|
// message's durable copy, so this listener only receives reply lines and never confirms or rejects them.
|
|
120
|
+
// An "interrupt" line is the one CONFIRMED message: the host's native abort runs and the socket answers
|
|
121
|
+
// interrupt-done or interrupt-rejected, because an interrupt has no durable copy to fall back on.
|
|
120
122
|
const serveRendezvous = (inject, opts) => {
|
|
121
123
|
const sock = (process.env.CLAUDE_BG_RENDEZVOUS_SOCK || "").trim()
|
|
122
124
|
if (!sock) return null
|
|
@@ -125,6 +127,7 @@ const spexShimRuntime = (cfg) => {
|
|
|
125
127
|
const server = __spexCreateServer((c) => {
|
|
126
128
|
let buf = ""
|
|
127
129
|
c.on("error", () => { /* probes disconnect abruptly — expected */ })
|
|
130
|
+
const answer = (o) => { try { c.write(JSON.stringify(o) + "\\n") } catch { /* the asker left — nothing to confirm to */ } }
|
|
128
131
|
c.on("data", (d) => {
|
|
129
132
|
buf += d.toString("utf8")
|
|
130
133
|
let nl
|
|
@@ -133,6 +136,17 @@ const spexShimRuntime = (cfg) => {
|
|
|
133
136
|
buf = buf.slice(nl + 1)
|
|
134
137
|
let msg
|
|
135
138
|
try { msg = JSON.parse(line) } catch { continue }
|
|
139
|
+
if (msg && msg.type === "interrupt") {
|
|
140
|
+
const abort = opts && opts.interrupt
|
|
141
|
+
if (!abort) { answer({ type: "interrupt-rejected", error: "this " + HARNESS + " shim exposes no native abort" }); continue }
|
|
142
|
+
try {
|
|
143
|
+
Promise.resolve(abort()).then(
|
|
144
|
+
() => answer({ type: "interrupt-done" }),
|
|
145
|
+
(e) => answer({ type: "interrupt-rejected", error: String((e && e.message) || e) }),
|
|
146
|
+
)
|
|
147
|
+
} catch (e) { answer({ type: "interrupt-rejected", error: String((e && e.message) || e) }) }
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
136
150
|
if (msg && msg.type === "reply" && typeof msg.text === "string") {
|
|
137
151
|
if (opts && opts.canInject && !opts.canInject()) continue
|
|
138
152
|
const mid = typeof msg.mid === "string" ? msg.mid : ""
|
|
@@ -49,7 +49,7 @@ const childApiBase = `http://127.0.0.1:${proxyPort}`;
|
|
|
49
49
|
// observe the source closure, compile it, then make the supervisor's normal zero-downtime swap. A source edit
|
|
50
50
|
// must never cause a restart that still runs the old dist.
|
|
51
51
|
const watchRoots = workspaceRoot
|
|
52
|
-
? [sourceRoot, join(workspaceRoot, 'spec-forge', 'src'), join(workspaceRoot, 'spec-eval', 'src'), join(workspaceRoot, 'packages', 'spec-core', 'src')]
|
|
52
|
+
? [sourceRoot, join(workspaceRoot, 'spec-forge', 'src'), join(workspaceRoot, 'spec-eval', 'src'), join(workspaceRoot, 'packages', 'spec-core', 'src'), join(workspaceRoot, 'packages', 'transcript', 'src')]
|
|
53
53
|
: [here];
|
|
54
54
|
function buildWorkspace() {
|
|
55
55
|
if (!workspaceRoot)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spexcode/spec-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0-next.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SpexCode CLI + server. The root spexcode package delegates to this compiled package; dashboard assets live in @spexcode/spec-dashboard.",
|
|
6
6
|
"bin": {
|
|
@@ -35,11 +35,13 @@
|
|
|
35
35
|
"test": "tsx --import ../scripts/test-home.mjs --test src/*.test.ts"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@spexcode/session-application": "0.
|
|
39
|
-
"@spexcode/session-selflaunch": "0.
|
|
40
|
-
"@spexcode/spec-core": "0.
|
|
41
|
-
"@spexcode/spec-eval": "0.
|
|
42
|
-
"@spexcode/spec-forge": "0.
|
|
38
|
+
"@spexcode/session-application": "0.7.0-next.1",
|
|
39
|
+
"@spexcode/session-selflaunch": "0.7.0-next.1",
|
|
40
|
+
"@spexcode/spec-core": "0.7.0-next.1",
|
|
41
|
+
"@spexcode/spec-eval": "0.7.0-next.1",
|
|
42
|
+
"@spexcode/spec-forge": "0.7.0-next.1",
|
|
43
|
+
"@spexcode/transcript": "0.7.0-next.1",
|
|
44
|
+
"smol-toml": "^1.8.0"
|
|
43
45
|
},
|
|
44
46
|
"devDependencies": {
|
|
45
47
|
"@types/node": "^20.16.0",
|