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.
Files changed (72) hide show
  1. package/node_modules/@spexcode/session-application/package.json +5 -5
  2. package/node_modules/@spexcode/session-events/package.json +2 -2
  3. package/node_modules/@spexcode/session-protocol/package.json +1 -1
  4. package/node_modules/@spexcode/session-runtime/package.json +2 -2
  5. package/node_modules/@spexcode/session-selflaunch/dist/locality.d.ts +12 -1
  6. package/node_modules/@spexcode/session-selflaunch/dist/locality.js +62 -10
  7. package/node_modules/@spexcode/session-selflaunch/package.json +3 -3
  8. package/node_modules/@spexcode/session-topology/package.json +2 -2
  9. package/node_modules/@spexcode/spec-cli/bin/spex.mjs +127 -10
  10. package/node_modules/@spexcode/spec-cli/dist/cli.js +19 -0
  11. package/node_modules/@spexcode/spec-cli/dist/file-write.js +14 -2
  12. package/node_modules/@spexcode/spec-cli/dist/guide.js +9 -11
  13. package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +11 -5
  14. package/node_modules/@spexcode/spec-cli/dist/harness.js +185 -28
  15. package/node_modules/@spexcode/spec-cli/dist/host.js +5 -1
  16. package/node_modules/@spexcode/spec-cli/dist/index.js +21 -65
  17. package/node_modules/@spexcode/spec-cli/dist/machine-peer.d.ts +2 -1
  18. package/node_modules/@spexcode/spec-cli/dist/machine-peer.js +45 -14
  19. package/node_modules/@spexcode/spec-cli/dist/opencode.js +10 -2
  20. package/node_modules/@spexcode/spec-cli/dist/pi-harness.js +18 -5
  21. package/node_modules/@spexcode/spec-cli/dist/pi-headless.d.ts +2 -0
  22. package/node_modules/@spexcode/spec-cli/dist/pi-headless.js +27 -0
  23. package/node_modules/@spexcode/spec-cli/dist/session-transcript.d.ts +57 -0
  24. package/node_modules/@spexcode/spec-cli/dist/session-transcript.js +137 -0
  25. package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +4 -0
  26. package/node_modules/@spexcode/spec-cli/dist/sessions.js +104 -36
  27. package/node_modules/@spexcode/spec-cli/dist/shim-runtime.js +14 -0
  28. package/node_modules/@spexcode/spec-cli/dist/supervise.js +1 -1
  29. package/node_modules/@spexcode/spec-cli/package.json +8 -6
  30. package/node_modules/@spexcode/spec-core/dist/layout.d.ts +0 -1
  31. package/node_modules/@spexcode/spec-core/package.json +1 -1
  32. package/node_modules/@spexcode/spec-core/templates/spexcode.json +36 -13
  33. package/node_modules/@spexcode/spec-eval/package.json +2 -2
  34. package/node_modules/@spexcode/spec-forge/package.json +2 -2
  35. package/node_modules/@spexcode/transcript/dist/frames.d.ts +46 -0
  36. package/node_modules/@spexcode/transcript/dist/frames.js +87 -0
  37. package/node_modules/@spexcode/transcript/dist/index.d.ts +5 -0
  38. package/node_modules/@spexcode/transcript/dist/index.js +5 -0
  39. package/node_modules/@spexcode/transcript/dist/live.d.ts +16 -0
  40. package/node_modules/@spexcode/transcript/dist/live.js +65 -0
  41. package/node_modules/@spexcode/transcript/dist/parsers.d.ts +50 -0
  42. package/node_modules/@spexcode/transcript/dist/parsers.js +395 -0
  43. package/node_modules/@spexcode/transcript/dist/readers.d.ts +16 -0
  44. package/node_modules/@spexcode/transcript/dist/readers.js +375 -0
  45. package/node_modules/@spexcode/{spec-cli/dist/transcript-reader.d.ts → transcript/dist/turns.d.ts} +11 -5
  46. package/node_modules/@spexcode/transcript/dist/turns.js +11 -0
  47. package/node_modules/@spexcode/transcript/package.json +30 -0
  48. package/node_modules/smol-toml/LICENSE +24 -0
  49. package/node_modules/smol-toml/README.md +418 -0
  50. package/node_modules/smol-toml/dist/date.d.ts +41 -0
  51. package/node_modules/smol-toml/dist/date.js +127 -0
  52. package/node_modules/smol-toml/dist/error.d.ts +38 -0
  53. package/node_modules/smol-toml/dist/error.js +63 -0
  54. package/node_modules/smol-toml/dist/extract.js +69 -0
  55. package/node_modules/smol-toml/dist/index.cjs +734 -0
  56. package/node_modules/smol-toml/dist/index.d.ts +43 -0
  57. package/node_modules/smol-toml/dist/index.js +33 -0
  58. package/node_modules/smol-toml/dist/parse.d.ts +36 -0
  59. package/node_modules/smol-toml/dist/parse.js +149 -0
  60. package/node_modules/smol-toml/dist/primitive.js +238 -0
  61. package/node_modules/smol-toml/dist/stringify.d.ts +31 -0
  62. package/node_modules/smol-toml/dist/stringify.js +181 -0
  63. package/node_modules/smol-toml/dist/struct.js +179 -0
  64. package/node_modules/smol-toml/dist/util.d.ts +38 -0
  65. package/node_modules/smol-toml/dist/util.js +89 -0
  66. package/node_modules/smol-toml/package.json +68 -0
  67. package/package.json +3 -3
  68. package/node_modules/@spexcode/spec-cli/dist/execution-trace.d.ts +0 -27
  69. package/node_modules/@spexcode/spec-cli/dist/execution-trace.js +0 -475
  70. package/node_modules/@spexcode/spec-cli/dist/session-execution.d.ts +0 -10
  71. package/node_modules/@spexcode/spec-cli/dist/session-execution.js +0 -70
  72. 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
