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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { closeSync, openSync, readSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { parse as parseToml } from 'smol-toml';
|
|
2
3
|
import { join, dirname, basename } from 'node:path';
|
|
3
4
|
import { homedir, tmpdir } from 'node:os';
|
|
4
5
|
import { createHash, randomBytes } from 'node:crypto';
|
|
@@ -12,14 +13,14 @@ import { piExtensionSource, writePiTrust, removePiTrust } from './pi-harness.js'
|
|
|
12
13
|
import { claudeHeadlessColdRuntime, claudeHeadlessLaunchCommand, claudeHeadlessSock, deliverViaClaudeHeadless, interruptClaudeHeadless } from './claude-headless.js';
|
|
13
14
|
import { codexHeadlessLaunchCommand } from './codex-headless.js';
|
|
14
15
|
import { opencodeHeadlessColdRuntime, opencodeHeadlessLaunchCommand, spawnOpenCodeHeadlessTurn } from './opencode-headless.js';
|
|
15
|
-
import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless, piHeadlessColdRuntime } from './pi-headless.js';
|
|
16
|
+
import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless, interruptPiHeadless, piHeadlessColdRuntime } from './pi-headless.js';
|
|
16
17
|
import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath, spexcodeHome } from '@spexcode/spec-core';
|
|
17
18
|
import { git } from '@spexcode/spec-core';
|
|
18
19
|
import { shQuote } from './sh.js';
|
|
19
20
|
import { detachedRuntimeGenerationToken, migrateLegacyDetachedRuntimeReceipt, processStartToken, verifyDetachedRuntime } from '@spexcode/spec-core';
|
|
20
21
|
import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGeneration, legacyCodexGenerationEndpoint, readCodexGenerationLedger, resolveCodexGenerationForSession } from './codex-runtime-generations.js';
|
|
21
22
|
import { writeFileIfChanged } from './file-write.js';
|
|
22
|
-
import {
|
|
23
|
+
import { claudeTranscript, codexRolloutPath, codexTranscript, opencodeTranscript, piTranscript, unsupportedTranscript } from '@spexcode/transcript';
|
|
23
24
|
import { harnessIdentity, HARNESS_IDENTITIES } from '@spexcode/spec-core';
|
|
24
25
|
// One project-wide resident-reference census for read projections. A shared app-server descriptor is probed
|
|
25
26
|
// once per call, then its result is joined to every record that names that adapter/thread. Product readers must
|
|
@@ -302,21 +303,38 @@ async function claudeDeliveryTransport(rec) {
|
|
|
302
303
|
return rendezvousDeliveryTransport(rec);
|
|
303
304
|
}
|
|
304
305
|
const unprovenDeliveryTransport = async () => ({ kind: 'unproven' });
|
|
305
|
-
|
|
306
|
+
// Claude's rendezvous daemon owns one connection at a time. A liveness probe can therefore destroy a
|
|
307
|
+
// delivery connection after its write has reached the kernel but before the daemon parses the line. Send the
|
|
308
|
+
// reply and an in-order repaint probe in one chunk: repaint-done proves the reply line was parsed first; a
|
|
309
|
+
// close/reset before that proves the whole chunk was discarded and is safe to retry. An open connection that
|
|
310
|
+
// outlives the wall is treated as busy, not lost, preserving the no-false-failure behavior for active turns.
|
|
311
|
+
function replyViaSocket(sock, text, mid, auth, wallMs = 10_000) {
|
|
306
312
|
return new Promise((resolve) => {
|
|
307
313
|
let settled = false;
|
|
308
314
|
let c;
|
|
315
|
+
let wall;
|
|
309
316
|
const done = (r) => {
|
|
310
317
|
if (settled)
|
|
311
318
|
return;
|
|
312
319
|
settled = true;
|
|
313
|
-
|
|
320
|
+
clearTimeout(wall);
|
|
321
|
+
if (r.ok) {
|
|
322
|
+
// A confirmed repaint has already crossed the parser. On a busy timeout, finish the stream cleanly so
|
|
323
|
+
// the daemon can consume the buffered pair before its FIN; never destroy an unparsed write.
|
|
324
|
+
try {
|
|
325
|
+
c?.end();
|
|
326
|
+
}
|
|
327
|
+
catch { /* */ }
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
314
330
|
try {
|
|
315
331
|
c?.destroy();
|
|
316
332
|
}
|
|
317
333
|
catch { /* */ }
|
|
334
|
+
}
|
|
318
335
|
resolve(r);
|
|
319
336
|
};
|
|
337
|
+
wall = setTimeout(() => done({ ok: true }), wallMs);
|
|
320
338
|
try {
|
|
321
339
|
c = createConnection({ path: sock });
|
|
322
340
|
}
|
|
@@ -326,29 +344,49 @@ function replyViaSocket(sock, text, mid, auth) {
|
|
|
326
344
|
}
|
|
327
345
|
c.on('error', (e) => {
|
|
328
346
|
const code = e?.code || String(e);
|
|
329
|
-
done({ ok: false, error: `rendezvous socket error: ${code}` });
|
|
347
|
+
done({ ok: false, kicked: code === 'ECONNRESET' || code === 'EPIPE', error: `rendezvous socket error: ${code} — prompt NOT delivered` });
|
|
348
|
+
});
|
|
349
|
+
c.on('close', () => done({ ok: false, kicked: true, error: 'rendezvous connection closed before the daemon parsed the prompt (kicked by a concurrent connect)' }));
|
|
350
|
+
c.on('connect', () => c.write(`${auth ? JSON.stringify({ role: 'controller', auth }) + '\n' : ''}${JSON.stringify({ type: 'reply', text, ...(mid ? { mid } : {}) })}\n${JSON.stringify({ type: 'repaint' })}\n`));
|
|
351
|
+
let buf = '';
|
|
352
|
+
c.on('data', (chunk) => {
|
|
353
|
+
buf += chunk.toString('utf8');
|
|
354
|
+
let nl;
|
|
355
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
356
|
+
const line = buf.slice(0, nl);
|
|
357
|
+
buf = buf.slice(nl + 1);
|
|
358
|
+
let type = '';
|
|
359
|
+
try {
|
|
360
|
+
type = JSON.parse(line)?.type ?? '';
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (type === 'repaint-done')
|
|
366
|
+
return done({ ok: true });
|
|
367
|
+
if (type === 'reply-rejected' || type === 'auth-rejected')
|
|
368
|
+
return done({ ok: false, error: `rendezvous daemon rejected the prompt (${type}) — prompt NOT delivered` });
|
|
369
|
+
if (type === 'shutting-down')
|
|
370
|
+
return done({ ok: false, error: 'agent is shutting down — prompt NOT delivered' });
|
|
371
|
+
}
|
|
330
372
|
});
|
|
331
|
-
c.on('close', () => done({ ok: false, error: 'rendezvous connection closed before the poke was written' }));
|
|
332
|
-
c.on('connect', () => c.write(`${auth ? JSON.stringify({ role: 'controller', auth }) + '\n' : ''}${JSON.stringify({ type: 'reply', text, ...(mid ? { mid } : {}) })}\n`, (error) => {
|
|
333
|
-
if (error)
|
|
334
|
-
return done({ ok: false, error: `rendezvous socket write failed: ${error.message}` });
|
|
335
|
-
c.end();
|
|
336
|
-
done({ ok: true });
|
|
337
|
-
}));
|
|
338
373
|
});
|
|
339
374
|
}
|
|
340
375
|
const POKE_ATTEMPTS = 2;
|
|
341
|
-
async function pokeRendezvous(sock, text, mid, auth) {
|
|
376
|
+
async function pokeRendezvous(sock, text, mid, auth, wallMs) {
|
|
342
377
|
let last = { ok: false, error: 'not attempted' };
|
|
343
378
|
for (let attempt = 0; attempt < POKE_ATTEMPTS; attempt++) {
|
|
344
|
-
last = await replyViaSocket(sock, text, mid, auth);
|
|
379
|
+
last = await replyViaSocket(sock, text, mid, auth, wallMs);
|
|
345
380
|
if (last.ok)
|
|
346
381
|
return last;
|
|
382
|
+
if (!last.kicked)
|
|
383
|
+
break;
|
|
384
|
+
await new Promise((resolve) => setTimeout(resolve, 60 + Math.random() * 140));
|
|
347
385
|
}
|
|
348
386
|
return { ok: false, error: `rendezvous poke failed after ${POKE_ATTEMPTS} attempts: ${last.error ?? 'unknown error'}` };
|
|
349
387
|
}
|
|
350
|
-
export async function deliverViaRendezvous(id, text, mid) {
|
|
351
|
-
return pokeRendezvous(rvSock(id), text, mid);
|
|
388
|
+
export async function deliverViaRendezvous(id, text, mid, wallMs) {
|
|
389
|
+
return pokeRendezvous(rvSock(id), text, mid, undefined, wallMs);
|
|
352
390
|
}
|
|
353
391
|
export async function deliverViaClaudeRendezvous(id, text, mid, runtimeDir) {
|
|
354
392
|
const fork = claudeForkTransport(id, runtimeDir);
|
|
@@ -371,12 +409,89 @@ export async function deliverViaSocketOrWake(id, text, mid, coldWake, unprovenEr
|
|
|
371
409
|
return { ok: false, error: unprovenError };
|
|
372
410
|
return coldWake();
|
|
373
411
|
}
|
|
412
|
+
const RENDEZVOUS_INTERRUPT_WALL_MS = 10_000;
|
|
413
|
+
const RENDEZVOUS_INTERRUPT_SETTLE_MS = 15_000;
|
|
414
|
+
// @@@ interruptViaRendezvous - one `interrupt` line to the session's LIVE rendezvous listener, confirmed by the
|
|
415
|
+
// shim's own answer ([[shim-runtime]] runs the host's native abort — pi's ctx.abort(), opencode's
|
|
416
|
+
// session.abort — and writes interrupt-done / interrupt-rejected). Unlike a reply poke, whose durable copy is
|
|
417
|
+
// the timeline, an interrupt has nothing to fall back on: silence is a loud failure, never an optimistic ok.
|
|
418
|
+
// A generative shim serves its socket only while a turn process is alive, so a dead listener means no turn
|
|
419
|
+
// is running — nothing to interrupt — and an unproven probe sends nothing. `settle` additionally waits for
|
|
420
|
+
// that listener to go dead: for a one-turn-per-process adapter the abort ends the process, and confirming
|
|
421
|
+
// only then means a delivery that follows the interrupt wakes cold instead of poking an exiting agent.
|
|
422
|
+
export async function interruptViaRendezvous(id, harness, opts = {}) {
|
|
423
|
+
const probe = await rendezvousListening(id);
|
|
424
|
+
if (probe === 'dead')
|
|
425
|
+
return { ok: false, error: `no ${harness} turn is running for session ${id} - nothing to interrupt` };
|
|
426
|
+
if (probe === 'unproven')
|
|
427
|
+
return { ok: false, error: `${harness} rendezvous listener for session ${id} is unproven - interrupt NOT sent` };
|
|
428
|
+
const answered = await sendRendezvousInterrupt(id, harness);
|
|
429
|
+
if (!answered.ok || !opts.settle)
|
|
430
|
+
return answered;
|
|
431
|
+
const deadline = Date.now() + RENDEZVOUS_INTERRUPT_SETTLE_MS;
|
|
432
|
+
for (;;) {
|
|
433
|
+
const state = await rendezvousListening(id);
|
|
434
|
+
if (state === 'dead')
|
|
435
|
+
return { ok: true };
|
|
436
|
+
if (Date.now() >= deadline)
|
|
437
|
+
return { ok: false, error: `${harness} confirmed the interrupt but its turn process for session ${id} is still ${state === 'live' ? 'serving' : 'unproven'} after ${RENDEZVOUS_INTERRUPT_SETTLE_MS}ms` };
|
|
438
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
function sendRendezvousInterrupt(id, harness) {
|
|
442
|
+
return new Promise((resolve) => {
|
|
443
|
+
let settled = false;
|
|
444
|
+
let c;
|
|
445
|
+
const done = (r) => {
|
|
446
|
+
if (settled)
|
|
447
|
+
return;
|
|
448
|
+
settled = true;
|
|
449
|
+
clearTimeout(wall);
|
|
450
|
+
try {
|
|
451
|
+
c?.destroy();
|
|
452
|
+
}
|
|
453
|
+
catch { /* */ }
|
|
454
|
+
resolve(r);
|
|
455
|
+
};
|
|
456
|
+
const wall = setTimeout(() => done({ ok: false, error: `${harness} did not confirm the interrupt for session ${id} within ${RENDEZVOUS_INTERRUPT_WALL_MS}ms (a shim materialized before interrupt support never answers - rerun spex materialize)` }), RENDEZVOUS_INTERRUPT_WALL_MS);
|
|
457
|
+
try {
|
|
458
|
+
c = createConnection({ path: rvSock(id) });
|
|
459
|
+
}
|
|
460
|
+
catch (e) {
|
|
461
|
+
done({ ok: false, error: `rendezvous socket connect threw: ${String(e)} - interrupt NOT sent` });
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
c.on('error', (e) => done({ ok: false, error: `rendezvous socket error: ${e?.code || String(e)} - interrupt NOT confirmed` }));
|
|
465
|
+
c.on('close', () => done({ ok: false, error: `rendezvous connection closed before ${harness} answered the interrupt` }));
|
|
466
|
+
c.on('connect', () => c.write(`${JSON.stringify({ type: 'interrupt' })}\n`));
|
|
467
|
+
let buf = '';
|
|
468
|
+
c.on('data', (chunk) => {
|
|
469
|
+
buf += chunk.toString('utf8');
|
|
470
|
+
let nl;
|
|
471
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
472
|
+
const line = buf.slice(0, nl);
|
|
473
|
+
buf = buf.slice(nl + 1);
|
|
474
|
+
let msg = {};
|
|
475
|
+
try {
|
|
476
|
+
msg = JSON.parse(line);
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if (msg.type === 'interrupt-done')
|
|
482
|
+
return done({ ok: true });
|
|
483
|
+
if (msg.type === 'interrupt-rejected')
|
|
484
|
+
return done({ ok: false, error: `${harness} rejected the interrupt: ${msg.error || 'no reason given'}` });
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
}
|
|
374
489
|
// The JSON-RPC the delivery handshake speaks, in send order. Method names + param shapes are pinned to codex
|
|
375
490
|
// 0.142.3 (`codex app-server generate-ts` → ClientRequest.ts / v2/*Params.ts): the visible TUI is launched with
|
|
376
491
|
// `codex --remote unix://<sock>`, so its thread is ALREADY loaded in this server — we must NOT `thread/resume`
|
|
377
492
|
// it (that re-loads a thread the live TUI already owns). Instead `thread/loaded/list` PROVES the captured thread
|
|
378
|
-
// is the one the pane is showing
|
|
379
|
-
//
|
|
493
|
+
// is the one the pane is showing. The failure observer owns the active native turn id from app-server
|
|
494
|
+
// notifications; delivery does not read the thread or replay its history — see codexInjectMessage.
|
|
380
495
|
const codexTextInput = (text) => [{ type: 'text', text, text_elements: [] }];
|
|
381
496
|
export function codexHandshakeMessages(threadId) {
|
|
382
497
|
return [
|
|
@@ -390,7 +505,6 @@ export function codexHandshakeMessages(threadId) {
|
|
|
390
505
|
},
|
|
391
506
|
{ method: 'initialized', params: {} },
|
|
392
507
|
{ id: 2, method: 'thread/loaded/list', params: {} },
|
|
393
|
-
{ id: 3, method: 'thread/read', params: { threadId, includeTurns: true } },
|
|
394
508
|
];
|
|
395
509
|
}
|
|
396
510
|
// the message that injects `text`. STEER (turn/steer) when an active turn id is known — codex processes it
|
|
@@ -406,7 +520,9 @@ export function codexInjectMessage(threadId, text, cwd, activeTurnId, id = 4, cl
|
|
|
406
520
|
return { id, method: 'turn/steer', params: { threadId, input: codexTextInput(text), expectedTurnId: activeTurnId, ...marker } };
|
|
407
521
|
return { id, method: 'turn/start', params: { threadId, input: codexTextInput(text), ...(cwd ? { cwd } : {}), ...marker } };
|
|
408
522
|
}
|
|
409
|
-
// the in-progress turn id from a `thread/read{includeTurns}` result, or null when the thread is idle.
|
|
523
|
+
// the in-progress turn id from a `thread/read{includeTurns}` result, or null when the thread is idle. This is
|
|
524
|
+
// retained for ownership/replay probes; delivery uses codexObservedActiveTurnId because it must not request the
|
|
525
|
+
// full history. With
|
|
410
526
|
// includeTurns the Thread carries its turns, each with a TurnStatus ("completed"|"interrupted"|"failed"|
|
|
411
527
|
// "inProgress"); the live turn is the `inProgress` one and its id is exactly what turn/steer's precondition needs.
|
|
412
528
|
export function activeTurnIdFromThread(readResult) {
|
|
@@ -415,6 +531,20 @@ export function activeTurnIdFromThread(readResult) {
|
|
|
415
531
|
const active = turns.find((t) => t?.status === 'inProgress');
|
|
416
532
|
return active?.id ?? null;
|
|
417
533
|
}
|
|
534
|
+
// The app-server's lightweight thread/read intentionally omits the turn list. Keep the native active id
|
|
535
|
+
// observed by the failure subscription so a send can steer without replaying the whole conversation.
|
|
536
|
+
const codexActiveTurns = new Map();
|
|
537
|
+
export function codexObservedActiveTurnId(threadId) {
|
|
538
|
+
return codexActiveTurns.get(threadId) || null;
|
|
539
|
+
}
|
|
540
|
+
function rememberCodexActiveTurn(threadId, turnId) {
|
|
541
|
+
if (typeof turnId === 'string' && turnId)
|
|
542
|
+
codexActiveTurns.set(threadId, turnId);
|
|
543
|
+
}
|
|
544
|
+
function forgetCodexActiveTurn(threadId, turnId) {
|
|
545
|
+
if (turnId === undefined || codexActiveTurns.get(threadId) === turnId)
|
|
546
|
+
codexActiveTurns.delete(threadId);
|
|
547
|
+
}
|
|
418
548
|
// The app-server and the visible `--remote … resume` TUI share ONE socket, so they MUST be the SAME codex
|
|
419
549
|
// install — a version split across that socket breaks the thread/start→resume handoff (an app-server on one
|
|
420
550
|
// version creates a thread a differently-versioned resume can't find; an old-enough app-server can't serve
|
|
@@ -539,8 +669,13 @@ export function codexLaunchCommand(id, codexCmd = 'codex', serverCmd, dir = runt
|
|
|
539
669
|
`if [ "$1" = "--resume" ]; then`,
|
|
540
670
|
` tid=$2`,
|
|
541
671
|
...(attachTui ? [] : [
|
|
542
|
-
// A headless
|
|
543
|
-
//
|
|
672
|
+
// A headless resume has no TUI to reload the thread. The shared app-server evicts an idle thread from its
|
|
673
|
+
// loaded set, and headless readiness proves online only for a RESIDENT thread, so resume must reopen it
|
|
674
|
+
// here — the load the visible TUI's `resume "$tid"` would have done — or readiness times out for a thread
|
|
675
|
+
// that is fine on disk. codex-generation-session above already ensured the app-server, so `$sock` is live.
|
|
676
|
+
` ${SPEX} internal codex-reopen "$sock" "$tid" || exit 1`,
|
|
677
|
+
// A headless forced reopen with NO thread id and no prompt has nothing to attach and nothing to launch.
|
|
678
|
+
// Keep it a no-op instead of calling codex-launch without a prompt (which would mint an unrelated thread).
|
|
544
679
|
`elif [ "$#" -eq 0 ]; then`,
|
|
545
680
|
` exit 0`,
|
|
546
681
|
]),
|
|
@@ -556,7 +691,11 @@ export function codexLaunchCommand(id, codexCmd = 'codex', serverCmd, dir = runt
|
|
|
556
691
|
// launch attaches to the thread codex-launch just made; a reopen resumes an existing one), so it injects
|
|
557
692
|
// the same per-thread identity through codex's own `-c` override. Same rule, both entry points: whoever
|
|
558
693
|
// creates a context stamps that context's record id, and nothing downstream re-derives it.
|
|
559
|
-
|
|
694
|
+
// A remote Codex TUI cannot inherit the launch shell's cwd: when `tui.resume_cwd = "current"` is
|
|
695
|
+
// configured, Codex requires an explicit workspace root for every `--remote` invocation. Keep the
|
|
696
|
+
// directory tied to the generated script's actual pane cwd so linked worktrees with spaces remain one
|
|
697
|
+
// argument and the resumed thread loads the same project context as thread/start above.
|
|
698
|
+
...(attachTui ? [`exec ${codexCmd}${tuiBypass} -c ${shQuote(`shell_environment_policy.set.SPEXCODE_SESSION_ID=${id}`)} --cd "$PWD" --remote unix://"$sock" resume "$tid"`] : []),
|
|
560
699
|
].join('\n');
|
|
561
700
|
return `bash -lc ${shQuote(script)} spexcode-codex`;
|
|
562
701
|
}
|
|
@@ -590,7 +729,7 @@ function encodeWsFrame(opcode, payload) {
|
|
|
590
729
|
return Buffer.concat([header, mask, masked]);
|
|
591
730
|
}
|
|
592
731
|
const wsText = (s) => encodeWsFrame(0x1, Buffer.from(s, 'utf8'));
|
|
593
|
-
function drainWsFrames(s, conn, onText) {
|
|
732
|
+
function drainWsFrames(s, conn, onText, acceptPayload = () => true) {
|
|
594
733
|
for (;;) {
|
|
595
734
|
if (s.buf.length < 2)
|
|
596
735
|
return false;
|
|
@@ -635,7 +774,7 @@ function drainWsFrames(s, conn, onText) {
|
|
|
635
774
|
s.fragBuf = payload;
|
|
636
775
|
}
|
|
637
776
|
if (fin) {
|
|
638
|
-
if (s.fragOp === 0x1)
|
|
777
|
+
if (s.fragOp === 0x1 && acceptPayload(s.fragBuf))
|
|
639
778
|
onText(s.fragBuf.toString('utf8'));
|
|
640
779
|
s.fragBuf = Buffer.alloc(0);
|
|
641
780
|
s.fragOp = 0;
|
|
@@ -644,6 +783,9 @@ function drainWsFrames(s, conn, onText) {
|
|
|
644
783
|
}
|
|
645
784
|
const WS_UPGRADE = (key) => `GET /rpc HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: ${key}\r\n\r\n`;
|
|
646
785
|
const wsInitialize = { id: 1, method: 'initialize', params: { clientInfo: { name: 'spexcode', title: 'SpexCode', version: '0.0.0' }, capabilities: { experimentalApi: true, requestAttestation: false } } };
|
|
786
|
+
// Native Codex can take 15-17s to answer thread/resume under a loaded app-server. A shorter
|
|
787
|
+
// deadline creates a retry storm in the session reconciler, not a useful failure signal.
|
|
788
|
+
export const CODEX_TURN_OBSERVER_SUBSCRIBE_MS = 30_000;
|
|
647
789
|
// Codex has no StopFailure hook, but its app-server has the stronger native signal: every subscribed turn ends
|
|
648
790
|
// with turn/completed and a final completed/interrupted/failed status. Rejoin is atomic with subscription, so
|
|
649
791
|
// this observer also survives backend replacement; a thread already in systemError is reconciled from its
|
|
@@ -665,6 +807,9 @@ export function codexTurnFailureObserver(rec, onFailure) {
|
|
|
665
807
|
const frames = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) };
|
|
666
808
|
let upgraded = false, settled = false;
|
|
667
809
|
let reconciliationTimer = null;
|
|
810
|
+
let readySettled = false;
|
|
811
|
+
let resolveReady;
|
|
812
|
+
const ready = new Promise((resolve) => { resolveReady = resolve; });
|
|
668
813
|
let resolveClosed;
|
|
669
814
|
const closed = new Promise((resolve) => { resolveClosed = resolve; });
|
|
670
815
|
const cancelReconciliation = () => {
|
|
@@ -677,6 +822,10 @@ export function codexTurnFailureObserver(rec, onFailure) {
|
|
|
677
822
|
if (settled)
|
|
678
823
|
return;
|
|
679
824
|
settled = true;
|
|
825
|
+
if (!readySettled) {
|
|
826
|
+
readySettled = true;
|
|
827
|
+
resolveReady(false);
|
|
828
|
+
}
|
|
680
829
|
clearTimeout(timer);
|
|
681
830
|
cancelReconciliation();
|
|
682
831
|
try {
|
|
@@ -685,7 +834,7 @@ export function codexTurnFailureObserver(rec, onFailure) {
|
|
|
685
834
|
catch { }
|
|
686
835
|
resolveClosed(reason);
|
|
687
836
|
};
|
|
688
|
-
const timer = setTimeout(() => finish(
|
|
837
|
+
const timer = setTimeout(() => finish(`Codex turn observer did not subscribe within ${CODEX_TURN_OBSERVER_SUBSCRIBE_MS}ms`), CODEX_TURN_OBSERVER_SUBSCRIBE_MS);
|
|
689
838
|
timer.unref?.();
|
|
690
839
|
const send = (message) => conn.write(wsText(JSON.stringify(message)));
|
|
691
840
|
const report = (turn, fallbackMessage) => {
|
|
@@ -702,6 +851,11 @@ export function codexTurnFailureObserver(rec, onFailure) {
|
|
|
702
851
|
conn.on('close', () => finish('Codex turn observer connection closed'));
|
|
703
852
|
conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))));
|
|
704
853
|
const handle = (json) => {
|
|
854
|
+
// A resumed Codex thread can stream large goal/progress notifications while the turn runs. They are
|
|
855
|
+
// irrelevant to this failure observer; avoid JSON.parse on those payloads so one active turn cannot make
|
|
856
|
+
// the backend spend its memory and CPU re-materializing a transcript it does not use.
|
|
857
|
+
if (json.includes('"method"') && !json.includes('"method":"turn/started"') && !json.includes('"method":"turn/completed"'))
|
|
858
|
+
return;
|
|
705
859
|
let message;
|
|
706
860
|
try {
|
|
707
861
|
message = JSON.parse(json);
|
|
@@ -721,10 +875,18 @@ export function codexTurnFailureObserver(rec, onFailure) {
|
|
|
721
875
|
}
|
|
722
876
|
if (message.id === 2 && message.result) {
|
|
723
877
|
clearTimeout(timer);
|
|
878
|
+
if (!readySettled) {
|
|
879
|
+
readySettled = true;
|
|
880
|
+
resolveReady(true);
|
|
881
|
+
}
|
|
724
882
|
const result = message.result;
|
|
883
|
+
const initialTurns = result.initialTurnsPage?.data;
|
|
884
|
+
const initialActive = Array.isArray(initialTurns)
|
|
885
|
+
? initialTurns.find((turn) => turn?.status === 'inProgress')
|
|
886
|
+
: null;
|
|
887
|
+
rememberCodexActiveTurn(threadId, initialActive?.id);
|
|
725
888
|
if (result.thread?.status?.type === 'systemError') {
|
|
726
|
-
const
|
|
727
|
-
const latest = Array.isArray(turns) ? turns[0] : null;
|
|
889
|
+
const latest = Array.isArray(initialTurns) ? initialTurns[0] : null;
|
|
728
890
|
// Give a concurrently-starting turn's native notification precedence over this historical snapshot.
|
|
729
891
|
reconciliationTimer = setTimeout(() => {
|
|
730
892
|
reconciliationTimer = null;
|
|
@@ -736,12 +898,15 @@ export function codexTurnFailureObserver(rec, onFailure) {
|
|
|
736
898
|
}
|
|
737
899
|
if (message.method === 'turn/started') {
|
|
738
900
|
const params = message.params;
|
|
739
|
-
if (params?.threadId === threadId)
|
|
901
|
+
if (params?.threadId === threadId) {
|
|
902
|
+
rememberCodexActiveTurn(threadId, params.turn?.id);
|
|
740
903
|
cancelReconciliation();
|
|
904
|
+
}
|
|
741
905
|
}
|
|
742
906
|
if (message.method === 'turn/completed') {
|
|
743
907
|
const params = message.params;
|
|
744
908
|
if (params?.threadId === threadId) {
|
|
909
|
+
forgetCodexActiveTurn(threadId, params.turn?.id);
|
|
745
910
|
cancelReconciliation();
|
|
746
911
|
report(params.turn);
|
|
747
912
|
}
|
|
@@ -760,10 +925,13 @@ export function codexTurnFailureObserver(rec, onFailure) {
|
|
|
760
925
|
frames.buf = frames.buf.slice(split + 4);
|
|
761
926
|
send(wsInitialize);
|
|
762
927
|
}
|
|
763
|
-
if (drainWsFrames(frames, conn, handle)
|
|
928
|
+
if (drainWsFrames(frames, conn, handle, (payload) => {
|
|
929
|
+
const method = Buffer.from('"method"');
|
|
930
|
+
return !payload.includes(method) || payload.includes(Buffer.from('"turn/started"')) || payload.includes(Buffer.from('"turn/completed"'));
|
|
931
|
+
}))
|
|
764
932
|
finish('Codex app-server closed the turn observer');
|
|
765
933
|
});
|
|
766
|
-
return { close: () => finish(null), closed };
|
|
934
|
+
return { close: () => finish(null), closed, ready };
|
|
767
935
|
}
|
|
768
936
|
function codexThreadMutation(sock, method, threadId, fence, turnId, budgetMs = CODEX_MUTATION_BASE_MS) {
|
|
769
937
|
const generationError = () => fence && codexRuntimeGeneration(fence.dir, fence.endpoint) !== fence.generation
|
|
@@ -860,6 +1028,69 @@ const CODEX_MUTATION_CENSUS_MS = 15_000;
|
|
|
860
1028
|
const CODEX_MUTATION_BASE_MS = 15_000;
|
|
861
1029
|
const CODEX_ARCHIVE_FLUSH_FLOOR_BYTES_PER_MS = 1000;
|
|
862
1030
|
const codexArchiveBudgetMs = (bytes) => CODEX_MUTATION_BASE_MS + Math.ceil(bytes / CODEX_ARCHIVE_FLUSH_FLOOR_BYTES_PER_MS);
|
|
1031
|
+
// @@@ codexReopenThread - load an evicted thread back into the shared app-server WITHOUT running a turn. The
|
|
1032
|
+
// app-server evicts an idle thread from its in-memory loaded set, and codex-headless readiness/liveness proves
|
|
1033
|
+
// online only when the thread is resident (thread/loaded/list). A visible-TUI resume reloads it implicitly via
|
|
1034
|
+
// `resume "$tid"`; headless has no TUI, so its resume must issue this `thread/resume` itself or readiness times
|
|
1035
|
+
// out for a thread that is perfectly fine on disk. `excludeTurns` + a one-row page keeps a huge rollout from
|
|
1036
|
+
// streaming back — we only need the load, not the history. Idempotent: reopening an already-loaded thread is a
|
|
1037
|
+
// no-op the server answers at once.
|
|
1038
|
+
export function codexReopenThread(sock, threadId, budgetMs = 20_000) {
|
|
1039
|
+
return new Promise((resolve) => {
|
|
1040
|
+
const conn = createConnection(sock);
|
|
1041
|
+
const fs = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) };
|
|
1042
|
+
let upgraded = false, settled = false;
|
|
1043
|
+
const done = (r) => {
|
|
1044
|
+
if (settled)
|
|
1045
|
+
return;
|
|
1046
|
+
settled = true;
|
|
1047
|
+
clearTimeout(timer);
|
|
1048
|
+
try {
|
|
1049
|
+
conn.destroy();
|
|
1050
|
+
}
|
|
1051
|
+
catch { /* */ }
|
|
1052
|
+
resolve(r);
|
|
1053
|
+
};
|
|
1054
|
+
const timer = setTimeout(() => done({ ok: false, error: `codex app-server did not reopen thread ${threadId} within ${budgetMs}ms` }), budgetMs);
|
|
1055
|
+
conn.on('error', (e) => done({ ok: false, error: `codex app-server connection failed: ${rpcError(e)}` }));
|
|
1056
|
+
conn.on('close', () => done({ ok: false, error: 'codex app-server closed before thread/resume was answered' }));
|
|
1057
|
+
const send = (m) => conn.write(wsText(JSON.stringify(m)));
|
|
1058
|
+
conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))));
|
|
1059
|
+
const handle = (json) => {
|
|
1060
|
+
let m;
|
|
1061
|
+
try {
|
|
1062
|
+
m = JSON.parse(json);
|
|
1063
|
+
}
|
|
1064
|
+
catch {
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
if (m.error)
|
|
1068
|
+
return done({ ok: false, error: `codex app-server ${m.id ? `request ${m.id}` : 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}` });
|
|
1069
|
+
if (m.id === 1 && m.result) {
|
|
1070
|
+
send({ method: 'initialized', params: {} });
|
|
1071
|
+
return send({ id: 2, method: 'thread/resume', params: { threadId, excludeTurns: true, initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } } });
|
|
1072
|
+
}
|
|
1073
|
+
if (m.id === 2 && m.result)
|
|
1074
|
+
return done({ ok: true });
|
|
1075
|
+
};
|
|
1076
|
+
conn.on('data', (chunk) => {
|
|
1077
|
+
fs.buf = Buffer.concat([fs.buf, chunk]);
|
|
1078
|
+
if (!upgraded) {
|
|
1079
|
+
const i = fs.buf.indexOf('\r\n\r\n');
|
|
1080
|
+
if (i < 0)
|
|
1081
|
+
return;
|
|
1082
|
+
const head = fs.buf.slice(0, i).toString('utf8');
|
|
1083
|
+
if (!/^HTTP\/1\.1 101/.test(head))
|
|
1084
|
+
return done({ ok: false, error: 'codex app-server refused WebSocket upgrade for thread/resume' });
|
|
1085
|
+
upgraded = true;
|
|
1086
|
+
fs.buf = fs.buf.slice(i + 4);
|
|
1087
|
+
send(wsInitialize);
|
|
1088
|
+
}
|
|
1089
|
+
if (drainWsFrames(fs, conn, handle))
|
|
1090
|
+
done({ ok: false, error: 'codex app-server closed during thread/resume' });
|
|
1091
|
+
});
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
863
1094
|
// Codex treats an omitted or empty sourceKinds filter as "interactive" defaults. Cold proof must census the
|
|
864
1095
|
// entire native thread graph, including subAgent/thread-spawn rows that have no Spex record, so the adapter
|
|
865
1096
|
// supplies every protocol source kind explicitly for its thread/list calls.
|
|
@@ -1579,7 +1810,7 @@ export function codexThreadId(sock) {
|
|
|
1579
1810
|
// Resource ownership asks the adapter for what the shared server actually owns now. Records are joined later;
|
|
1580
1811
|
// they are never treated as references by themselves. A loaded thread is a control-plane reference and its
|
|
1581
1812
|
// fresh inProgress turn (the same predicate used by delivery) distinguishes active from addressable-idle.
|
|
1582
|
-
export function codexSharedRuntimeProbe(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)) {
|
|
1813
|
+
export function codexSharedRuntimeProbe(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir), referenceIds) {
|
|
1583
1814
|
const sock = endpoint.socketPath;
|
|
1584
1815
|
return (async () => {
|
|
1585
1816
|
// File presence is not process identity. A dead PID plus a stale socket file is the normal crash residue;
|
|
@@ -1676,11 +1907,19 @@ export function codexSharedRuntimeProbe(dir = runtimeRoot(), endpoint = legacyCo
|
|
|
1676
1907
|
// Continue with the complete paginated set, not just the first manager page.
|
|
1677
1908
|
if (!loadedIds.size)
|
|
1678
1909
|
return done({ healthy: true, references: [] });
|
|
1679
|
-
loadedIds.
|
|
1910
|
+
const wanted = referenceIds === undefined ? [...loadedIds] : [...loadedIds].filter((threadId) => referenceIds.includes(threadId));
|
|
1911
|
+
loadedIds.forEach((threadId) => references.set(threadId, { referenceId: threadId, turnPresence: 'unknown' }));
|
|
1912
|
+
// A draining generation deliberately has no native turn reads. Complete the census after recording
|
|
1913
|
+
// loaded ownership; leaving the request map empty would otherwise wait for the global timeout forever.
|
|
1914
|
+
if (!wanted.length)
|
|
1915
|
+
return done({ healthy: true, references: [...references.values()] });
|
|
1916
|
+
wanted.forEach((threadId) => {
|
|
1680
1917
|
const id = 100 + requests.size;
|
|
1681
|
-
references.set(threadId, { referenceId: threadId, turnPresence: 'unknown' });
|
|
1682
1918
|
requests.set(id, threadId);
|
|
1683
|
-
|
|
1919
|
+
// Ownership sampling only needs the native status, never the persisted turn history. The old
|
|
1920
|
+
// includeTurns:true request made a periodic resource report replay every loaded conversation and
|
|
1921
|
+
// was the direct source of 5s probe timeouts on the draining generation.
|
|
1922
|
+
send({ id, method: 'thread/read', params: { threadId, includeTurns: false } });
|
|
1684
1923
|
});
|
|
1685
1924
|
return;
|
|
1686
1925
|
}
|
|
@@ -1689,9 +1928,10 @@ export function codexSharedRuntimeProbe(dir = runtimeRoot(), endpoint = legacyCo
|
|
|
1689
1928
|
requests.delete(m.id);
|
|
1690
1929
|
const thread = m.result.thread;
|
|
1691
1930
|
const turnId = activeTurnIdFromThread(m.result);
|
|
1931
|
+
const nativeStatus = typeof thread?.status === 'string' ? thread.status : thread?.status?.type;
|
|
1692
1932
|
references.set(threadId, {
|
|
1693
1933
|
referenceId: threadId,
|
|
1694
|
-
turnPresence:
|
|
1934
|
+
turnPresence: turnId || nativeStatus === 'active' ? 'active' : nativeStatus === 'idle' ? 'idle' : 'unknown',
|
|
1695
1935
|
...(turnId ? { turnId } : {}),
|
|
1696
1936
|
});
|
|
1697
1937
|
if (!requests.size)
|
|
@@ -1812,7 +2052,7 @@ const codexTurnConfirmMs = () => {
|
|
|
1812
2052
|
function sendCodexAppServerTurn(sock, threadId, text, cwd, clientUserMessageId) {
|
|
1813
2053
|
return new Promise((resolve) => {
|
|
1814
2054
|
const conn = createConnection(sock);
|
|
1815
|
-
const hs = codexHandshakeMessages(threadId); // [initialize(1), initialized, thread/loaded/list(2)
|
|
2055
|
+
const hs = codexHandshakeMessages(threadId); // [initialize(1), initialized, thread/loaded/list(2)]
|
|
1816
2056
|
let buf = Buffer.alloc(0), upgraded = false, settled = false;
|
|
1817
2057
|
let fragOp = 0, fragBuf = Buffer.alloc(0);
|
|
1818
2058
|
let steering = false; // the id-4 message we sent was a steer → an expectedTurnId race may retry as start(5)
|
|
@@ -1857,14 +2097,11 @@ function sendCodexAppServerTurn(sock, threadId, text, cwd, clientUserMessageId)
|
|
|
1857
2097
|
send(hs[1]);
|
|
1858
2098
|
return send(hs[2]);
|
|
1859
2099
|
} // initialize ack → initialized → ask which threads are loaded
|
|
1860
|
-
if (m.id === 2 && m.result) { // loaded-thread list → confirm OUR thread is live, then
|
|
2100
|
+
if (m.id === 2 && m.result) { // loaded-thread list → confirm OUR thread is live, then inject
|
|
1861
2101
|
const loaded = m.result?.data;
|
|
1862
2102
|
if (Array.isArray(loaded) && !loaded.includes(threadId))
|
|
1863
2103
|
return done({ ok: false, error: `Codex thread ${threadId} is not loaded in the app-server (loaded: ${loaded.join(', ') || 'none'}) — immediate poke not accepted` });
|
|
1864
|
-
|
|
1865
|
-
}
|
|
1866
|
-
if (m.id === 3 && m.result) { // thread read → in-progress turn? steer into it; else start a new one
|
|
1867
|
-
const turnId = activeTurnIdFromThread(m.result);
|
|
2104
|
+
const turnId = codexObservedActiveTurnId(threadId);
|
|
1868
2105
|
steering = !!turnId;
|
|
1869
2106
|
return send(codexInjectMessage(threadId, text, cwd, turnId, 4, clientUserMessageId)); // id 4: turn/steer the live turn, or turn/start
|
|
1870
2107
|
}
|
|
@@ -2106,6 +2343,157 @@ export function removeManagedBlock(file, comment = ['<!-- ', ' -->'], deleteIfEm
|
|
|
2106
2343
|
}
|
|
2107
2344
|
writeFileSync(file, out);
|
|
2108
2345
|
}
|
|
2346
|
+
// @@@ managed-json-hooks - the JSON counterpart of writeManagedBlock/removeManagedBlock, for a shim file the
|
|
2347
|
+
// host agent SHARES with the user. `.claude/settings.json` (and `.codex/hooks.json`, `.zcode/settings.json`)
|
|
2348
|
+
// is the user's project config — permissions, env, statusLine, their own hooks — that merely HAPPENS to be
|
|
2349
|
+
// where the harness also discovers ours. A whole-file write there is silent data loss, and a whole-file
|
|
2350
|
+
// delete on uninstall makes it permanent for an untracked (gitignored) file. JSON has no comment syntax, so
|
|
2351
|
+
// the sentinel that scopes ownership is the hook COMMAND itself: every entry we write invokes `dispatch.sh`,
|
|
2352
|
+
// and ONLY such entries are ever removed. Everything else round-trips — other keys, other events, foreign
|
|
2353
|
+
// hook groups, and the user's half of a group that mixes both.
|
|
2354
|
+
const isOurHookEntry = (entry) => !!entry && typeof entry === 'object' && typeof entry.command === 'string'
|
|
2355
|
+
&& entry.command.includes('dispatch.sh');
|
|
2356
|
+
// drop OUR entries from one event's group list, keeping every foreign group byte-for-byte and keeping the
|
|
2357
|
+
// user's half of a mixed group. A group whose entries were all ours disappears with it.
|
|
2358
|
+
function stripOurHookGroups(list) {
|
|
2359
|
+
if (!Array.isArray(list))
|
|
2360
|
+
return Array.isArray(list) ? list : [];
|
|
2361
|
+
const kept = [];
|
|
2362
|
+
for (const group of list) {
|
|
2363
|
+
const inner = group?.hooks;
|
|
2364
|
+
if (!group || typeof group !== 'object' || !Array.isArray(inner)) {
|
|
2365
|
+
kept.push(group);
|
|
2366
|
+
continue;
|
|
2367
|
+
}
|
|
2368
|
+
const rest = inner.filter((e) => !isOurHookEntry(e));
|
|
2369
|
+
if (rest.length === inner.length)
|
|
2370
|
+
kept.push(group); // nothing of ours in here
|
|
2371
|
+
else if (rest.length)
|
|
2372
|
+
kept.push({ ...group, hooks: rest }); // mixed group — keep their half
|
|
2373
|
+
}
|
|
2374
|
+
return kept;
|
|
2375
|
+
}
|
|
2376
|
+
// read a shared shim file as JSON. Absent → {} (we are about to create it). UNPARSEABLE → throw: the file is
|
|
2377
|
+
// the user's, and overwriting prose we cannot read is exactly the data loss this primitive exists to prevent
|
|
2378
|
+
// (the harness itself cannot read it either, so the repair is the same one they already need).
|
|
2379
|
+
function readSharedShim(file) {
|
|
2380
|
+
if (!existsSync(file))
|
|
2381
|
+
return {};
|
|
2382
|
+
const raw = readFileSync(file, 'utf8');
|
|
2383
|
+
if (!raw.trim())
|
|
2384
|
+
return {};
|
|
2385
|
+
// (re-serialization NORMALIZES the host's formatting — 2-space, one member per line. Their CONTENT all
|
|
2386
|
+
// round-trips; their layout does not, because no JSON writer can reproduce hand-compacted objects. The one
|
|
2387
|
+
// byte-level convention we DO honor is the trailing newline, below.)
|
|
2388
|
+
try {
|
|
2389
|
+
const parsed = JSON.parse(raw);
|
|
2390
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
2391
|
+
throw new Error('not a JSON object');
|
|
2392
|
+
return parsed;
|
|
2393
|
+
}
|
|
2394
|
+
catch (e) {
|
|
2395
|
+
throw new Error(`it is not readable JSON (${e.message}). SpexCode co-owns only its own hook entries in this file and will not overwrite content it cannot parse — fix the JSON (your harness cannot read it either), then re-run \`spex materialize\`.`);
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
// does this file end with a newline today? A host file keeps its own convention; a file we are about to
|
|
2399
|
+
// create gets the POSIX one. (Absent → true, so a fresh shim is newline-terminated.)
|
|
2400
|
+
function trailingNewline(file) {
|
|
2401
|
+
if (!existsSync(file))
|
|
2402
|
+
return true;
|
|
2403
|
+
try {
|
|
2404
|
+
return /\n$/.test(readFileSync(file, 'utf8'));
|
|
2405
|
+
}
|
|
2406
|
+
catch {
|
|
2407
|
+
return true;
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
const hostHooksOf = (host) => {
|
|
2411
|
+
const h = host.hooks;
|
|
2412
|
+
return h && typeof h === 'object' && !Array.isArray(h) ? h : {};
|
|
2413
|
+
};
|
|
2414
|
+
// MERGE our per-event hook groups into a shared shim file: our previous entries are stripped first (so a
|
|
2415
|
+
// changed dispatch path or event set self-heals instead of accumulating), then re-appended AFTER the user's
|
|
2416
|
+
// groups for that event. Every other key keeps its value and its position.
|
|
2417
|
+
export function writeManagedJsonHooks(file, hooks) {
|
|
2418
|
+
const eol = trailingNewline(file) ? '\n' : '';
|
|
2419
|
+
const host = readSharedShim(file);
|
|
2420
|
+
const merged = {};
|
|
2421
|
+
for (const [event, list] of Object.entries(hostHooksOf(host))) {
|
|
2422
|
+
const kept = stripOurHookGroups(list);
|
|
2423
|
+
if (kept.length)
|
|
2424
|
+
merged[event] = kept;
|
|
2425
|
+
}
|
|
2426
|
+
for (const [event, groups] of Object.entries(hooks))
|
|
2427
|
+
merged[event] = [...(merged[event] ?? []), ...groups];
|
|
2428
|
+
return writeFileIfChanged(file, JSON.stringify({ ...host, hooks: merged }, null, 2) + eol);
|
|
2429
|
+
}
|
|
2430
|
+
// the INVERSE: strip our entries, leave every other byte of meaning intact. The file is REMOVED only when
|
|
2431
|
+
// nothing of the user's remains ({} after our entries go) — the same deleteIfEmpty rule removeManagedBlock
|
|
2432
|
+
// applies to a wholly-ours CLAUDE.md, and safe here only because the write half no longer clobbers.
|
|
2433
|
+
export function removeManagedJsonHooks(file) {
|
|
2434
|
+
if (!existsSync(file))
|
|
2435
|
+
return;
|
|
2436
|
+
const eol = trailingNewline(file) ? '\n' : '';
|
|
2437
|
+
let host;
|
|
2438
|
+
try {
|
|
2439
|
+
host = readSharedShim(file);
|
|
2440
|
+
}
|
|
2441
|
+
catch {
|
|
2442
|
+
return;
|
|
2443
|
+
} // unparseable → not provably ours, leave it alone
|
|
2444
|
+
const rest = { ...host };
|
|
2445
|
+
const merged = {};
|
|
2446
|
+
for (const [event, list] of Object.entries(hostHooksOf(host))) {
|
|
2447
|
+
const kept = stripOurHookGroups(list);
|
|
2448
|
+
if (kept.length)
|
|
2449
|
+
merged[event] = kept;
|
|
2450
|
+
}
|
|
2451
|
+
if (Object.keys(merged).length)
|
|
2452
|
+
rest.hooks = merged;
|
|
2453
|
+
else
|
|
2454
|
+
delete rest.hooks;
|
|
2455
|
+
if (!Object.keys(rest).length) {
|
|
2456
|
+
rmSync(file, { force: true });
|
|
2457
|
+
return;
|
|
2458
|
+
}
|
|
2459
|
+
writeFileIfChanged(file, JSON.stringify(rest, null, 2) + eol);
|
|
2460
|
+
}
|
|
2461
|
+
// does anything of the USER's survive in this shared shim file — the JSON analogue of the contract files'
|
|
2462
|
+
// host-content test ([[residence]])? Everything except our identity-stamped hook entries counts. This is what
|
|
2463
|
+
// decides the file's residence: wholly ours → hidden by the tree's ignore block exactly like any other machine
|
|
2464
|
+
// fact; carrying their content → left visible, because hiding a file the user owns is data-loss shaped.
|
|
2465
|
+
export function sharedShimHasHostContent(file) {
|
|
2466
|
+
if (!existsSync(file))
|
|
2467
|
+
return false;
|
|
2468
|
+
let host;
|
|
2469
|
+
try {
|
|
2470
|
+
host = readSharedShim(file);
|
|
2471
|
+
}
|
|
2472
|
+
catch {
|
|
2473
|
+
return true;
|
|
2474
|
+
} // unreadable → assume theirs
|
|
2475
|
+
const rest = { ...host };
|
|
2476
|
+
delete rest.hooks;
|
|
2477
|
+
if (Object.keys(rest).length)
|
|
2478
|
+
return true;
|
|
2479
|
+
return Object.values(hostHooksOf(host)).some((list) => stripOurHookGroups(list).length > 0);
|
|
2480
|
+
}
|
|
2481
|
+
// the identity stamp on every generated skill/agent file. It is what lets BOTH halves of the pass tell our
|
|
2482
|
+
// artifact from a same-named file the user wrote: the erase phase refuses to delete an unstamped file, and
|
|
2483
|
+
// the write phase refuses to overwrite one ([[harness-delivery]]).
|
|
2484
|
+
export const GENERATED_MARK = '<!-- spexcode:generated -->';
|
|
2485
|
+
// is this path ours to replace or remove? An absent file is (nothing to lose); a present one only when it
|
|
2486
|
+
// carries the stamp. Unreadable → not provably ours.
|
|
2487
|
+
export function isGeneratedArtifact(file) {
|
|
2488
|
+
if (!existsSync(file))
|
|
2489
|
+
return true;
|
|
2490
|
+
try {
|
|
2491
|
+
return readFileSync(file, 'utf8').includes(GENERATED_MARK);
|
|
2492
|
+
}
|
|
2493
|
+
catch {
|
|
2494
|
+
return false;
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2109
2497
|
// the shim for one harness: every event → `SPEX='…' bash <dispatch> <harnessId> <Event>`. The harness id is
|
|
2110
2498
|
// baked in so dispatch.sh can export SPEXCODE_HARNESS (the detector for the shell side). SPEX is inherited by
|
|
2111
2499
|
// the cli-needing handlers.
|
|
@@ -2114,7 +2502,9 @@ function buildShim(id, events, dispatch, spex) {
|
|
|
2114
2502
|
const hooks = {};
|
|
2115
2503
|
for (const e of events)
|
|
2116
2504
|
hooks[e] = [{ hooks: [{ type: 'command', command: cmd(e) }] }];
|
|
2117
|
-
|
|
2505
|
+
// `content` stays the standalone rendering (what a shim file that is wholly ours would hold); `hooks` is
|
|
2506
|
+
// what the merge writer folds into the user's shared config file. Every buildShim harness is 'shared-json'.
|
|
2507
|
+
return { content: JSON.stringify({ hooks }, null, 2), hooks, cmd };
|
|
2118
2508
|
}
|
|
2119
2509
|
// ---------------------------------------------------------------------------------------------------------
|
|
2120
2510
|
// Codex trust — the codex-rs trusted_hash, reverse-engineered + pinned. Lives in the Codex adapter (it is a
|
|
@@ -2168,6 +2558,18 @@ function stripCodexTrustFor(cur, proj, hooksJson) {
|
|
|
2168
2558
|
}
|
|
2169
2559
|
return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/\n*$/, '');
|
|
2170
2560
|
}
|
|
2561
|
+
// @@@ assertCodexConfigParses - ~/.codex/config.toml has TWO uncoordinated writers, codex itself and this adapter,
|
|
2562
|
+
// and neither rewrite is something the other waits for. A body read mid-write (a line cut short) or one already
|
|
2563
|
+
// broken must never be written back: codex refuses the whole file, and every dispatched thread then dies at
|
|
2564
|
+
// config load. The check is on the bytes about to land, so stripping this project's own duplicate still heals.
|
|
2565
|
+
function assertCodexConfigParses(file, body) {
|
|
2566
|
+
try {
|
|
2567
|
+
parseToml(body);
|
|
2568
|
+
}
|
|
2569
|
+
catch (error) {
|
|
2570
|
+
throw new Error(`refusing to rewrite ${file}: it does not parse as TOML (${error instanceof Error ? error.message : String(error)}); the file is broken or another writer is mid-write — repair it, then rerun`);
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2171
2573
|
// additively stamp PROJECT trust (`[projects."<proj>"] trust_level = "trusted"`) AND the per-hook
|
|
2172
2574
|
// `trusted_hash` blocks for each event into the user's GLOBAL ~/.codex/config.toml, so a dispatched or
|
|
2173
2575
|
// self-launched codex trusts THIS project's config layer (enabling hook discovery) AND treats each hook as
|
|
@@ -2187,9 +2589,11 @@ export function writeCodexTrust(proj, events, cmdFor) {
|
|
|
2187
2589
|
}
|
|
2188
2590
|
const blk = `# spexcode:trust:${proj} (managed — do not edit)\n${lines.join('\n')}\n# spexcode:trust:end:${proj}`;
|
|
2189
2591
|
const cleaned = stripCodexTrustFor(existsSync(file) ? readFileSync(file, 'utf8') : '', proj, hooksJson);
|
|
2592
|
+
const next = cleaned ? `${cleaned}\n\n${blk}\n` : `${blk}\n`;
|
|
2593
|
+
assertCodexConfigParses(file, next);
|
|
2190
2594
|
if (!existsSync(home))
|
|
2191
2595
|
mkdirSync(home, { recursive: true });
|
|
2192
|
-
writeFileIfChanged(file,
|
|
2596
|
+
writeFileIfChanged(file, next);
|
|
2193
2597
|
return file;
|
|
2194
2598
|
}
|
|
2195
2599
|
// the inverse of writeCodexTrust: strip THIS project's codex trust from the GLOBAL config.toml — the SAME
|
|
@@ -2207,7 +2611,9 @@ function removeCodexTrust(proj) {
|
|
|
2207
2611
|
!cur.includes(`# spexcode:trust:${proj} `) && !cur.includes(`# spexcode:trust:end:${proj}`))
|
|
2208
2612
|
return;
|
|
2209
2613
|
const cleaned = stripCodexTrustFor(cur, proj, hooksJson);
|
|
2210
|
-
|
|
2614
|
+
const next = cleaned ? `${cleaned}\n` : '';
|
|
2615
|
+
assertCodexConfigParses(file, next);
|
|
2616
|
+
writeFileIfChanged(file, next);
|
|
2211
2617
|
}
|
|
2212
2618
|
// is this file git-tracked in proj? (guards cleanHarness's deleteIfEmpty; env-stripped git, never throws)
|
|
2213
2619
|
function isTrackedFile(proj, f) {
|
|
@@ -2233,21 +2639,35 @@ function cleanHarness(h, proj, arts, preserveProject = false) {
|
|
|
2233
2639
|
for (const f of h.contractFiles(proj))
|
|
2234
2640
|
removeManagedBlock(f, ['<!-- ', ' -->'], !isTrackedFile(proj, f));
|
|
2235
2641
|
const shim = h.shimFile(proj);
|
|
2236
|
-
|
|
2237
|
-
|
|
2642
|
+
// a SHARED config file is un-written entry by entry (and disappears only if nothing of the user's is left);
|
|
2643
|
+
// a file wholly ours goes whole, gated on its own dispatch.sh stamp.
|
|
2644
|
+
if (h.shimScope === 'tree' || !preserveProject) {
|
|
2645
|
+
if (h.shimOwnership === 'shared-json')
|
|
2646
|
+
removeManagedJsonHooks(shim);
|
|
2647
|
+
else if (existsSync(shim) && readFileSync(shim, 'utf8').includes('dispatch.sh'))
|
|
2648
|
+
rmSync(shim, { force: true });
|
|
2649
|
+
}
|
|
2238
2650
|
const anchor = h.worktreeHookAnchor(proj); // the linked-worktree anchor copy, same identity gate as the shim
|
|
2239
2651
|
if (anchor && existsSync(anchor) && readFileSync(anchor, 'utf8').includes('dispatch.sh'))
|
|
2240
2652
|
rmSync(anchor, { force: true });
|
|
2241
2653
|
if (!preserveProject)
|
|
2242
2654
|
h.removeTrust(proj);
|
|
2655
|
+
// the name sweep is identity-gated exactly like the stamp sweep: a live spec node named `distill` says
|
|
2656
|
+
// WHICH path to look at, never that the file sitting there is ours. A user's same-named skill (the write
|
|
2657
|
+
// half now refuses to overwrite it) must survive our uninstall.
|
|
2243
2658
|
const sd = h.skillDir(proj);
|
|
2659
|
+
const stamped = (f) => existsSync(f) && isGeneratedArtifact(f);
|
|
2244
2660
|
if (sd)
|
|
2245
|
-
for (const n of arts.skills)
|
|
2246
|
-
|
|
2661
|
+
for (const n of arts.skills) {
|
|
2662
|
+
if (stamped(join(sd, n, 'SKILL.md')))
|
|
2663
|
+
rmSync(join(sd, n), { recursive: true, force: true });
|
|
2664
|
+
}
|
|
2247
2665
|
const ad = h.agentDir(proj);
|
|
2248
2666
|
if (ad)
|
|
2249
|
-
for (const n of arts.agents)
|
|
2250
|
-
|
|
2667
|
+
for (const n of arts.agents) {
|
|
2668
|
+
if (stamped(join(ad, `${n}.md`)))
|
|
2669
|
+
rmSync(join(ad, `${n}.md`), { force: true });
|
|
2670
|
+
}
|
|
2251
2671
|
}
|
|
2252
2672
|
// ---------------------------------------------------------------------------------------------------------
|
|
2253
2673
|
// codex per-session liveness signal — a codex process live in the pane's DESCENDANT tree, NOT the pane's
|
|
@@ -2412,9 +2832,9 @@ export const claudeHarness = {
|
|
|
2412
2832
|
dispatchId: 'claude',
|
|
2413
2833
|
headless: false,
|
|
2414
2834
|
events: CLAUDE_EVENTS,
|
|
2415
|
-
ownsRendezvous: true, //
|
|
2835
|
+
ownsRendezvous: true, // claude's background daemon opens the rendezvous control socket (prompt delivery + liveness)
|
|
2416
2836
|
paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
|
|
2417
|
-
|
|
2837
|
+
transcript: claudeTranscript,
|
|
2418
2838
|
launchCmd: (_id, _rt, cmd) => claudeBaseCmd(cmd), // claude's full invocation IS its base command (the tail is appended by the caller)
|
|
2419
2839
|
baseCmd: claudeBaseCmd,
|
|
2420
2840
|
oneShotTurn: (prompt, cmd) => ({ command: `${claudeBaseCmd(cmd)} -p`, stdin: prompt }), // --print reads the prompt from stdin
|
|
@@ -2423,6 +2843,7 @@ export const claudeHarness = {
|
|
|
2423
2843
|
launchEnv: rendezvousLaunchEnv,
|
|
2424
2844
|
shimFile: (proj) => join(proj, '.claude', 'settings.json'),
|
|
2425
2845
|
shimScope: 'tree',
|
|
2846
|
+
shimOwnership: 'shared-json',
|
|
2426
2847
|
worktreeHookAnchor: () => null, // claude's shim already lives in the worktree (.claude/settings.json) — self-anchors, no root rewrite
|
|
2427
2848
|
contractFiles: (proj) => [join(proj, 'CLAUDE.md')],
|
|
2428
2849
|
skillDir: (proj) => join(proj, '.claude', 'skills'),
|
|
@@ -2503,7 +2924,13 @@ function codexRuntimeDescriptor(endpoint, runtimeDir) {
|
|
|
2503
2924
|
return result.ok ? { healthy: true, referenceIds: result.referenceIds } : { healthy: false, referenceIds: [], error: result.error };
|
|
2504
2925
|
},
|
|
2505
2926
|
mutationGuard: (targetReferenceId, opts) => codexMutationGuard(targetReferenceId, runtimeDir, opts, endpoint),
|
|
2506
|
-
probe: () =>
|
|
2927
|
+
probe: (referenceIds) => {
|
|
2928
|
+
// A draining generation keeps serving its bound sessions but is no longer a control-plane candidate.
|
|
2929
|
+
// Listing loaded ids preserves ownership evidence; reading native turn state on that old server only
|
|
2930
|
+
// adds load and can time out while the current generation is healthy.
|
|
2931
|
+
const generation = readCodexGenerationLedger(runtimeDir).generations[endpoint.id];
|
|
2932
|
+
return codexSharedRuntimeProbe(runtimeDir, endpoint, generation?.state === 'draining' ? [] : referenceIds);
|
|
2933
|
+
},
|
|
2507
2934
|
};
|
|
2508
2935
|
}
|
|
2509
2936
|
function codexRuntimeDescriptors(runtimeDir) {
|
|
@@ -2525,9 +2952,9 @@ export const codexHarness = {
|
|
|
2525
2952
|
headless: false,
|
|
2526
2953
|
sharedRuntimeSpawn: true,
|
|
2527
2954
|
events: CODEX_EVENTS,
|
|
2528
|
-
ownsRendezvous: false, // no
|
|
2955
|
+
ownsRendezvous: false, // no rendezvous daemon — liveness + prompts through the project app-server socket
|
|
2529
2956
|
paneTitleIsSelfSummary: false, // codex's pane title is a spinner + the cwd folder name, NOT a task summary → headline uses the prompt
|
|
2530
|
-
|
|
2957
|
+
transcript: codexTranscript,
|
|
2531
2958
|
launchCmd: (id, runtimeDir, cmd) => codexLaunchCommand(id, codexBaseCmd(cmd), undefined, runtimeDir ?? runtimeRoot()), // the full app-server+TUI script BUILT AROUND the resolved base command; ONE app-server per PROJECT
|
|
2532
2959
|
baseCmd: codexBaseCmd,
|
|
2533
2960
|
oneShotTurn: (prompt, cmd) => ({ command: `${codexBaseCmd(cmd)} exec -`, stdin: prompt }), // `exec -` reads the prompt from stdin
|
|
@@ -2543,6 +2970,7 @@ export const codexHarness = {
|
|
|
2543
2970
|
// cwd, so one shared shim serves every worktree.
|
|
2544
2971
|
shimFile: (proj) => join(mainCheckout(proj), '.codex', 'hooks.json'),
|
|
2545
2972
|
shimScope: 'project',
|
|
2973
|
+
shimOwnership: 'shared-json',
|
|
2546
2974
|
// a LINKED worktree also needs its OWN `.codex/hooks.json` so codex-rs anchors the project config layer for
|
|
2547
2975
|
// the worktree cwd (without a `.codex/` under the worktree root, codex builds no layer, so the rewritten
|
|
2548
2976
|
// root-checkout hooks are never discovered and NO hooks fire — bypass_hook_trust cannot rescue a layer that
|
|
@@ -2789,7 +3217,7 @@ const governedSharedRuntimeOwners = (runtimeDir, descriptorKey, threadId, exclud
|
|
|
2789
3217
|
continue;
|
|
2790
3218
|
let parsed;
|
|
2791
3219
|
try {
|
|
2792
|
-
parsed = JSON.parse(readFileSync(join(root, entry.name, '
|
|
3220
|
+
parsed = JSON.parse(readFileSync(join(root, entry.name, 'runtime.json'), 'utf8'));
|
|
2793
3221
|
}
|
|
2794
3222
|
catch (error) {
|
|
2795
3223
|
if (error.code === 'ENOENT')
|
|
@@ -2825,11 +3253,13 @@ async function codexHeadlessReadinessProof(current) {
|
|
|
2825
3253
|
return null;
|
|
2826
3254
|
const descriptor = codexHeadlessHarness.sharedRuntimes?.(record.runtimeDir)
|
|
2827
3255
|
.find((candidate) => candidate.key === codexDescriptorKey(endpoint));
|
|
2828
|
-
if (!descriptor
|
|
3256
|
+
if (!descriptor)
|
|
2829
3257
|
return null;
|
|
2830
3258
|
const generationBefore = codexRuntimeGenerationProof(record.runtimeDir, endpoint);
|
|
2831
3259
|
if (!generationBefore)
|
|
2832
3260
|
return null;
|
|
3261
|
+
if (!descriptor.residency)
|
|
3262
|
+
return null;
|
|
2833
3263
|
let resident;
|
|
2834
3264
|
try {
|
|
2835
3265
|
resident = await descriptor.residency();
|
|
@@ -2874,9 +3304,15 @@ export const codexHeadlessHarness = {
|
|
|
2874
3304
|
runtimeOwnership: 'adapter',
|
|
2875
3305
|
launchOneShot: true,
|
|
2876
3306
|
launchCmd: (id, runtimeDir, cmd) => codexHeadlessLaunchCommand(id, codexBaseCmd(cmd), undefined, runtimeDir ?? runtimeRoot()),
|
|
2877
|
-
//
|
|
2878
|
-
//
|
|
2879
|
-
|
|
3307
|
+
// A headless thread has no pane to witness it. The shared app-server generation is therefore the adapter's
|
|
3308
|
+
// minimum liveness witness: a stale session record must not keep a dead project runtime online forever. The
|
|
3309
|
+
// session layer adds the one exact loaded-reference census before publishing this reading on the board.
|
|
3310
|
+
liveness: (rec, _tmuxAlive, runtimeDir) => {
|
|
3311
|
+
if (rec.stopped || rec.archived || !rec.harnessSessionId)
|
|
3312
|
+
return 'offline';
|
|
3313
|
+
const endpoint = codexEndpointForRecord(rec, runtimeDir ?? runtimeRoot());
|
|
3314
|
+
return endpoint && codexRuntimeGenerationProof(runtimeDir ?? runtimeRoot(), endpoint) ? 'online' : 'offline';
|
|
3315
|
+
},
|
|
2880
3316
|
launchReady: async (current, deadline) => {
|
|
2881
3317
|
for (;;) {
|
|
2882
3318
|
const proof = await codexHeadlessReadinessProof(current);
|
|
@@ -2904,7 +3340,7 @@ export const codexHeadlessHarness = {
|
|
|
2904
3340
|
// hook binding (its lifecycle surface is the in-process extension API), so the shim is a GENERATED TypeScript
|
|
2905
3341
|
// extension (.pi/extensions/spexcode.ts, run natively by pi) that forwards five claude-shaped events to
|
|
2906
3342
|
// dispatch.sh AND binds this session's rendezvous socket itself (the adapter's launchEnv exports
|
|
2907
|
-
// CLAUDE_BG_RENDEZVOUS_SOCK) speaking
|
|
3343
|
+
// CLAUDE_BG_RENDEZVOUS_SOCK) speaking claude's rendezvous line protocol — so
|
|
2908
3344
|
// deliverViaRendezvous and the socket-listener liveness work through the same adapter seam. Trust: pi gates project-local
|
|
2909
3345
|
// extensions behind saved per-directory trust (~/.pi/agent/trust.json), so writeTrust stamps the main
|
|
2910
3346
|
// checkout there (the nearest-parent lookup covers nested worktrees) and the launch carries `--approve` as
|
|
@@ -2914,9 +3350,9 @@ export const piHarness = {
|
|
|
2914
3350
|
dispatchId: 'pi',
|
|
2915
3351
|
headless: false,
|
|
2916
3352
|
events: PI_EVENTS,
|
|
2917
|
-
ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks
|
|
3353
|
+
ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks claude's rendezvous protocol
|
|
2918
3354
|
paneTitleIsSelfSummary: false, // pi's pane title is not an agent-written task summary → headline uses the prompt preview
|
|
2919
|
-
|
|
3355
|
+
transcript: piTranscript,
|
|
2920
3356
|
launchCmd: (_id, _rt, cmd) => `${piBaseCmd(cmd)} --approve`, // --approve = one-run project trust (belt to writeTrust's braces)
|
|
2921
3357
|
baseCmd: piBaseCmd,
|
|
2922
3358
|
sessionIdArg: (id) => `--session-id ${id}`, // caller pins the exact session id, claude-style (created if missing)
|
|
@@ -2924,6 +3360,7 @@ export const piHarness = {
|
|
|
2924
3360
|
launchEnv: rendezvousLaunchEnv,
|
|
2925
3361
|
shimFile: (proj) => join(proj, '.pi', 'extensions', 'spexcode.ts'),
|
|
2926
3362
|
shimScope: 'tree',
|
|
3363
|
+
shimOwnership: 'exclusive',
|
|
2927
3364
|
worktreeHookAnchor: () => null, // the extension lives in the worktree and self-anchors, like claude
|
|
2928
3365
|
contractFiles: (proj) => [join(proj, 'AGENTS.md')], // pi auto-loads AGENTS.md context files (shared with codex — writeManagedBlock is idempotent)
|
|
2929
3366
|
skillDir: (proj) => join(proj, '.pi', 'skills'), // Agent Skills standard dirs, discovered after project trust
|
|
@@ -2965,6 +3402,8 @@ export const piHeadlessHarness = {
|
|
|
2965
3402
|
launchCmd: (id, runtimeDir, cmd) => piHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), piBaseCmd(cmd)),
|
|
2966
3403
|
liveness: sessionHomeLiveness,
|
|
2967
3404
|
deliver: deliverViaPiHeadless,
|
|
3405
|
+
// the controller aborts its own turn child natively and confirms only once that child is gone
|
|
3406
|
+
interrupt: interruptPiHeadless,
|
|
2968
3407
|
cleanupRuntime: (rec) => unlinkSocks(piHeadlessSock(rec.session), rvSock(rec.session)),
|
|
2969
3408
|
coldRuntime: async (rec) => {
|
|
2970
3409
|
const result = await piHeadlessColdRuntime(rec);
|
|
@@ -2985,7 +3424,7 @@ export const zcodeHarness = {
|
|
|
2985
3424
|
events: ZCODE_EVENTS,
|
|
2986
3425
|
ownsRendezvous: false,
|
|
2987
3426
|
paneTitleIsSelfSummary: false,
|
|
2988
|
-
|
|
3427
|
+
transcript: unsupportedTranscript('zcode'),
|
|
2989
3428
|
launchCmd: (_id, _rt, cmd) => `${zcodeBaseCmd(cmd)} --prompt`,
|
|
2990
3429
|
baseCmd: zcodeBaseCmd,
|
|
2991
3430
|
// z-code's one-turn launcher already IS the non-interactive shape; it takes the prompt as an argument.
|
|
@@ -2995,6 +3434,7 @@ export const zcodeHarness = {
|
|
|
2995
3434
|
launchEnv: noLaunchEnv,
|
|
2996
3435
|
shimFile: (proj) => join(proj, '.zcode', 'settings.json'),
|
|
2997
3436
|
shimScope: 'tree',
|
|
3437
|
+
shimOwnership: 'shared-json',
|
|
2998
3438
|
worktreeHookAnchor: () => null,
|
|
2999
3439
|
contractFiles: (proj) => [join(proj, 'AGENTS.md')],
|
|
3000
3440
|
skillDir: (proj) => join(proj, '.zcode', 'skills'),
|
|
@@ -3020,7 +3460,7 @@ export const opencodeHarness = {
|
|
|
3020
3460
|
// socket the launch env hands it, so the shared reply poke and socket-listener liveness are reused verbatim.
|
|
3021
3461
|
ownsRendezvous: true,
|
|
3022
3462
|
paneTitleIsSelfSummary: false, // opencode's TUI title is not the agent's live task self-summary → headline uses the prompt
|
|
3023
|
-
|
|
3463
|
+
transcript: opencodeTranscript,
|
|
3024
3464
|
launchCmd: (_id, _rt, cmd) => opencodeLaunchCommand(opencodeBaseCmd(cmd)), // the tail-branching script (prompt vs --resume/--continue marker)
|
|
3025
3465
|
baseCmd: opencodeBaseCmd,
|
|
3026
3466
|
// `opencode run` takes the message positionally; it documents no stdin form, so the prompt is an argument.
|
|
@@ -3036,6 +3476,7 @@ export const opencodeHarness = {
|
|
|
3036
3476
|
// by walking the cwd, so like claude it self-anchors and needs no root-checkout rewrite or worktree anchor.
|
|
3037
3477
|
shimFile: (proj) => join(proj, '.opencode', 'plugins', 'spexcode.ts'),
|
|
3038
3478
|
shimScope: 'tree',
|
|
3479
|
+
shimOwnership: 'exclusive',
|
|
3039
3480
|
worktreeHookAnchor: () => null,
|
|
3040
3481
|
contractFiles: (proj) => [join(proj, 'AGENTS.md')], // opencode reads AGENTS.md natively (same file codex owns; the managed block is idempotent across writers)
|
|
3041
3482
|
skillDir: (proj) => join(proj, '.opencode', 'skills'),
|
|
@@ -3080,6 +3521,9 @@ export const opencodeHeadlessHarness = {
|
|
|
3080
3521
|
deliver: async (rec, text) => {
|
|
3081
3522
|
return deliverViaSocketOrWake(rec.session, text, rec.mid, () => spawnOpenCodeHeadlessTurn(rec, text, opencodeBaseCmd(rec.launchCmd ?? undefined), rvSock(rec.session)), `opencode-headless rendezvous probe was inconclusive for session ${rec.session} - refusing to start a possibly duplicate turn`);
|
|
3082
3523
|
},
|
|
3524
|
+
// `opencode run` serves the rendezvous socket only for the turn it runs, so the plugin's session abort IS the
|
|
3525
|
+
// interrupt, and the listener going dead is the turn's exit — confirmed only then, so the next delivery wakes cold
|
|
3526
|
+
interrupt: (rec) => interruptViaRendezvous(rec.session, 'opencode-headless', { settle: true }),
|
|
3083
3527
|
};
|
|
3084
3528
|
// every adapter — materialize iterates this to write each harness's artifacts in one pass.
|
|
3085
3529
|
export const HARNESSES = [claudeHarness, codexHarness, opencodeHarness, piHarness, zcodeHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness, codexHeadlessHarness];
|
|
@@ -3109,12 +3553,6 @@ export function launcherList(root = mainCheckout()) {
|
|
|
3109
3553
|
})
|
|
3110
3554
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
3111
3555
|
}
|
|
3112
|
-
// The dashboard's visibility projection. It never removes a launcher from the complete config/CLI path;
|
|
3113
|
-
// it only narrows GET /api/settings for the New Session picker ([[launcher-visibility]]).
|
|
3114
|
-
export function dashboardLauncherList(root = mainCheckout()) {
|
|
3115
|
-
const showHeadless = readConfig(root).dashboard?.showHeadlessLaunchers === true;
|
|
3116
|
-
return launcherList(root).filter((launcher) => showHeadless || !launcher.headless);
|
|
3117
|
-
}
|
|
3118
3556
|
export const MISSING_DEFAULT_LAUNCHER_ERROR = 'sessions.defaultLauncher is required for a launch without --launcher; set it in spexcode.json or spexcode.local.json (for example {"sessions":{"defaultLauncher":"claude"}})';
|
|
3119
3557
|
// the configured default launcher NAME ([[launcher-select]]) — the profile `spex session new`/a dropdown pick with no
|
|
3120
3558
|
// explicit choice resolves. Missing config is a fail-loud setup error, never an implicit fallthrough to a
|