spexcode 0.7.0-next.1 → 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.
Files changed (30) hide show
  1. package/node_modules/@spexcode/session-application/package.json +5 -5
  2. package/node_modules/@spexcode/session-events/package.json +2 -2
  3. package/node_modules/@spexcode/session-protocol/package.json +1 -1
  4. package/node_modules/@spexcode/session-runtime/package.json +2 -2
  5. package/node_modules/@spexcode/session-selflaunch/package.json +3 -3
  6. package/node_modules/@spexcode/session-topology/package.json +2 -2
  7. package/node_modules/@spexcode/spec-cli/dist/client.js +7 -5
  8. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +4 -0
  9. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +12 -0
  10. package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +4 -1
  11. package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +1 -0
  12. package/node_modules/@spexcode/spec-cli/dist/harness.js +48 -8
  13. package/node_modules/@spexcode/spec-cli/dist/host-resources.js +55 -3
  14. package/node_modules/@spexcode/spec-cli/dist/materialize.js +54 -1
  15. package/node_modules/@spexcode/spec-cli/dist/session-transcript.d.ts +1 -0
  16. package/node_modules/@spexcode/spec-cli/dist/sessions.js +115 -12
  17. package/node_modules/@spexcode/spec-cli/package.json +7 -7
  18. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/merge/spec.md +9 -3
  19. package/node_modules/@spexcode/spec-core/dist/git.d.ts +1 -0
  20. package/node_modules/@spexcode/spec-core/dist/git.js +25 -0
  21. package/node_modules/@spexcode/spec-core/package.json +1 -1
  22. package/node_modules/@spexcode/spec-eval/package.json +2 -2
  23. package/node_modules/@spexcode/spec-forge/package.json +2 -2
  24. package/node_modules/@spexcode/transcript/dist/parsers.d.ts +5 -0
  25. package/node_modules/@spexcode/transcript/dist/parsers.js +211 -23
  26. package/node_modules/@spexcode/transcript/dist/readers.d.ts +1 -1
  27. package/node_modules/@spexcode/transcript/dist/readers.js +7 -3
  28. package/node_modules/@spexcode/transcript/dist/turns.d.ts +1 -0
  29. package/node_modules/@spexcode/transcript/package.json +1 -1
  30. package/package.json +2 -2
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/session-application",
3
- "version": "0.7.0-next.1",
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.1",
20
- "@spexcode/session-protocol": "0.7.0-next.1",
21
- "@spexcode/session-runtime": "0.7.0-next.1",
22
- "@spexcode/session-topology": "0.7.0-next.1"
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.1",
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.1" },
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-protocol",
3
- "version": "0.7.0-next.1",
3
+ "version": "0.7.0-next.10",
4
4
  "type": "module",
5
5
  "description": "A durable SQLite session message protocol.",
6
6
  "files": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/session-runtime",
3
- "version": "0.7.0-next.1",
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.1" },
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.1",
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.1",
28
- "@spexcode/session-runtime": "0.7.0-next.1"
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.1",
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.1" },
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 === 'awaiting'
183
- ? displayStatusForProposal(state.proposal)
184
- : state.status === 'archived' ? 'offline' : 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),
@@ -206,6 +206,7 @@ export type DispatchResult = {
206
206
  };
207
207
  export type HarnessDeliveryRecord = {
208
208
  session: string;
209
+ harness?: string;
209
210
  stopped?: boolean;
210
211
  archived?: boolean;
211
212
  worktreePath?: string;
@@ -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
- const endpoint = rec.harnessSessionId ? codexEndpointForRecord(rec, runtimeDir) : currentCodexGeneration(runtimeDir);
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
- const sessionHomeLiveness = (_rec, tmuxAlive) => tmuxAlive ? 'online' : 'offline';
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 endpoint = codexEndpointForRecord(rec);
3053
- return endpoint ? codexColdPreflight(rec.harnessSessionId, runtimeRoot(), undefined, endpoint)
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 endpoint = codexEndpointForRecord(rec, dir);
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;