spexcode 0.7.0-next.0 → 0.7.0-next.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/@spexcode/session-application/package.json +5 -5
- package/node_modules/@spexcode/session-events/package.json +2 -2
- package/node_modules/@spexcode/session-protocol/package.json +1 -1
- package/node_modules/@spexcode/session-runtime/package.json +2 -2
- package/node_modules/@spexcode/session-selflaunch/package.json +3 -3
- package/node_modules/@spexcode/session-topology/package.json +2 -2
- package/node_modules/@spexcode/spec-cli/dist/client.js +7 -5
- package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +4 -0
- package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +12 -0
- package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +4 -1
- package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/harness.js +48 -8
- package/node_modules/@spexcode/spec-cli/dist/host-resources.js +55 -3
- package/node_modules/@spexcode/spec-cli/dist/materialize.js +54 -1
- package/node_modules/@spexcode/spec-cli/dist/session-transcript.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/sessions.js +116 -13
- package/node_modules/@spexcode/spec-cli/package.json +7 -7
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/merge/spec.md +9 -3
- package/node_modules/@spexcode/spec-core/dist/git.d.ts +1 -0
- package/node_modules/@spexcode/spec-core/dist/git.js +25 -0
- package/node_modules/@spexcode/spec-core/package.json +1 -1
- 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 +2 -0
- package/node_modules/@spexcode/transcript/dist/frames.js +2 -0
- package/node_modules/@spexcode/transcript/dist/parsers.d.ts +8 -0
- package/node_modules/@spexcode/transcript/dist/parsers.js +320 -22
- package/node_modules/@spexcode/transcript/dist/readers.d.ts +7 -1
- package/node_modules/@spexcode/transcript/dist/readers.js +87 -4
- package/node_modules/@spexcode/transcript/dist/turns.d.ts +1 -0
- package/node_modules/@spexcode/transcript/package.json +1 -1
- package/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spexcode/session-application",
|
|
3
|
-
"version": "0.7.0-next.
|
|
3
|
+
"version": "0.7.0-next.10",
|
|
4
4
|
"publishConfig": { "access": "public" },
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Adopter-owned composition of session protocol and topology transactions.",
|
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
"test": "npm run build && tsx --import ../../scripts/test-home.mjs --test src/*.test.ts"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@spexcode/session-events": "0.7.0-next.
|
|
20
|
-
"@spexcode/session-protocol": "0.7.0-next.
|
|
21
|
-
"@spexcode/session-runtime": "0.7.0-next.
|
|
22
|
-
"@spexcode/session-topology": "0.7.0-next.
|
|
19
|
+
"@spexcode/session-events": "0.7.0-next.10",
|
|
20
|
+
"@spexcode/session-protocol": "0.7.0-next.10",
|
|
21
|
+
"@spexcode/session-runtime": "0.7.0-next.10",
|
|
22
|
+
"@spexcode/session-topology": "0.7.0-next.10"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/node": "^20.16.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spexcode/session-events",
|
|
3
|
-
"version": "0.7.0-next.
|
|
3
|
+
"version": "0.7.0-next.10",
|
|
4
4
|
"publishConfig": { "access": "public" },
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Append-only session facts and deterministic ordered replay inputs.",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"prepublishOnly": "node ../../scripts/release-publish.mjs --from-package-publish",
|
|
16
16
|
"test": "npm run build && tsx --import ../../scripts/test-home.mjs --test src/*.test.ts"
|
|
17
17
|
},
|
|
18
|
-
"dependencies": { "@spexcode/session-protocol": "0.7.0-next.
|
|
18
|
+
"dependencies": { "@spexcode/session-protocol": "0.7.0-next.10" },
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"@types/node": "^20.16.0",
|
|
21
21
|
"tsx": "^4.19.2",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spexcode/session-runtime",
|
|
3
|
-
"version": "0.7.0-next.
|
|
3
|
+
"version": "0.7.0-next.10",
|
|
4
4
|
"publishConfig": { "access": "public" },
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Adopter-owned bindings between protocol addresses and native runtime identities.",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"prepublishOnly": "node ../../scripts/release-publish.mjs --from-package-publish",
|
|
16
16
|
"test": "npm run build && tsx --import ../../scripts/test-home.mjs --test src/*.test.ts"
|
|
17
17
|
},
|
|
18
|
-
"dependencies": { "@spexcode/session-protocol": "0.7.0-next.
|
|
18
|
+
"dependencies": { "@spexcode/session-protocol": "0.7.0-next.10" },
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"@types/node": "^20.16.0",
|
|
21
21
|
"tsx": "^4.19.2",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spexcode/session-selflaunch",
|
|
3
|
-
"version": "0.7.0-next.
|
|
3
|
+
"version": "0.7.0-next.10",
|
|
4
4
|
"publishConfig": { "access": "public" },
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A fail-closed self-launch adopter for the SpexCode session protocol.",
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"test": "npm run build && tsx --import ../../scripts/test-home.mjs --test src/*.test.ts"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@spexcode/session-protocol": "0.7.0-next.
|
|
28
|
-
"@spexcode/session-runtime": "0.7.0-next.
|
|
27
|
+
"@spexcode/session-protocol": "0.7.0-next.10",
|
|
28
|
+
"@spexcode/session-runtime": "0.7.0-next.10"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@types/node": "^20.16.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spexcode/session-topology",
|
|
3
|
-
"version": "0.7.0-next.
|
|
3
|
+
"version": "0.7.0-next.10",
|
|
4
4
|
"publishConfig": { "access": "public" },
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A neutral SQLite relation model for session addresses.",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"prepublishOnly": "node ../../scripts/release-publish.mjs --from-package-publish",
|
|
16
16
|
"test": "npm run build && tsx --import ../../scripts/test-home.mjs --test src/*.test.ts"
|
|
17
17
|
},
|
|
18
|
-
"dependencies": { "@spexcode/session-protocol": "0.7.0-next.
|
|
18
|
+
"dependencies": { "@spexcode/session-protocol": "0.7.0-next.10" },
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"@types/node": "^20.16.0",
|
|
21
21
|
"tsx": "^4.19.2",
|
|
@@ -132,10 +132,10 @@ const post = (body) => ({ method: 'POST', headers: { 'content-type': 'applicatio
|
|
|
132
132
|
const seg = (id) => encodeURIComponent(id);
|
|
133
133
|
// A rendezvous liveness probe can displace the real delivery client.
|
|
134
134
|
function cachedStatus(rec) {
|
|
135
|
-
if (!rec.worktreePath || !existsSync(rec.worktreePath))
|
|
136
|
-
return 'retired';
|
|
137
135
|
if (rec.archived)
|
|
138
136
|
return 'offline';
|
|
137
|
+
if (!rec.worktreePath || !existsSync(rec.worktreePath))
|
|
138
|
+
return 'retired';
|
|
139
139
|
if (rec.status === 'awaiting')
|
|
140
140
|
return displayStatusForProposal(rec.proposal);
|
|
141
141
|
return rec.status === 'active' || rec.status === 'idle' ? 'unknown' : rec.status;
|
|
@@ -179,9 +179,11 @@ export function localCachedSessions(includeArchived = false) {
|
|
|
179
179
|
if (!state)
|
|
180
180
|
continue;
|
|
181
181
|
const lifecycle = state.status;
|
|
182
|
-
const status = state.status === '
|
|
183
|
-
?
|
|
184
|
-
: state.status === '
|
|
182
|
+
const status = state.status === 'archived'
|
|
183
|
+
? 'offline'
|
|
184
|
+
: state.status === 'awaiting'
|
|
185
|
+
? displayStatusForProposal(state.proposal)
|
|
186
|
+
: state.status;
|
|
185
187
|
rows.push({
|
|
186
188
|
id,
|
|
187
189
|
node: null,
|
|
@@ -46,6 +46,10 @@ export declare function prepareCodexGenerationClose(root: string, sessionId: str
|
|
|
46
46
|
export declare function repinCodexGeneration(root: string, sessionId: string, threadId: string, generationId: string): void;
|
|
47
47
|
export declare function resolveCodexGenerationForResume(root: string, sessionId: string, threadId: string, start: (endpoint: CodexGenerationEndpoint) => Promise<void>): Promise<CodexGenerationEndpoint | null>;
|
|
48
48
|
export declare function resolveCodexGenerationForSession(root: string, sessionId: string, threadId: string): CodexGenerationEndpoint | null;
|
|
49
|
+
export declare function resolveCodexGenerationForClose(root: string, sessionId: string, threadId: string): {
|
|
50
|
+
endpoint: CodexGenerationEndpoint;
|
|
51
|
+
gone: boolean;
|
|
52
|
+
} | null;
|
|
49
53
|
export declare function codexGenerationBindingForSession(root: string, sessionId: string): CodexGenerationBinding | null;
|
|
50
54
|
export declare function currentCodexGeneration(root: string): CodexGenerationEndpoint | null;
|
|
51
55
|
export declare function codexGenerationEndpoints(root: string): CodexGenerationEndpoint[];
|
|
@@ -765,6 +765,18 @@ export function resolveCodexGenerationForSession(root, sessionId, threadId) {
|
|
|
765
765
|
const generation = ledger.generations[binding.generationId];
|
|
766
766
|
return generation && generation.state !== 'reclaimed' ? generation.endpoint : null;
|
|
767
767
|
}
|
|
768
|
+
// Close still needs the immutable endpoint record to recognize a positively retired generation. The
|
|
769
|
+
// endpoint is never used for native I/O in this state; it only identifies the empty control plane.
|
|
770
|
+
export function resolveCodexGenerationForClose(root, sessionId, threadId) {
|
|
771
|
+
const ledger = readCodexGenerationLedger(root);
|
|
772
|
+
const binding = ledger.bindings[sessionId];
|
|
773
|
+
if (!binding || binding.threadId !== threadId)
|
|
774
|
+
return null;
|
|
775
|
+
const generation = ledger.generations[binding.generationId];
|
|
776
|
+
if (!generation)
|
|
777
|
+
return null;
|
|
778
|
+
return { endpoint: generation.endpoint, gone: generation.state === 'reclaimed' };
|
|
779
|
+
}
|
|
768
780
|
export function codexGenerationBindingForSession(root, sessionId) {
|
|
769
781
|
return readCodexGenerationLedger(root).bindings[sessionId] ?? null;
|
|
770
782
|
}
|
|
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import { loadSpecs, requireGitWorkspace, headSha } from '@spexcode/spec-core';
|
|
4
4
|
import { resolveLayout } from '@spexcode/spec-core';
|
|
5
5
|
import { listSessions } from './sessions.js';
|
|
6
|
-
import { driftIndex, historyIndex, repoRoot } from '@spexcode/spec-core';
|
|
6
|
+
import { driftIndex, historyIndex, pruneHistoryCaches, repoRoot } from '@spexcode/spec-core';
|
|
7
7
|
import { residentForgeRevision, residentForgeState } from '@spexcode/spec-forge/resident';
|
|
8
8
|
import { resolveForgeHost } from '@spexcode/spec-forge/drivers';
|
|
9
9
|
import { boardThreads } from './issues.js';
|
|
@@ -56,6 +56,9 @@ export async function boardSnapshot() {
|
|
|
56
56
|
const root = repoRoot();
|
|
57
57
|
requireGitWorkspace(root);
|
|
58
58
|
const [specs, sessions] = await Promise.all([loadSpecs(), listSessions()]);
|
|
59
|
+
// Session worktrees are the live-root census for immutable history caches. Reconcile before the snapshot
|
|
60
|
+
// returns so closing a session releases its full index immediately rather than waiting for an LRU slot.
|
|
61
|
+
pruneHistoryCaches([root, ...sessions.map((session) => session.path)]);
|
|
59
62
|
const layout = await resolveLayout({ activeSessionIds: sessions.map((session) => session.id) });
|
|
60
63
|
const nodeIds = [...new Set([
|
|
61
64
|
...specs.map((node) => node.id),
|
|
@@ -18,7 +18,8 @@ import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath, spexcodeHom
|
|
|
18
18
|
import { git } from '@spexcode/spec-core';
|
|
19
19
|
import { shQuote } from './sh.js';
|
|
20
20
|
import { detachedRuntimeGenerationToken, migrateLegacyDetachedRuntimeReceipt, processStartToken, verifyDetachedRuntime } from '@spexcode/spec-core';
|
|
21
|
-
import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGeneration, legacyCodexGenerationEndpoint, readCodexGenerationLedger, resolveCodexGenerationForSession } from './codex-runtime-generations.js';
|
|
21
|
+
import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGeneration, legacyCodexGenerationEndpoint, readCodexGenerationLedger, prepareCodexGenerationClose, resolveCodexGenerationForClose, resolveCodexGenerationForResume, resolveCodexGenerationForSession } from './codex-runtime-generations.js';
|
|
22
|
+
import { spawnDetachedRuntime } from './runtime-ownership.js';
|
|
22
23
|
import { writeFileIfChanged } from './file-write.js';
|
|
23
24
|
import { claudeTranscript, codexRolloutPath, codexTranscript, opencodeTranscript, piTranscript, unsupportedTranscript } from '@spexcode/transcript';
|
|
24
25
|
import { harnessIdentity, HARNESS_IDENTITIES } from '@spexcode/spec-core';
|
|
@@ -220,12 +221,14 @@ function codexMutationGeneration(dir = runtimeRoot(), endpoint = legacyCodexGene
|
|
|
220
221
|
return codexRuntimeGeneration(dir, endpoint);
|
|
221
222
|
}
|
|
222
223
|
const codexDescriptorKey = (endpoint) => endpoint.id === 'legacy' ? 'codex-app-server' : `codex-app-server:${endpoint.id}`;
|
|
223
|
-
function codexEndpointForRecord(rec, dir = runtimeRoot()) {
|
|
224
|
+
function codexEndpointForRecord(rec, dir = runtimeRoot(), includeGone = false) {
|
|
224
225
|
if (!rec.harnessSessionId)
|
|
225
226
|
return null;
|
|
226
227
|
const ledger = readCodexGenerationLedger(dir);
|
|
227
228
|
if (ledger.revision === 0 && !ledger.current && !Object.keys(ledger.generations).length)
|
|
228
229
|
return legacyCodexGenerationEndpoint(dir);
|
|
230
|
+
if (includeGone)
|
|
231
|
+
return resolveCodexGenerationForClose(dir, rec.session, rec.harnessSessionId)?.endpoint ?? null;
|
|
229
232
|
return resolveCodexGenerationForSession(dir, rec.session, rec.harnessSessionId);
|
|
230
233
|
}
|
|
231
234
|
// the spex launcher (bin/spex.mjs), baked into the codex launch script (mirrors materialize.ts's SPEX) so
|
|
@@ -2280,7 +2283,21 @@ async function deliverViaCodexAppServer(rec, text) {
|
|
|
2280
2283
|
// the socket is PER-PROJECT (the runtime root), shared by every worktree's thread; the owned thread id on
|
|
2281
2284
|
// the record picks out THIS session's thread.
|
|
2282
2285
|
const runtimeDir = rec.runtimeDir ?? runtimeRoot();
|
|
2283
|
-
|
|
2286
|
+
let endpoint = rec.harnessSessionId ? codexEndpointForRecord(rec, runtimeDir) : currentCodexGeneration(runtimeDir);
|
|
2287
|
+
// A generation may be reclaimed after rotation or a host restart while the session record remains valid.
|
|
2288
|
+
// Repair that stale route at the delivery boundary using the same exact-thread re-pin used by resume, so an
|
|
2289
|
+
// accepted message is not left indefinitely in the queue just because no later human resume was requested.
|
|
2290
|
+
if ((!endpoint || !existsSync(endpoint.socketPath)) && rec.harnessSessionId) {
|
|
2291
|
+
const command = codexBaseCmd(rec.launchCmd || 'codex');
|
|
2292
|
+
const env = { ...process.env };
|
|
2293
|
+
for (const key of sessionIdentityEnvVars())
|
|
2294
|
+
delete env[key];
|
|
2295
|
+
const start = async (candidate) => {
|
|
2296
|
+
await spawnDetachedRuntime({ cwd: runtimeDir, logFile: candidate.logFile, pidFile: candidate.pidFile,
|
|
2297
|
+
receiptFile: candidate.receiptFile, command, args: ['app-server', '--listen', `unix://${candidate.socketPath}`], env });
|
|
2298
|
+
};
|
|
2299
|
+
endpoint = await resolveCodexGenerationForResume(runtimeDir, rec.session, rec.harnessSessionId, start);
|
|
2300
|
+
}
|
|
2284
2301
|
if (!endpoint)
|
|
2285
2302
|
return { ok: false, error: `no exact Codex generation binding for session ${rec.session} — immediate poke unavailable` };
|
|
2286
2303
|
const sock = endpoint.socketPath;
|
|
@@ -2295,6 +2312,14 @@ async function deliverViaCodexAppServer(rec, text) {
|
|
|
2295
2312
|
return { ok: false, error: `${r.error} — immediate poke unavailable` };
|
|
2296
2313
|
threadId = r.threadId;
|
|
2297
2314
|
}
|
|
2315
|
+
const delivered = await sendCodexAppServerTurn(sock, threadId, text, rec.worktreePath, rec.mid);
|
|
2316
|
+
if (delivered.ok || rec.harness !== 'codex-headless' || !/not loaded in the app-server/u.test(delivered.error || ''))
|
|
2317
|
+
return delivered;
|
|
2318
|
+
// Headless Codex has no TUI resume step. An idle thread can be evicted from the shared server's loaded set;
|
|
2319
|
+
// reload the exact rollout, then retry the same turn once. This is idempotent and does not create a new thread.
|
|
2320
|
+
const reopened = await codexReopenThread(sock, threadId);
|
|
2321
|
+
if (!reopened.ok)
|
|
2322
|
+
return { ok: false, error: `${delivered.error}; ${reopened.error}` };
|
|
2298
2323
|
return sendCodexAppServerTurn(sock, threadId, text, rec.worktreePath, rec.mid);
|
|
2299
2324
|
}
|
|
2300
2325
|
// idempotent replace of the content between sentinels; the user's own content above/below is preserved. The
|
|
@@ -2784,7 +2809,9 @@ const socketListenerLiveness = (_rec, tmuxAlive, _runtimeDir, _pane, socketLive)
|
|
|
2784
2809
|
const socketListenerOrPidAliveLiveness = (_rec, tmuxAlive, _runtimeDir, pane, socketLive) => (tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline');
|
|
2785
2810
|
const panePidLiveness = (_rec, tmuxAlive, _runtimeDir, pane) => (tmuxAlive && pane?.pidAlive === true ? 'online' : 'offline');
|
|
2786
2811
|
const recordOnline = (rec) => rec.stopped ? 'offline' : 'online';
|
|
2787
|
-
|
|
2812
|
+
// Leaf-backed headless sessions are the controller process, not the tmux pane that hosts it. The pane can
|
|
2813
|
+
// survive a SIGKILL as a bare shell, so tmux presence without the launch-registered PID is never online.
|
|
2814
|
+
const sessionHomeLiveness = (_rec, tmuxAlive, _runtimeDir, pane) => (tmuxAlive && pane?.pidAlive === true ? 'online' : 'offline');
|
|
2788
2815
|
// @@@ unlinkSocks - remove ONLY the transport this teardown PROVED dead. `cleanupRuntime` unlinks *their*
|
|
2789
2816
|
// socket, and the honest test of "theirs" is that the agent it just killed is GONE. It used to unlink on
|
|
2790
2817
|
// faith, which is unsound because a socket path is derived from the session id ALONE: it is the one
|
|
@@ -2889,6 +2916,7 @@ export const claudeHeadlessHarness = {
|
|
|
2889
2916
|
paneTitleIsSelfSummary: false,
|
|
2890
2917
|
launchCmd: (id, runtimeDir, cmd) => claudeHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), claudeBaseCmd(cmd)),
|
|
2891
2918
|
launchEnv: noLaunchEnv,
|
|
2919
|
+
// The controller's registered PID is the liveness witness; the tmux home is only the address boundary.
|
|
2892
2920
|
liveness: sessionHomeLiveness,
|
|
2893
2921
|
deliveryTransport: unprovenDeliveryTransport,
|
|
2894
2922
|
deliver: deliverViaClaudeHeadless,
|
|
@@ -3025,7 +3053,7 @@ export const codexHarness = {
|
|
|
3025
3053
|
interrupt: interruptCodexTurn,
|
|
3026
3054
|
cleanupRuntime: async () => { },
|
|
3027
3055
|
targetDescriptorKey: (rec) => {
|
|
3028
|
-
const endpoint = codexEndpointForRecord(rec);
|
|
3056
|
+
const endpoint = codexEndpointForRecord(rec, runtimeRoot(), true);
|
|
3029
3057
|
return endpoint ? codexDescriptorKey(endpoint) : null;
|
|
3030
3058
|
},
|
|
3031
3059
|
coldRetirementPreflight: async (rec) => {
|
|
@@ -3049,8 +3077,13 @@ export const codexHarness = {
|
|
|
3049
3077
|
coldPreflight: async (rec) => {
|
|
3050
3078
|
if (!rec.harnessSessionId)
|
|
3051
3079
|
return { ok: false, reason: 'no exact Codex thread identity is registered' };
|
|
3052
|
-
const
|
|
3053
|
-
|
|
3080
|
+
const dir = runtimeRoot();
|
|
3081
|
+
const binding = resolveCodexGenerationForClose(dir, rec.session, rec.harnessSessionId);
|
|
3082
|
+
if (binding?.gone) {
|
|
3083
|
+
return { ok: true, alreadyCold: true };
|
|
3084
|
+
}
|
|
3085
|
+
const endpoint = binding?.endpoint ?? codexEndpointForRecord(rec, dir);
|
|
3086
|
+
return endpoint ? codexColdPreflight(rec.harnessSessionId, dir, undefined, endpoint)
|
|
3054
3087
|
: { ok: false, reason: 'no exact Codex generation binding is registered for this target' };
|
|
3055
3088
|
},
|
|
3056
3089
|
coldRuntime: async (rec, suppliedReceipt) => {
|
|
@@ -3058,7 +3091,12 @@ export const codexHarness = {
|
|
|
3058
3091
|
return { ok: false, reason: 'no exact Codex thread identity is registered' };
|
|
3059
3092
|
const threadId = rec.harnessSessionId;
|
|
3060
3093
|
const dir = runtimeRoot();
|
|
3061
|
-
const
|
|
3094
|
+
const binding = resolveCodexGenerationForClose(dir, rec.session, threadId);
|
|
3095
|
+
if (binding?.gone) {
|
|
3096
|
+
prepareCodexGenerationClose(dir, rec.session, threadId);
|
|
3097
|
+
return { ok: true };
|
|
3098
|
+
}
|
|
3099
|
+
const endpoint = binding?.endpoint ?? codexEndpointForRecord(rec, dir);
|
|
3062
3100
|
if (!endpoint)
|
|
3063
3101
|
return { ok: false, reason: 'no exact Codex generation binding is registered for this target' };
|
|
3064
3102
|
const sock = endpoint.socketPath;
|
|
@@ -3400,6 +3438,7 @@ export const piHeadlessHarness = {
|
|
|
3400
3438
|
runtimeOwnership: 'leaf',
|
|
3401
3439
|
paneTitleIsSelfSummary: false,
|
|
3402
3440
|
launchCmd: (id, runtimeDir, cmd) => piHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), piBaseCmd(cmd)),
|
|
3441
|
+
// The controller's registered PID is the liveness witness; the tmux home is only the address boundary.
|
|
3403
3442
|
liveness: sessionHomeLiveness,
|
|
3404
3443
|
deliver: deliverViaPiHeadless,
|
|
3405
3444
|
// the controller aborts its own turn child natively and confirms only once that child is gone
|
|
@@ -3513,6 +3552,7 @@ export const opencodeHeadlessHarness = {
|
|
|
3513
3552
|
runtimeOwnership: 'leaf',
|
|
3514
3553
|
deliveryTransport: unprovenDeliveryTransport,
|
|
3515
3554
|
launchCmd: (_id, _runtimeDir, cmd) => opencodeHeadlessLaunchCommand(opencodeBaseCmd(cmd)),
|
|
3555
|
+
// The controller's registered PID is the liveness witness; the tmux home is only the address boundary.
|
|
3516
3556
|
liveness: sessionHomeLiveness,
|
|
3517
3557
|
coldRuntime: async (rec) => {
|
|
3518
3558
|
const result = await opencodeHeadlessColdRuntime(rec);
|
|
@@ -145,6 +145,23 @@ const descendants = (root, procs) => {
|
|
|
145
145
|
}
|
|
146
146
|
return ids;
|
|
147
147
|
};
|
|
148
|
+
// tmux is a shared host for many independent session windows. Its inherited environment is therefore
|
|
149
|
+
// not a launch receipt: a token on the server or one of its panes cannot charge the whole hosted tree.
|
|
150
|
+
const isTmuxServer = (proc) => proc?.command === 'tmux: server';
|
|
151
|
+
const hasTmuxAncestor = (pid, procs) => {
|
|
152
|
+
const seen = new Set();
|
|
153
|
+
let next = procs.get(pid)?.ppid;
|
|
154
|
+
while (next && !seen.has(next)) {
|
|
155
|
+
seen.add(next);
|
|
156
|
+
const parent = procs.get(next);
|
|
157
|
+
if (!parent)
|
|
158
|
+
return false;
|
|
159
|
+
if (isTmuxServer(parent))
|
|
160
|
+
return true;
|
|
161
|
+
next = parent.ppid;
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
};
|
|
148
165
|
const publicRecordInventory = () => {
|
|
149
166
|
const entries = listSessionIds().map(readPublicRecordEntry);
|
|
150
167
|
const application = configuredSessionApplicationIfCutover();
|
|
@@ -216,6 +233,29 @@ const unresolvedHarnessOwners = (recs, budgets, have) => {
|
|
|
216
233
|
}
|
|
217
234
|
return out;
|
|
218
235
|
};
|
|
236
|
+
// A leaf-backed headless controller is the session's runtime. Keep its durable row visible when the controller
|
|
237
|
+
// disappears before the next resources sweep can attribute a process, and name the status/liveness contradiction
|
|
238
|
+
// explicitly so a supervisor cannot mistake the missing process for a cleanly idle session.
|
|
239
|
+
const missingHeadlessControllerOwners = (recs, procs, budgets, have) => {
|
|
240
|
+
const out = [];
|
|
241
|
+
for (const rec of recs) {
|
|
242
|
+
const harness = harnessByIdOrNull(rec.harness || defaultHarness.id);
|
|
243
|
+
if (!rec.governed || rec.stopped || rec.archived || rec.status !== 'active' && rec.status !== 'idle'
|
|
244
|
+
|| harness?.runtimeOwnership !== 'leaf' || have(rec.session_id))
|
|
245
|
+
continue;
|
|
246
|
+
const pid = runtimePid(join(runtimeRoot(), 'sessions', rec.session_id, 'agent.pid'));
|
|
247
|
+
if (pid && procs.has(pid))
|
|
248
|
+
continue;
|
|
249
|
+
out.push({
|
|
250
|
+
kind: 'session', id: rec.session_id, label: `session ${rec.session_id.slice(0, 8)}`, status: rec.status, liveness: 'offline',
|
|
251
|
+
processes: [], rssMiB: 0, pssMiB: null, cpuPercent: 0,
|
|
252
|
+
budget: { rssMiB: budgets.sessionRssMiB, idleCpuPercent: budgets.idleCpuPercent },
|
|
253
|
+
findings: [`headless-liveness-contradiction:${rec.status}/offline`],
|
|
254
|
+
reclaim: { eligible: false, reason: 'headless controller is absent; lifecycle repair must be explicit' },
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
};
|
|
219
259
|
const sharedDescriptors = (recs, retainRegistry = false) => {
|
|
220
260
|
const out = new Map();
|
|
221
261
|
for (const harness of HARNESSES)
|
|
@@ -322,13 +362,13 @@ const buildInventory = (procs, publicRecords = publicRecordInventory()) => {
|
|
|
322
362
|
for (const p of procs.values()) {
|
|
323
363
|
const acting = actingSession(p);
|
|
324
364
|
const fallback = p.env.SPEXCODE_SESSION_ID;
|
|
325
|
-
const sid = acting ?? (fallback ? byId.get(fallback) : undefined);
|
|
365
|
+
const sid = acting ?? (!hasTmuxAncestor(p.pid, procs) && !isTmuxServer(p) && fallback ? byId.get(fallback) : undefined);
|
|
326
366
|
if (sid)
|
|
327
367
|
ownership.set(p.pid, `session:${sid}`);
|
|
328
368
|
}
|
|
329
369
|
for (const rec of activeRecs) {
|
|
330
370
|
const root = runtimePid(join(runtimeRoot(), 'sessions', rec.session_id, 'agent.pid'));
|
|
331
|
-
if (root && procs.has(root))
|
|
371
|
+
if (root && procs.has(root) && !isTmuxServer(procs.get(root)))
|
|
332
372
|
for (const pid of descendants(root, procs))
|
|
333
373
|
if (!ownership.has(pid))
|
|
334
374
|
ownership.set(pid, `session:${rec.session_id}`);
|
|
@@ -368,7 +408,7 @@ const buildInventory = (procs, publicRecords = publicRecordInventory()) => {
|
|
|
368
408
|
}
|
|
369
409
|
const root = repoRoot();
|
|
370
410
|
for (const p of procs.values()) {
|
|
371
|
-
if (ownership.has(p.pid) || p.env.SPEXCODE_PROJECT_ROOT !== root)
|
|
411
|
+
if (ownership.has(p.pid) || p.env.SPEXCODE_PROJECT_ROOT !== root || hasTmuxAncestor(p.pid, procs) || isTmuxServer(p))
|
|
372
412
|
continue;
|
|
373
413
|
const claimed = p.env.SPEXCODE_SESSION_ID;
|
|
374
414
|
if (claimed && !byId.has(claimed))
|
|
@@ -448,6 +488,17 @@ const sessionStopBlocker = async (id, harnessId, recs = rawRecords(), knownProbe
|
|
|
448
488
|
const entryTargetThread = entry.recs.find((rec) => rec.session_id === id)?.harness_session_id;
|
|
449
489
|
if (entryTargetThread && ownerCounts.get(entryTargetThread) !== 1)
|
|
450
490
|
return `${descriptor.label} target thread ${entryTargetThread} has no one exact governed session owner`;
|
|
491
|
+
// A retired shared generation is positive death, not an unknown control plane. Its residency census is
|
|
492
|
+
// the only proof needed here; there is no native target left to signal or unload.
|
|
493
|
+
if (descriptor.residency && entryTargetThread) {
|
|
494
|
+
let residency = null;
|
|
495
|
+
try {
|
|
496
|
+
residency = await descriptor.residency();
|
|
497
|
+
}
|
|
498
|
+
catch { /* retain the normal fail-closed path */ }
|
|
499
|
+
if (residency?.healthy && residency.rootAbsent === true && residency.referenceIds.length === 0)
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
451
502
|
if (!knownProbes && descriptor.mutationGuard) {
|
|
452
503
|
if (!targetThread)
|
|
453
504
|
return `${descriptor.label} target has no exact governed thread identity`;
|
|
@@ -722,6 +773,7 @@ export async function collectResourceReport(opts = {}) {
|
|
|
722
773
|
budget: { rssMiB: budgets.sessionRssMiB, idleCpuPercent: budgets.idleCpuPercent },
|
|
723
774
|
findings: [`session-record-corrupt:${entry.error}`], reclaim: { eligible: false, reason: 'session record is corrupt; ownership and lifecycle are unknown' },
|
|
724
775
|
});
|
|
776
|
+
owners.push(...missingHeadlessControllerOwners(inv.recs, inv.procs, budgets, (sessionId) => owners.some((owner) => owner.kind === 'session' && owner.id === sessionId)));
|
|
725
777
|
owners.push(...unresolvedHarnessOwners(inv.recs, budgets, (sessionId) => owners.some((owner) => owner.kind === 'session' && owner.id === sessionId)));
|
|
726
778
|
// A referenced shared runtime with no readable process is still operationally important: keep its live or
|
|
727
779
|
// unknown refcount visible instead of silently omitting it from a process-derived report.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { writeFileSync, mkdirSync, readFileSync, existsSync, readdirSync, renameSync, rmSync, rmdirSync } from 'node:fs';
|
|
1
|
+
import { writeFileSync, mkdirSync, readFileSync, existsSync, readdirSync, renameSync, rmSync, rmdirSync, copyFileSync, chmodSync } from 'node:fs';
|
|
2
2
|
import { join, dirname, relative } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
@@ -37,6 +37,58 @@ const DISPATCH = join(PKG, 'hooks', 'dispatch.sh');
|
|
|
37
37
|
// CLI code and keeps the source-workspace mid-merge guard (one line + exit 75), so every hook callback
|
|
38
38
|
// inherits both.
|
|
39
39
|
const SPEX = join(PKG, 'bin', 'spex.mjs');
|
|
40
|
+
const CORE_TEMPLATE = join(PKG, 'templates', 'spec', 'project', '.plugins', 'core');
|
|
41
|
+
// Core hook handlers are shipped executable protocol, not adopter-owned plugin variants. Reconcile only the
|
|
42
|
+
// known `core/` subtree before compiling the manifest so a project seeded by an older toolchain cannot keep
|
|
43
|
+
// invoking a retired lifecycle writer. User plugins live outside this allowlist and are never enumerated.
|
|
44
|
+
function refreshCorePluginHandlers(proj) {
|
|
45
|
+
if (!existsSync(CORE_TEMPLATE))
|
|
46
|
+
return [];
|
|
47
|
+
const specDir = join(proj, '.spec');
|
|
48
|
+
const roots = existsSync(specDir)
|
|
49
|
+
? readdirSync(specDir, { withFileTypes: true })
|
|
50
|
+
.filter((entry) => entry.isDirectory() && existsSync(join(specDir, entry.name, '.plugins', 'core')))
|
|
51
|
+
.map((entry) => join(specDir, entry.name, '.plugins', 'core'))
|
|
52
|
+
: [];
|
|
53
|
+
const handlers = [];
|
|
54
|
+
const walk = (dir, prefix = '') => {
|
|
55
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
56
|
+
const rel = join(prefix, entry.name);
|
|
57
|
+
if (entry.isDirectory())
|
|
58
|
+
walk(join(dir, entry.name), rel);
|
|
59
|
+
else if (entry.isFile() && entry.name.endsWith('.sh'))
|
|
60
|
+
handlers.push(rel);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
walk(CORE_TEMPLATE);
|
|
64
|
+
const refreshed = [];
|
|
65
|
+
for (const root of roots)
|
|
66
|
+
for (const rel of handlers) {
|
|
67
|
+
const source = join(CORE_TEMPLATE, rel);
|
|
68
|
+
const dest = join(root, rel);
|
|
69
|
+
let current = null;
|
|
70
|
+
try {
|
|
71
|
+
current = readFileSync(dest);
|
|
72
|
+
}
|
|
73
|
+
catch { }
|
|
74
|
+
if (current?.equals(readFileSync(source)))
|
|
75
|
+
continue;
|
|
76
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
77
|
+
const temp = `${dest}.spexcode-${process.pid}`;
|
|
78
|
+
try {
|
|
79
|
+
copyFileSync(source, temp);
|
|
80
|
+
chmodSync(temp, 0o755);
|
|
81
|
+
renameSync(temp, dest);
|
|
82
|
+
refreshed.push(relative(proj, dest));
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
rmSync(temp, { force: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (refreshed.length)
|
|
89
|
+
console.log(`✓ refreshed core plugin handlers (${refreshed.join(', ')})`);
|
|
90
|
+
return refreshed;
|
|
91
|
+
}
|
|
40
92
|
// the manifest + content-hash marker + plugin-folder ledger land in the materialized TREE's own slot of the
|
|
41
93
|
// GLOBAL per-project store (layout.treeSlotDir — trees/<enc-worktree>), NOT the worktree and NOT one shared
|
|
42
94
|
// per-project file: each is a pure function of ONE tree's .plugins, and the old single slot let the last-
|
|
@@ -321,6 +373,7 @@ export function dematerialize(proj = process.cwd(), arts = { skills: [], agents:
|
|
|
321
373
|
// the whole pay-per-change materialize. proj defaults to cwd. Its receipt is populated at each successful
|
|
322
374
|
// write so callers report the actual selected footprint instead of maintaining a second artifact inventory.
|
|
323
375
|
export function materialize(proj = process.cwd()) {
|
|
376
|
+
refreshCorePluginHandlers(proj);
|
|
324
377
|
const rt = treeSlotDir(proj); // this tree's slot in the global store, not the worktree
|
|
325
378
|
mkdirSync(rt, { recursive: true });
|
|
326
379
|
const planted = [];
|
|
@@ -30,6 +30,7 @@ export declare function readSessionTranscript(c: Context): Promise<(Response & i
|
|
|
30
30
|
readonly output?: string | undefined;
|
|
31
31
|
readonly outputLines: number;
|
|
32
32
|
readonly outputBytes: number;
|
|
33
|
+
readonly outcome?: "failed" | "rejected" | undefined;
|
|
33
34
|
}[] | undefined;
|
|
34
35
|
}[];
|
|
35
36
|
readonly truncated: boolean;
|