spexcode 0.6.7 → 0.7.0-next.0
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/README.md +12 -7
- package/node_modules/@spexcode/session-application/dist/index.d.ts +17 -0
- package/node_modules/@spexcode/session-application/dist/index.js +35 -0
- package/node_modules/@spexcode/session-application/dist/migration.d.ts +51 -0
- package/node_modules/@spexcode/session-application/dist/migration.js +694 -0
- package/node_modules/@spexcode/session-application/dist/production.d.ts +109 -0
- package/node_modules/@spexcode/session-application/dist/production.js +438 -0
- package/node_modules/@spexcode/session-application/dist/schema.d.ts +2 -0
- package/node_modules/@spexcode/session-application/dist/schema.js +39 -0
- package/node_modules/@spexcode/session-application/package.json +29 -0
- package/node_modules/@spexcode/session-events/dist/errors.d.ts +6 -0
- package/node_modules/@spexcode/session-events/dist/errors.js +11 -0
- package/node_modules/@spexcode/session-events/dist/index.d.ts +46 -0
- package/node_modules/@spexcode/session-events/dist/index.js +245 -0
- package/node_modules/@spexcode/session-events/dist/schema.d.ts +3 -0
- package/node_modules/@spexcode/session-events/dist/schema.js +44 -0
- package/node_modules/@spexcode/session-events/package.json +24 -0
- package/node_modules/@spexcode/session-protocol/dist/canonical.d.ts +36 -0
- package/node_modules/@spexcode/session-protocol/dist/canonical.js +139 -0
- package/node_modules/@spexcode/session-protocol/dist/engine.d.ts +22 -0
- package/node_modules/@spexcode/session-protocol/dist/engine.js +478 -0
- package/node_modules/@spexcode/session-protocol/dist/errors.d.ts +8 -0
- package/node_modules/@spexcode/session-protocol/dist/errors.js +39 -0
- package/node_modules/@spexcode/session-protocol/dist/index.d.ts +60 -0
- package/node_modules/@spexcode/session-protocol/dist/index.js +4 -0
- package/node_modules/@spexcode/session-protocol/dist/schema.d.ts +36 -0
- package/node_modules/@spexcode/session-protocol/dist/schema.js +192 -0
- package/node_modules/@spexcode/{session-core → session-protocol}/package.json +4 -8
- package/node_modules/@spexcode/session-runtime/dist/errors.d.ts +6 -0
- package/node_modules/@spexcode/session-runtime/dist/errors.js +11 -0
- package/node_modules/@spexcode/session-runtime/dist/index.d.ts +33 -0
- package/node_modules/@spexcode/session-runtime/dist/index.js +193 -0
- package/node_modules/@spexcode/session-runtime/dist/schema.d.ts +2 -0
- package/node_modules/@spexcode/session-runtime/dist/schema.js +37 -0
- package/node_modules/@spexcode/session-runtime/package.json +24 -0
- package/node_modules/@spexcode/session-selflaunch/bin/spex-session.mjs +4 -0
- package/node_modules/@spexcode/session-selflaunch/dist/cli.d.ts +25 -0
- package/node_modules/@spexcode/session-selflaunch/dist/cli.js +156 -0
- package/node_modules/@spexcode/session-selflaunch/dist/index.d.ts +28 -0
- package/node_modules/@spexcode/session-selflaunch/dist/index.js +25 -0
- package/node_modules/@spexcode/session-selflaunch/dist/locality.d.ts +31 -0
- package/node_modules/@spexcode/session-selflaunch/dist/locality.js +135 -0
- package/node_modules/@spexcode/session-selflaunch/dist/path.d.ts +13 -0
- package/node_modules/@spexcode/session-selflaunch/dist/path.js +45 -0
- package/node_modules/@spexcode/session-selflaunch/package.json +35 -0
- package/node_modules/@spexcode/session-topology/dist/errors.d.ts +6 -0
- package/node_modules/@spexcode/session-topology/dist/errors.js +11 -0
- package/node_modules/@spexcode/session-topology/dist/index.d.ts +24 -0
- package/node_modules/@spexcode/session-topology/dist/index.js +173 -0
- package/node_modules/@spexcode/session-topology/dist/schema.d.ts +3 -0
- package/node_modules/@spexcode/session-topology/dist/schema.js +27 -0
- package/node_modules/@spexcode/session-topology/package.json +24 -0
- package/node_modules/@spexcode/spec-cli/bin/spex.mjs +175 -21
- package/node_modules/@spexcode/spec-cli/dist/cli.js +121 -59
- package/node_modules/@spexcode/spec-cli/dist/client.d.ts +1 -3
- package/node_modules/@spexcode/spec-cli/dist/client.js +49 -30
- package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +11 -0
- package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +46 -9
- package/node_modules/@spexcode/spec-cli/dist/delivery-lock.d.ts +2 -0
- package/node_modules/@spexcode/spec-cli/dist/delivery-lock.js +58 -0
- package/node_modules/@spexcode/spec-cli/dist/doctor.js +53 -11
- package/node_modules/@spexcode/spec-cli/dist/file-write.js +14 -2
- package/node_modules/@spexcode/spec-cli/dist/gateway-hub.js +2 -1
- package/node_modules/@spexcode/spec-cli/dist/gateway.js +6 -3
- package/node_modules/@spexcode/spec-cli/dist/graphCache.js +32 -2
- package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +57 -2
- package/node_modules/@spexcode/spec-cli/dist/graphStream.d.ts +2 -0
- package/node_modules/@spexcode/spec-cli/dist/graphStream.js +83 -3
- package/node_modules/@spexcode/spec-cli/dist/guide.js +29 -18
- package/node_modules/@spexcode/spec-cli/dist/harness-select.js +16 -3
- package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +24 -6
- package/node_modules/@spexcode/spec-cli/dist/harness.js +510 -72
- package/node_modules/@spexcode/spec-cli/dist/help.js +11 -8
- package/node_modules/@spexcode/spec-cli/dist/hook-prompts.js +8 -0
- package/node_modules/@spexcode/spec-cli/dist/host-resources.js +29 -8
- package/node_modules/@spexcode/spec-cli/dist/host.d.ts +7 -0
- package/node_modules/@spexcode/spec-cli/dist/host.js +98 -1
- package/node_modules/@spexcode/spec-cli/dist/index.js +294 -36
- package/node_modules/@spexcode/spec-cli/dist/init.js +1 -1
- package/node_modules/@spexcode/spec-cli/dist/lint.js +70 -35
- package/node_modules/@spexcode/spec-cli/dist/listen.d.ts +3 -2
- package/node_modules/@spexcode/spec-cli/dist/listen.js +14 -2
- package/node_modules/@spexcode/spec-cli/dist/machine-peer.d.ts +2 -1
- package/node_modules/@spexcode/spec-cli/dist/machine-peer.js +46 -15
- package/node_modules/@spexcode/spec-cli/dist/materialize.d.ts +2 -2
- package/node_modules/@spexcode/spec-cli/dist/materialize.js +176 -35
- 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/pty-bridge.js +14 -14
- package/node_modules/@spexcode/spec-cli/dist/reviews.js +12 -7
- package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.d.ts +11 -0
- package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.js +79 -1
- package/node_modules/@spexcode/spec-cli/dist/session-application.d.ts +23 -0
- package/node_modules/@spexcode/spec-cli/dist/session-application.js +189 -0
- package/node_modules/@spexcode/spec-cli/dist/session-declarations.js +13 -1
- package/node_modules/@spexcode/spec-cli/dist/session-files.d.ts +6 -0
- package/node_modules/@spexcode/spec-cli/dist/session-files.js +13 -1
- package/node_modules/@spexcode/spec-cli/dist/session-follow.js +39 -22
- package/node_modules/@spexcode/{session-core/dist/record-lock.d.ts → spec-cli/dist/session-record-lock.d.ts} +0 -4
- package/node_modules/@spexcode/spec-cli/dist/session-record-lock.js +94 -0
- package/node_modules/@spexcode/spec-cli/dist/session-runtime-adapter.d.ts +44 -0
- package/node_modules/@spexcode/spec-cli/dist/session-runtime-adapter.js +37 -0
- package/node_modules/@spexcode/spec-cli/dist/session-timeline.d.ts +25 -2
- package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +68 -11
- 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/session-web.js +4 -4
- package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +112 -15
- package/node_modules/@spexcode/spec-cli/dist/sessions.js +1553 -764
- package/node_modules/@spexcode/spec-cli/dist/shim-runtime.js +14 -0
- package/node_modules/@spexcode/spec-cli/dist/source-list.d.ts +13 -0
- package/node_modules/@spexcode/spec-cli/dist/source-list.js +99 -0
- package/node_modules/@spexcode/spec-cli/dist/source-read.d.ts +16 -0
- package/node_modules/@spexcode/spec-cli/dist/source-read.js +84 -0
- package/node_modules/@spexcode/spec-cli/dist/spec-attachments.d.ts +7 -0
- package/node_modules/@spexcode/spec-cli/dist/spec-attachments.js +89 -0
- package/node_modules/@spexcode/spec-cli/dist/spec-body-edit.d.ts +23 -0
- package/node_modules/@spexcode/spec-cli/dist/spec-body-edit.js +138 -0
- package/node_modules/@spexcode/spec-cli/dist/supervise.js +15 -6
- package/node_modules/@spexcode/spec-cli/hooks/dispatch.sh +19 -31
- package/node_modules/@spexcode/spec-cli/hooks/harness.sh +6 -6
- package/node_modules/@spexcode/spec-cli/package.json +8 -6
- package/node_modules/@spexcode/spec-cli/templates/hooks/post-checkout +4 -2
- package/node_modules/@spexcode/spec-cli/templates/hooks/post-merge +2 -1
- package/node_modules/@spexcode/spec-cli/templates/hooks/pre-commit +5 -3
- package/node_modules/@spexcode/spec-cli/templates/hooks/reference-transaction +5 -3
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/commands/spec.md +2 -7
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/spec.md +2 -6
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/spec.md +7 -0
- package/node_modules/@spexcode/spec-core/dist/anchors.d.ts +13 -2
- package/node_modules/@spexcode/spec-core/dist/anchors.js +311 -15
- package/node_modules/@spexcode/spec-core/dist/git.d.ts +4 -0
- package/node_modules/@spexcode/spec-core/dist/git.js +9 -9
- package/node_modules/@spexcode/spec-core/dist/layout.d.ts +12 -1
- package/node_modules/@spexcode/spec-core/dist/layout.js +4 -2
- package/node_modules/@spexcode/spec-core/dist/resilience.d.ts +1 -0
- package/node_modules/@spexcode/spec-core/dist/resilience.js +8 -0
- package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.d.ts +1 -0
- package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.js +7 -3
- package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.d.ts +1 -0
- package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.js +4 -0
- package/node_modules/@spexcode/spec-core/dist/specs.d.ts +2 -0
- package/node_modules/@spexcode/spec-core/dist/specs.js +30 -0
- package/node_modules/@spexcode/spec-core/package.json +4 -1
- package/node_modules/@spexcode/spec-core/templates/spexcode.json +36 -13
- package/node_modules/@spexcode/spec-eval/dist/cli.js +1 -1
- package/node_modules/@spexcode/spec-eval/dist/evaltab.d.ts +1 -0
- package/node_modules/@spexcode/spec-eval/dist/evaltab.js +6 -1
- package/node_modules/@spexcode/spec-eval/dist/freshness.d.ts +1 -1
- package/node_modules/@spexcode/spec-eval/dist/freshness.js +12 -12
- package/node_modules/@spexcode/spec-eval/dist/host.d.ts +2 -1
- package/node_modules/@spexcode/spec-eval/dist/sessioneval.d.ts +2 -1
- package/node_modules/@spexcode/spec-eval/dist/sessioneval.js +20 -3
- 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 +44 -0
- package/node_modules/@spexcode/transcript/dist/frames.js +85 -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 +47 -0
- package/node_modules/@spexcode/transcript/dist/parsers.js +285 -0
- package/node_modules/@spexcode/transcript/dist/readers.d.ts +10 -0
- package/node_modules/@spexcode/transcript/dist/readers.js +296 -0
- package/node_modules/@spexcode/transcript/dist/turns.d.ts +42 -0
- package/node_modules/@spexcode/transcript/dist/turns.js +11 -0
- package/node_modules/@spexcode/transcript/package.json +30 -0
- package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
- package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
- package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
- package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
- package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -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 +9 -3
- package/node_modules/@spexcode/session-core/dist/delivery-queue.d.ts +0 -24
- package/node_modules/@spexcode/session-core/dist/delivery-queue.js +0 -206
- package/node_modules/@spexcode/session-core/dist/index.d.ts +0 -5
- package/node_modules/@spexcode/session-core/dist/index.js +0 -5
- package/node_modules/@spexcode/session-core/dist/internal.d.ts +0 -3
- package/node_modules/@spexcode/session-core/dist/internal.js +0 -3
- package/node_modules/@spexcode/session-core/dist/message.d.ts +0 -22
- package/node_modules/@spexcode/session-core/dist/message.js +0 -53
- package/node_modules/@spexcode/session-core/dist/record-lock.js +0 -152
- package/node_modules/@spexcode/session-core/dist/runtime-session.d.ts +0 -62
- package/node_modules/@spexcode/session-core/dist/runtime-session.js +0 -326
- package/node_modules/@spexcode/session-core/dist/session-cursors.d.ts +0 -14
- package/node_modules/@spexcode/session-core/dist/session-cursors.js +0 -82
- package/node_modules/@spexcode/session-core/dist/session-timeline.d.ts +0 -47
- package/node_modules/@spexcode/session-core/dist/session-timeline.js +0 -216
- package/node_modules/@spexcode/spec-cli/dist/execution-trace.d.ts +0 -26
- 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/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
- package/node_modules/@spexcode/spec-cli/hooks/compat/mark-active-sed-v0.fixture +0 -46
|
@@ -89,8 +89,16 @@ export const SpexcodePlugin = async (ctx) => {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
// The shared rendezvous server receives best-effort reply pokes. An unadopted plugin simply leaves the
|
|
92
|
-
// timeline line for its next turn boundary; the SDK injection remains host-owned.
|
|
93
|
-
|
|
92
|
+
// timeline line for its next turn boundary; the SDK injection remains host-owned. An interrupt is
|
|
93
|
+
// opencode's own session abort on the adopted root session, confirmed back over the same socket.
|
|
94
|
+
rt.serveRendezvous(injectPrompt, {
|
|
95
|
+
canInject: () => !!(client && rootSession),
|
|
96
|
+
interrupt: async () => {
|
|
97
|
+
if (!client || !rootSession) throw new Error("no opencode session is adopted - nothing to interrupt")
|
|
98
|
+
const r = await client.session.abort({ path: { id: rootSession } })
|
|
99
|
+
if (r === false || (r && r.data === false)) throw new Error("no opencode turn is running - nothing to interrupt")
|
|
100
|
+
},
|
|
101
|
+
})
|
|
94
102
|
|
|
95
103
|
const toolPayload = (input, output) => {
|
|
96
104
|
const sid = (input && input.sessionID) || ""
|
|
@@ -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
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFile, spawn } from 'node:child_process';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
|
-
import { alive
|
|
4
|
+
import { alive } from './sessions.js';
|
|
5
5
|
const pexec = promisify(execFile);
|
|
6
6
|
const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode';
|
|
7
7
|
const HELPER = fileURLToPath(new URL('./pty-helper.mjs', import.meta.url));
|
|
@@ -9,8 +9,9 @@ const subscribers = new Map();
|
|
|
9
9
|
const BSU = Buffer.from('\x1b[?2026h');
|
|
10
10
|
const ESU = Buffer.from('\x1b[?2026l');
|
|
11
11
|
const GEOMETRY_STABILIZATION_MS = 400;
|
|
12
|
-
// A hidden but still-alive browser keeps its own client briefly so a quick return
|
|
13
|
-
//
|
|
12
|
+
// A hidden but still-alive browser keeps its own native client briefly so a quick return skips helper spawn
|
|
13
|
+
// and tmux attach. It receives no output while hidden; the return always repaints. A dead socket bypasses
|
|
14
|
+
// this window and detaches immediately.
|
|
14
15
|
const LINGER_MS = Number(process.env.SPEXCODE_TERM_LINGER_MS) > 0 ? Number(process.env.SPEXCODE_TERM_LINGER_MS) : 30_000;
|
|
15
16
|
function subscriptionMap(id) {
|
|
16
17
|
let map = subscribers.get(id);
|
|
@@ -26,7 +27,7 @@ function isCurrent(bridge) {
|
|
|
26
27
|
}
|
|
27
28
|
function deliver(bridge, data) {
|
|
28
29
|
const subscription = currentSubscription(bridge.id, bridge.viewer);
|
|
29
|
-
if (!subscription
|
|
30
|
+
if (!subscription?.visible)
|
|
30
31
|
return;
|
|
31
32
|
try {
|
|
32
33
|
bridge.viewer.send(data);
|
|
@@ -174,7 +175,6 @@ function cancelLinger(subscription) {
|
|
|
174
175
|
if (subscription.lingerTimer)
|
|
175
176
|
clearTimeout(subscription.lingerTimer);
|
|
176
177
|
subscription.lingerTimer = undefined;
|
|
177
|
-
subscription.lingering = false;
|
|
178
178
|
}
|
|
179
179
|
function cancelRestore(subscription) {
|
|
180
180
|
if (subscription.restoreTimer)
|
|
@@ -309,7 +309,7 @@ export function attachViewer(id, viewer) {
|
|
|
309
309
|
const previous = map.get(viewer);
|
|
310
310
|
if (previous)
|
|
311
311
|
killBridge(previous);
|
|
312
|
-
map.set(viewer, { visible: false,
|
|
312
|
+
map.set(viewer, { visible: false, cols: 0, rows: 0 });
|
|
313
313
|
}
|
|
314
314
|
export function hideViewer(id, viewer) {
|
|
315
315
|
const subscription = currentSubscription(id, viewer);
|
|
@@ -318,7 +318,6 @@ export function hideViewer(id, viewer) {
|
|
|
318
318
|
subscription.visible = false;
|
|
319
319
|
if (!subscription.bridge)
|
|
320
320
|
return;
|
|
321
|
-
subscription.lingering = true;
|
|
322
321
|
if (subscription.lingerTimer)
|
|
323
322
|
return;
|
|
324
323
|
subscription.lingerTimer = setTimeout(() => {
|
|
@@ -346,15 +345,15 @@ export function resizeBridge(id, viewer, colsValue, rowsValue) {
|
|
|
346
345
|
const subscription = currentSubscription(id, viewer);
|
|
347
346
|
if (!subscription)
|
|
348
347
|
return;
|
|
349
|
-
const seamless = subscription.lingering && !!subscription.bridge
|
|
350
|
-
&& subscription.bridge.cols === cols && subscription.bridge.rows === rows;
|
|
351
348
|
cancelLinger(subscription);
|
|
352
349
|
subscription.visible = true;
|
|
353
350
|
subscription.cols = cols;
|
|
354
351
|
subscription.rows = rows;
|
|
355
352
|
const { bridge, created } = ensureBridge(id, viewer, subscription, cols, rows);
|
|
356
|
-
if (!bridge || created
|
|
353
|
+
if (!bridge || created)
|
|
357
354
|
return;
|
|
355
|
+
// A helper kept alive across a hidden window has been rendering to nobody: the browser cache behind this
|
|
356
|
+
// claim is stale by exactly that window, so an unchanged grid still answers with one atomic repaint.
|
|
358
357
|
if (bridge.cols === cols && bridge.rows === rows) {
|
|
359
358
|
beginDelivery(bridge);
|
|
360
359
|
queueRefresh(bridge);
|
|
@@ -364,14 +363,15 @@ export function resizeBridge(id, viewer, colsValue, rowsValue) {
|
|
|
364
363
|
}
|
|
365
364
|
}
|
|
366
365
|
const MAX_INPUT_BYTES = 64 * 1024;
|
|
366
|
+
// Input is transport: it goes straight to the helper. It never waits for, or is refused by, the session record
|
|
367
|
+
// lock — that lock serializes lifecycle writers, which hold it for a noticeable share of a busy session's wall
|
|
368
|
+
// time, and a keystroke that lost that race used to vanish silently.
|
|
367
369
|
export function forwardInput(id, viewer, data) {
|
|
368
370
|
const subscription = currentSubscription(id, viewer);
|
|
369
371
|
if (!subscription?.visible || !subscription.bridge || !data || Buffer.byteLength(data, 'utf8') > MAX_INPUT_BYTES)
|
|
370
372
|
return false;
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
return true;
|
|
374
|
-
}) ?? false;
|
|
373
|
+
sendControl(subscription.bridge, { t: 'input', data });
|
|
374
|
+
return true;
|
|
375
375
|
}
|
|
376
376
|
async function restoreBridge(id, viewer, subscription) {
|
|
377
377
|
subscription.restoreTimer = undefined;
|
|
@@ -86,7 +86,9 @@ export async function issuesReview(query, requestedPage) {
|
|
|
86
86
|
}),
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
|
-
const
|
|
89
|
+
const orderableEval = (item) => item.filterKind === EVAL_FILTER_KIND.RESULT
|
|
90
|
+
|| item.filterKind === EVAL_FILTER_KIND.DEFERRED;
|
|
91
|
+
const byNewest = (a, b) => Number(orderableEval(b)) - Number(orderableEval(a))
|
|
90
92
|
|| String(b.ts ?? '').localeCompare(String(a.ts ?? ''))
|
|
91
93
|
|| String(a.node ?? '').localeCompare(String(b.node ?? ''))
|
|
92
94
|
|| String(a.scenario ?? '').localeCompare(String(b.scenario ?? ''));
|
|
@@ -114,10 +116,11 @@ export function trunkEvalReviewItems(nodes) {
|
|
|
114
116
|
...reading,
|
|
115
117
|
expected: scenario.expected ?? reading.expected,
|
|
116
118
|
tags: scenario.tags,
|
|
117
|
-
|
|
119
|
+
...(reading.freshnessDeferred
|
|
120
|
+
? { state: 'deferred', filterKind: EVAL_FILTER_KIND.DEFERRED }
|
|
121
|
+
: { state: evalReviewState(reading), filterKind: EVAL_FILTER_KIND.RESULT }),
|
|
118
122
|
node: node.id,
|
|
119
123
|
hue: node.hue,
|
|
120
|
-
filterKind: EVAL_FILTER_KIND.RESULT,
|
|
121
124
|
});
|
|
122
125
|
}
|
|
123
126
|
}
|
|
@@ -149,10 +152,11 @@ export function scopedEvalReviewItems(model) {
|
|
|
149
152
|
expected: scenario.expected ?? reading.expected,
|
|
150
153
|
tags: scenario.tags,
|
|
151
154
|
impact: scenario.impact,
|
|
152
|
-
|
|
155
|
+
...(reading.freshnessDeferred
|
|
156
|
+
? { state: 'deferred', filterKind: EVAL_FILTER_KIND.DEFERRED }
|
|
157
|
+
: { state: evalReviewState(reading), filterKind: EVAL_FILTER_KIND.RESULT }),
|
|
153
158
|
node: node.id,
|
|
154
159
|
hue: node.hue,
|
|
155
|
-
filterKind: EVAL_FILTER_KIND.RESULT,
|
|
156
160
|
};
|
|
157
161
|
items.push(item);
|
|
158
162
|
}
|
|
@@ -278,9 +282,10 @@ export function timelineEvalReviewItems(timeline, node) {
|
|
|
278
282
|
})),
|
|
279
283
|
...[...latest.values()].map((reading) => ({
|
|
280
284
|
...reading,
|
|
281
|
-
|
|
285
|
+
...(reading.freshnessDeferred
|
|
286
|
+
? { state: 'deferred', filterKind: EVAL_FILTER_KIND.DEFERRED }
|
|
287
|
+
: { state: evalReviewState(reading), filterKind: EVAL_FILTER_KIND.RESULT }),
|
|
282
288
|
node,
|
|
283
|
-
filterKind: EVAL_FILTER_KIND.RESULT,
|
|
284
289
|
filterKey: `${EVAL_FILTER_KIND.RESULT}:${reading.scenario}`,
|
|
285
290
|
})),
|
|
286
291
|
...(timeline.dangling ?? []).map((track) => ({
|
|
@@ -10,6 +10,16 @@ export type BackendInstanceRecord = {
|
|
|
10
10
|
export declare function readBackendInstanceRecords(root?: string): BackendInstanceRecord[];
|
|
11
11
|
export declare function registerBackendInstance(instanceId: string, pid?: number, root?: string): BackendInstanceRecord;
|
|
12
12
|
export declare function unregisterBackendInstance(instanceId: string, pid?: number): void;
|
|
13
|
+
type ProcBackendCandidate = BackendInstanceRecord & {
|
|
14
|
+
candidatePid: number;
|
|
15
|
+
command: string;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Reap only backend children whose supervisor identity is provably stale. A dead registry PID alone is not
|
|
19
|
+
* enough: the live process must carry the exact instance id and project root, run the backend entrypoint, and
|
|
20
|
+
* have been orphaned to PID 1. Ordinary launchers, shells, and app-server generations do not match.
|
|
21
|
+
*/
|
|
22
|
+
export declare function reapOrphanBackendInstances(root?: string): ProcBackendCandidate[];
|
|
13
23
|
export declare function spawnDetachedRuntime(opts: {
|
|
14
24
|
cwd: string;
|
|
15
25
|
logFile: string;
|
|
@@ -19,3 +29,4 @@ export declare function spawnDetachedRuntime(opts: {
|
|
|
19
29
|
args: string[];
|
|
20
30
|
env?: NodeJS.ProcessEnv;
|
|
21
31
|
}): ProcessIdentity;
|
|
32
|
+
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import { closeSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { repoRoot } from '@spexcode/spec-core';
|
|
6
6
|
import { readJsonConfig, runtimeRoot } from '@spexcode/spec-core';
|
|
@@ -47,6 +47,84 @@ export function unregisterBackendInstance(instanceId, pid = process.pid) {
|
|
|
47
47
|
}
|
|
48
48
|
catch { /* not ours / already removed */ }
|
|
49
49
|
}
|
|
50
|
+
const procRows = () => {
|
|
51
|
+
try {
|
|
52
|
+
return readdirSync('/proc').filter((name) => /^\d+$/.test(name)).map(Number);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const procEnv = (pid) => {
|
|
59
|
+
try {
|
|
60
|
+
return Object.fromEntries(readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0').flatMap((entry) => {
|
|
61
|
+
const split = entry.indexOf('=');
|
|
62
|
+
return split > 0 ? [[entry.slice(0, split), entry.slice(split + 1)]] : [];
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const procCommand = (pid) => {
|
|
70
|
+
try {
|
|
71
|
+
return readFileSync(`/proc/${pid}/cmdline`, 'utf8').replaceAll('\0', ' ').trim();
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return '';
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const procParent = (pid) => {
|
|
78
|
+
try {
|
|
79
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
80
|
+
const close = stat.lastIndexOf(')');
|
|
81
|
+
const fields = stat.slice(close + 2).split(' ');
|
|
82
|
+
return Number(fields[1]) || null;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const isBackendCommand = (command) => /(?:^|\/)spec-cli\/(?:src\/index\.ts|dist\/index\.js)(?:\s|$)/.test(command);
|
|
89
|
+
/**
|
|
90
|
+
* Reap only backend children whose supervisor identity is provably stale. A dead registry PID alone is not
|
|
91
|
+
* enough: the live process must carry the exact instance id and project root, run the backend entrypoint, and
|
|
92
|
+
* have been orphaned to PID 1. Ordinary launchers, shells, and app-server generations do not match.
|
|
93
|
+
*/
|
|
94
|
+
export function reapOrphanBackendInstances(root = repoRoot()) {
|
|
95
|
+
const records = readBackendInstanceRecords(root);
|
|
96
|
+
const liveCurrent = records.filter((record) => processStartToken(record.pid) === record.startToken);
|
|
97
|
+
const currentIds = new Set(liveCurrent.map((record) => record.instanceId));
|
|
98
|
+
const candidates = [];
|
|
99
|
+
for (const record of records) {
|
|
100
|
+
if (currentIds.has(record.instanceId))
|
|
101
|
+
continue;
|
|
102
|
+
if (processStartToken(record.pid) === record.startToken)
|
|
103
|
+
continue;
|
|
104
|
+
for (const pid of procRows()) {
|
|
105
|
+
const env = procEnv(pid);
|
|
106
|
+
if (env.SPEXCODE_INSTANCE_ID !== record.instanceId || env.SPEXCODE_PROJECT_ROOT !== root)
|
|
107
|
+
continue;
|
|
108
|
+
if (procParent(pid) !== 1)
|
|
109
|
+
continue;
|
|
110
|
+
const command = procCommand(pid);
|
|
111
|
+
if (!isBackendCommand(command))
|
|
112
|
+
continue;
|
|
113
|
+
candidates.push({ ...record, candidatePid: pid, command });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const candidate of candidates) {
|
|
117
|
+
try {
|
|
118
|
+
process.kill(candidate.candidatePid, 'SIGTERM');
|
|
119
|
+
}
|
|
120
|
+
catch { /* exact process already exited */ }
|
|
121
|
+
try {
|
|
122
|
+
rmSync(backendInstancePath(candidate.instanceId), { force: true });
|
|
123
|
+
}
|
|
124
|
+
catch { /* stale record is advisory */ }
|
|
125
|
+
}
|
|
126
|
+
return candidates;
|
|
127
|
+
}
|
|
50
128
|
export function spawnDetachedRuntime(opts) {
|
|
51
129
|
mkdirSync(dirname(opts.logFile), { recursive: true });
|
|
52
130
|
mkdirSync(dirname(opts.pidFile), { recursive: true });
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type CommittedSessionChange, type ProductionSessionApplication } from '@spexcode/session-application';
|
|
2
|
+
type SessionApplicationCommitWake = (recipients: readonly string[]) => void;
|
|
3
|
+
/** The application owns commit ordering; Spex supplies the adopter transport wake. */
|
|
4
|
+
export declare function setSessionApplicationCommitWake(wake: SessionApplicationCommitWake): void;
|
|
5
|
+
/** Allow the adopter to refresh its board stream after the canonical transaction commits. */
|
|
6
|
+
export declare function setSessionApplicationCommitObserver(observer: (change: Pick<CommittedSessionChange, 'recipients'>) => void): void;
|
|
7
|
+
/** The backend's sole session application composition. Path selection is shared with self-launch. */
|
|
8
|
+
export declare function configuredSessionApplication(): ProductionSessionApplication;
|
|
9
|
+
export type SessionApplicationCutoverState = 'ready' | 'residue' | 'fresh' | 'migration-required' | 'fenced' | 'ambiguous';
|
|
10
|
+
/** Inspect cutover state without initializing a fresh database as a side effect of a rejected request. */
|
|
11
|
+
export declare function sessionApplicationCutoverState(): SessionApplicationCutoverState;
|
|
12
|
+
/** Resolve the canonical application. A legacy tree is cut over at its first canonical access; runtime code
|
|
13
|
+
* never gets to choose between JSON and SQLite based on the caller or request path. */
|
|
14
|
+
export declare function configuredSessionApplicationIfCutover(): ProductionSessionApplication | undefined;
|
|
15
|
+
/** Initialize a fresh canonical store only after an accepted create has crossed its no-side-effect boundary. */
|
|
16
|
+
export declare function initializeFreshSessionApplication(): ProductionSessionApplication;
|
|
17
|
+
export declare function acquireFreshSessionApplicationForCreate(): {
|
|
18
|
+
application: ProductionSessionApplication;
|
|
19
|
+
owned: boolean;
|
|
20
|
+
};
|
|
21
|
+
export declare function releaseFreshSessionApplicationForCreate(owned: boolean, committed: boolean): void;
|
|
22
|
+
export declare function resetConfiguredSessionApplicationForTest(): void;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { existsSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { openProjectSessionApplication, legacyResidueExists, migrateJsonSessionRecords, } from '@spexcode/session-application';
|
|
4
|
+
import { requireLocalDatabasePath, resolveDatabasePath } from '@spexcode/session-selflaunch';
|
|
5
|
+
import { runtimeRoot } from '@spexcode/spec-core';
|
|
6
|
+
import { jsonMigrationFencePath } from '@spexcode/session-application';
|
|
7
|
+
let cached;
|
|
8
|
+
let cachedPath;
|
|
9
|
+
let freshStoreOwned = false;
|
|
10
|
+
let freshStoreCommitted = false;
|
|
11
|
+
let freshStoreLeases = 0;
|
|
12
|
+
let commitWake = () => { };
|
|
13
|
+
let commitObserver;
|
|
14
|
+
/** The application owns commit ordering; Spex supplies the adopter transport wake. */
|
|
15
|
+
export function setSessionApplicationCommitWake(wake) {
|
|
16
|
+
commitWake = wake;
|
|
17
|
+
}
|
|
18
|
+
/** Allow the adopter to refresh its board stream after the canonical transaction commits. */
|
|
19
|
+
export function setSessionApplicationCommitObserver(observer) {
|
|
20
|
+
commitObserver = observer;
|
|
21
|
+
}
|
|
22
|
+
/** The backend's sole session application composition. Path selection is shared with self-launch. */
|
|
23
|
+
export function configuredSessionApplication() {
|
|
24
|
+
const databasePath = resolveDatabasePath();
|
|
25
|
+
if (cached !== undefined && cachedPath === databasePath)
|
|
26
|
+
return cached;
|
|
27
|
+
if (cached !== undefined)
|
|
28
|
+
cached.close();
|
|
29
|
+
cached = openProjectSessionApplication({
|
|
30
|
+
databasePath,
|
|
31
|
+
locality: path => { requireLocalDatabasePath(path); },
|
|
32
|
+
onCommitted: result => {
|
|
33
|
+
commitWake(result.recipients);
|
|
34
|
+
if (commitObserver)
|
|
35
|
+
setImmediate(() => commitObserver?.(result));
|
|
36
|
+
},
|
|
37
|
+
onRuntimeBound: sessionId => commitWake([sessionId]),
|
|
38
|
+
});
|
|
39
|
+
cachedPath = databasePath;
|
|
40
|
+
return cached;
|
|
41
|
+
}
|
|
42
|
+
// A marked store is `ready` only once its legacy tree holds nothing left to absorb. The scan runs once per process
|
|
43
|
+
// per store: after residue is migrated (or none was found) no legacy writer exists to create more, so later calls
|
|
44
|
+
// answer from memory instead of walking every session directory on the hot path.
|
|
45
|
+
const settledResidueStores = new Set();
|
|
46
|
+
/** Inspect cutover state without initializing a fresh database as a side effect of a rejected request. */
|
|
47
|
+
export function sessionApplicationCutoverState() {
|
|
48
|
+
const databasePath = resolveDatabasePath();
|
|
49
|
+
const recordsRoot = join(runtimeRoot(), 'sessions');
|
|
50
|
+
const storeKey = `${databasePath}\0${recordsRoot}`;
|
|
51
|
+
if (existsSync(`${databasePath}.json-migration.json`)) {
|
|
52
|
+
if (settledResidueStores.has(storeKey))
|
|
53
|
+
return 'ready';
|
|
54
|
+
if (legacyResidueExists(recordsRoot))
|
|
55
|
+
return 'residue';
|
|
56
|
+
settledResidueStores.add(storeKey);
|
|
57
|
+
return 'ready';
|
|
58
|
+
}
|
|
59
|
+
if (existsSync(jsonMigrationFencePath(recordsRoot)))
|
|
60
|
+
return 'fenced';
|
|
61
|
+
if (existsSync(recordsRoot) && readdirSync(recordsRoot, { withFileTypes: true }).some(entry => entry.isDirectory()))
|
|
62
|
+
return 'migration-required';
|
|
63
|
+
if (existsSync(databasePath))
|
|
64
|
+
return 'ambiguous';
|
|
65
|
+
return 'fresh';
|
|
66
|
+
}
|
|
67
|
+
/** Resolve the canonical application. A legacy tree is cut over at its first canonical access; runtime code
|
|
68
|
+
* never gets to choose between JSON and SQLite based on the caller or request path. */
|
|
69
|
+
export function configuredSessionApplicationIfCutover() {
|
|
70
|
+
const state = sessionApplicationCutoverState();
|
|
71
|
+
if (state === 'ready')
|
|
72
|
+
return configuredSessionApplication();
|
|
73
|
+
if (state === 'migration-required' || state === 'residue')
|
|
74
|
+
return initializeMigratedSessionApplication(state);
|
|
75
|
+
if (state === 'fresh')
|
|
76
|
+
return initializeFreshSessionApplication();
|
|
77
|
+
if (state === 'fenced' || state === 'ambiguous') {
|
|
78
|
+
throw new Error(`session application cutover is ${state}; refusing a legacy/runtime split`);
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
// One importer for both shapes of legacy tree. Before the marker it installs the canonical store; after the
|
|
83
|
+
// marker it absorbs whatever residue the tree still holds into the store this process already owns, then
|
|
84
|
+
// retires the tree. Either way the tree is gone when this returns, so the cutover state settles to `ready`.
|
|
85
|
+
function initializeMigratedSessionApplication(state) {
|
|
86
|
+
const databasePath = resolveDatabasePath();
|
|
87
|
+
const recordsRoot = join(runtimeRoot(), 'sessions');
|
|
88
|
+
const locality = (path) => { requireLocalDatabasePath(path); };
|
|
89
|
+
if (state === 'migration-required') {
|
|
90
|
+
migrateJsonSessionRecords({ databasePath, recordsRoot, locality });
|
|
91
|
+
return configuredSessionApplication();
|
|
92
|
+
}
|
|
93
|
+
const application = configuredSessionApplication();
|
|
94
|
+
const report = migrateJsonSessionRecords({ databasePath, recordsRoot, locality, application });
|
|
95
|
+
settledResidueStores.add(`${databasePath}\0${recordsRoot}`);
|
|
96
|
+
const residue = report.residue;
|
|
97
|
+
if (residue) {
|
|
98
|
+
console.log(`[session-application] migrated legacy residue from ${recordsRoot}: ${residue.records} record(s), ${residue.events} event(s), ${residue.parentEdges} parent edge(s), ${residue.watchEdges} watch edge(s), ${residue.pending} pending; ${residue.unclaimed.length} unclaimed dir(s)${residue.unclaimed.length ? ` (${residue.unclaimed.join(', ')})` : ''}; backup ${residue.backupRoot}`);
|
|
99
|
+
}
|
|
100
|
+
return application;
|
|
101
|
+
}
|
|
102
|
+
/** Initialize a fresh canonical store only after an accepted create has crossed its no-side-effect boundary. */
|
|
103
|
+
export function initializeFreshSessionApplication() {
|
|
104
|
+
const state = sessionApplicationCutoverState();
|
|
105
|
+
if (state === 'ready')
|
|
106
|
+
return configuredSessionApplication();
|
|
107
|
+
if (state === 'residue')
|
|
108
|
+
return initializeMigratedSessionApplication(state);
|
|
109
|
+
if (state !== 'fresh')
|
|
110
|
+
throw new Error(`cannot initialize a fresh session store from cutover state: ${state}`);
|
|
111
|
+
const databasePath = resolveDatabasePath();
|
|
112
|
+
const recordsRoot = join(runtimeRoot(), 'sessions');
|
|
113
|
+
migrateJsonSessionRecords({ databasePath, recordsRoot, locality: path => { requireLocalDatabasePath(path); } });
|
|
114
|
+
return configuredSessionApplication();
|
|
115
|
+
}
|
|
116
|
+
export function acquireFreshSessionApplicationForCreate() {
|
|
117
|
+
const state = sessionApplicationCutoverState();
|
|
118
|
+
if (state === 'fresh') {
|
|
119
|
+
const application = initializeFreshSessionApplication();
|
|
120
|
+
freshStoreOwned = true;
|
|
121
|
+
freshStoreCommitted = false;
|
|
122
|
+
freshStoreLeases++;
|
|
123
|
+
return { application, owned: true };
|
|
124
|
+
}
|
|
125
|
+
if (state === 'ready' && freshStoreOwned && !freshStoreCommitted) {
|
|
126
|
+
freshStoreLeases++;
|
|
127
|
+
return { application: configuredSessionApplication(), owned: true };
|
|
128
|
+
}
|
|
129
|
+
if (state === 'residue')
|
|
130
|
+
return { application: initializeMigratedSessionApplication(state), owned: false };
|
|
131
|
+
if (state !== 'ready')
|
|
132
|
+
throw new Error(`cannot initialize a session store from cutover state: ${state}`);
|
|
133
|
+
return { application: configuredSessionApplication(), owned: false };
|
|
134
|
+
}
|
|
135
|
+
export function releaseFreshSessionApplicationForCreate(owned, committed) {
|
|
136
|
+
if (!owned)
|
|
137
|
+
return;
|
|
138
|
+
if (committed)
|
|
139
|
+
freshStoreCommitted = true;
|
|
140
|
+
freshStoreLeases = Math.max(0, freshStoreLeases - 1);
|
|
141
|
+
if (freshStoreLeases !== 0)
|
|
142
|
+
return;
|
|
143
|
+
if (freshStoreCommitted) {
|
|
144
|
+
try {
|
|
145
|
+
unlinkSync(jsonMigrationFencePath(join(runtimeRoot(), 'sessions')));
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (error.code !== 'ENOENT')
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const application = cached;
|
|
154
|
+
const rows = application?.protocol.withTransaction(tx => Number(tx.query('SELECT COUNT(*) AS count FROM session_application_state')[0]?.count ?? 0)) ?? 0;
|
|
155
|
+
if (rows !== 0) {
|
|
156
|
+
freshStoreCommitted = true;
|
|
157
|
+
try {
|
|
158
|
+
unlinkSync(jsonMigrationFencePath(join(runtimeRoot(), 'sessions')));
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (error.code !== 'ENOENT')
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
application?.close();
|
|
167
|
+
cached = undefined;
|
|
168
|
+
const databasePath = resolveDatabasePath();
|
|
169
|
+
const recordsRoot = join(runtimeRoot(), 'sessions');
|
|
170
|
+
for (const path of [databasePath, `${databasePath}.json-migration.json`, jsonMigrationFencePath(recordsRoot)]) {
|
|
171
|
+
try {
|
|
172
|
+
unlinkSync(path);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (error.code !== 'ENOENT')
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
rmSync(`${databasePath}.json-migration-backup`, { recursive: true, force: true });
|
|
180
|
+
freshStoreOwned = false;
|
|
181
|
+
}
|
|
182
|
+
export function resetConfiguredSessionApplicationForTest() {
|
|
183
|
+
cached?.close();
|
|
184
|
+
cached = undefined;
|
|
185
|
+
settledResidueStores.clear();
|
|
186
|
+
freshStoreOwned = false;
|
|
187
|
+
freshStoreCommitted = false;
|
|
188
|
+
freshStoreLeases = 0;
|
|
189
|
+
}
|
|
@@ -27,7 +27,11 @@ export async function sessionStateKit(sessionId) {
|
|
|
27
27
|
// the agent-authored state writers resolve WHICH session by id: a `--session <id>` flag (the lifecycle
|
|
28
28
|
// hooks pass it, parsed from the payload, since they no longer have a cwd `.session`) wins, else the
|
|
29
29
|
// harness env var (ownSessionId — the agent's own `spex session …` carries the harness session id).
|
|
30
|
-
|
|
30
|
+
// Hook payloads from Codex carry the native thread id, while the canonical
|
|
31
|
+
// application is keyed by the Spex session id. Resolve that alias once at
|
|
32
|
+
// the state-writer boundary; shell hooks stay transport-only and every
|
|
33
|
+
// lifecycle producer writes the same canonical record.
|
|
34
|
+
const sess = sessionId ? (l.readAliasedRawRecord(sessionId)?.session_id ?? sessionId) : undefined;
|
|
31
35
|
// @@@ no-record diagnosis ([[state]]) - the session store resolves from the CURRENT directory (runtimeRoot
|
|
32
36
|
// ← the cwd's git common dir), so the classic declaration failure is a cd OUTSIDE the session's project —
|
|
33
37
|
// and a bare "no session record" told the author none of that (field-reported). Name the actual cause and
|
|
@@ -99,6 +103,14 @@ export async function runSessionDeclaration(verb, argv = process.argv) {
|
|
|
99
103
|
if (proposal === 'nothing')
|
|
100
104
|
nothingProposalTrap();
|
|
101
105
|
const { s, sess, mark, noRecord, noteEcho } = await sessionStateKit(sessionId);
|
|
106
|
+
if (proposal === 'merge') {
|
|
107
|
+
const readiness = s.mergeReadiness('merge');
|
|
108
|
+
if (!readiness.ready) {
|
|
109
|
+
console.error(`review declaration refused: ${readiness.reason}`);
|
|
110
|
+
process.exitCode = 1;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
102
114
|
let closeNote = proposal === 'close' ? CLOSE_CLEANUP : '';
|
|
103
115
|
if (proposal === 'close') {
|
|
104
116
|
// The closeout nudge is advisory: a declaration must land even if the local issue store fails.
|
|
@@ -6,6 +6,12 @@ type SessionFileLock = <T>(id: string, body: () => T) => T;
|
|
|
6
6
|
export declare const SESSION_FILE_PREVIEW_MAX_BYTES: number;
|
|
7
7
|
export type SessionFilePreviewKind = 'text' | 'image' | 'html';
|
|
8
8
|
export declare function listSessionFiles(id: string): string[];
|
|
9
|
+
export type SessionFileStatus = {
|
|
10
|
+
path: string;
|
|
11
|
+
valid: boolean;
|
|
12
|
+
reason?: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function inspectSessionFiles(id: string): SessionFileStatus[];
|
|
9
15
|
export declare function readSessionFiles(id: string): string[];
|
|
10
16
|
export declare function addSessionFile(id: string, input: string, lock: SessionFileLock, cwd?: string): {
|
|
11
17
|
path: string;
|