- pi.on("input", async (event) => { await rt.dispatchEvent("UserPromptSubmit", { prompt: event.text }) })
52
- pi.on("tool_call", async (event) => {
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— REQUIRED REPLY TRANSPORT (PER-MESSAGE): this terminal-free sender CANNOT see normal assistant/final output. Do not stop after only printing the answer. As your FINAL action, put your COMPLETE reply to this message in the truthful declaration's --note. For an answered exploratory question or simple answer awaiting the sender's follow-up, run \`spex session ask --note "<complete reply>"\`; if the true state is done or parked, put the same complete reply in that declaration's --note instead. This declaration command is reply transport, not part of the requested work, and remains REQUIRED even when the message says to use no tools, make no tool calls, or only print/reply. A later message arriving WITHOUT this notice means the sender is back at a terminal and reads your normal output again.`;
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
- if (recordLockHeld) {
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
- else
3142
- await withRecordLock(id, async () => {
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 without a
5300
- // confirmed native primitive refuses loudly; there is no signal/PTY fallback that could target the wrong turn.
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 (!h.interrupt)
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
- return h.interrupt({ ...rec, runtimeDir: runtimeRoot() });
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, async () => {
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.6.8",
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.6.8",
39
- "@spexcode/session-selflaunch": "0.6.8",
40
- "@spexcode/spec-core": "0.6.8",
41
- "@spexcode/spec-eval": "0.6.8",
42
- "@spexcode/spec-forge": "0.6.8"
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",
@@ -14,7 +14,6 @@ export type Config = {
14
14
  apiUrl?: string;
15
15
  title?: string;
16
16
  icon?: string;
17
- showHeadlessLaunchers?: boolean;
18
17
  };
19
18
  uploads?: {
20
19
  maxBytes?: number;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/spec-core",
3
- "version": "0.6.8",
3
+ "version": "0.7.0-next.1",
4
4
  "type": "module",
5
5
  "description": "SpexCode's dependency-minimal spec graph core.",
6
6
  "files": [