@spexcode/spec-cli 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/dist/client.js CHANGED
@@ -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),
package/dist/harness.d.ts CHANGED
@@ -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;
package/dist/harness.js CHANGED
@@ -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;
package/dist/sessions.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFile, execFileSync, spawn } from 'node:child_process';
2
+ import { createConnection } from 'node:net';
2
3
  import { promisify } from 'node:util';
3
4
  import { createHash, randomUUID } from 'node:crypto';
4
5
  import { readFileSync, writeFileSync, existsSync, renameSync, linkSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync, unlinkSync } from 'node:fs';
@@ -15,6 +16,7 @@ import { readSessionFiles } from './session-files.js';
15
16
  import { readSessionWebs } from './session-web.js';
16
17
  import { acquireFreshSessionApplicationForCreate, configuredSessionApplicationIfCutover, initializeFreshSessionApplication, releaseFreshSessionApplicationForCreate, sessionApplicationCutoverState, setSessionApplicationCommitWake } from './session-application.js';
17
18
  import { jsonMigrationFencePath } from '@spexcode/session-application';
19
+ import { decodeEventJson } from '@spexcode/session-events';
18
20
  import { withDeliveryLocks } from './delivery-lock.js';
19
21
  import { withSessionRecordLock, withSessionRecordLockSync as coreWithSessionRecordLockSync } from './session-record-lock.js';
20
22
  import { stripRefSigil } from './mentions.js';
@@ -1100,7 +1102,7 @@ export function toSession(rec, status, lv, activity = null) {
1100
1102
  const pp = prompt ? oneLinePreview(prompt) : null;
1101
1103
  const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, note: rec.note, promptPreview: pp };
1102
1104
  const harness = harnessById(rec.harness || defaultHarness.id);
1103
- return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), title: deriveTitle(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless }, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, archived: rec.archived, closedAt: rec.archived ? rec.closedAt : null, archiveHazard: null, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey, files: readSessionFiles(rec.session), web: readSessionWebs(rec.session), ...(rec.zcodeChildSessionIds?.length ? { zcodeChildSessionIds: [...rec.zcodeChildSessionIds] } : {}) };
1105
+ return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), title: deriveTitle(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless }, launcher: rec.launcher, lifecycle: rec.closedAt ? 'archived' : rec.status, proposal: rec.closedAt ? null : rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, archived: rec.archived || !!rec.closedAt, closedAt: rec.closedAt, archiveHazard: null, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey, files: readSessionFiles(rec.session), web: readSessionWebs(rec.session), ...(rec.zcodeChildSessionIds?.length ? { zcodeChildSessionIds: [...rec.zcodeChildSessionIds] } : {}) };
1104
1106
  }
1105
1107
  // @@@zcode child identity - ZCode owns the child id and SpexCode owns the session record. The writer accepts
1106
1108
  // only their exact declared pair; names, worktrees, branches, and timestamps are deliberately not candidates.
@@ -1174,7 +1176,7 @@ export async function listArchivedSessionIndex(probe) {
1174
1176
  if (entry.kind !== 'ok')
1175
1177
  continue;
1176
1178
  const rec = fromRaw(entry.raw);
1177
- if (!rec.governed || !rec.archived)
1179
+ if (!rec.governed || (!rec.archived && !rec.closedAt))
1178
1180
  continue;
1179
1181
  const parts = {
1180
1182
  id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch,
@@ -1303,7 +1305,11 @@ export async function listSessions(includeArchived = false) {
1303
1305
  // missing durable cold proof is also legacy: leaf liveness alone cannot prove a Codex loaded thread was
1304
1306
  // unloaded, so it remains visible until an explicit archive repair.
1305
1307
  const cleanCold = projectedRecord.archived && !changedDuringCensus.has(id) && hasValidColdProof(projectedRecord) && physical === 'offline' && (!residentRequired || resident?.healthy === true);
1306
- const projected = projectedRecord.archived && !cleanCold ? { ...projectedRecord, archived: false, stopped: false } : projectedRecord;
1308
+ // A published close is terminal public history even if a later census cannot prove the old adapter fully
1309
+ // unloaded. Only legacy archived rows without closedAt may be exposed as a working hazard for repair.
1310
+ const projected = projectedRecord.archived && !cleanCold && !projectedRecord.closedAt
1311
+ ? { ...projectedRecord, archived: false, stopped: false }
1312
+ : projectedRecord;
1307
1313
  const projectedLv = projected === projectedRecord
1308
1314
  ? sessionHarness.runtimeOwnership === 'adapter'
1309
1315
  ? adapterResidentLiveness(projectedRecord, resident)
@@ -1704,11 +1710,45 @@ let draining = false; // re-entrancy guard: only one drain pass runs at a time (
1704
1710
  // A native receipt is bound before the readiness fence validates it. Suppress only that immediate wake so
1705
1711
  // queued prompts cannot drain during the candidate window; the successful publication path drains normally.
1706
1712
  const readinessWakeSuppressed = new Set();
1713
+ // A readiness timeout is launch-phase evidence only until the session produces another durable event. The
1714
+ // first event at/after the readiness marker is the launch transition itself; anything after that proves the
1715
+ // worker progressed (including a declaration), so a late diagnostic is moot and must not replace its note.
1716
+ function hasLaterLaunchReadinessEvent(rec) {
1717
+ const startedAt = rec.launchReadinessStartedAt;
1718
+ if (!Number.isFinite(startedAt))
1719
+ return false;
1720
+ const application = configuredSessionApplicationIfCutover();
1721
+ if (!application?.readState(rec.session))
1722
+ return false;
1723
+ const events = application.readEvents(rec.session);
1724
+ const statusPayload = (event) => {
1725
+ const payload = decodeEventJson(event.payload);
1726
+ return payload && typeof payload === 'object' && !Array.isArray(payload) && 'status' in payload
1727
+ ? payload : null;
1728
+ };
1729
+ const baseline = events.find((event) => {
1730
+ if (event.occurredAtMs < Number(startedAt))
1731
+ return false;
1732
+ const payload = statusPayload(event);
1733
+ return payload?.status === 'active';
1734
+ });
1735
+ return baseline ? events.some((event) => {
1736
+ if (event.eventSeq <= baseline.eventSeq)
1737
+ return false;
1738
+ const payload = statusPayload(event);
1739
+ if (!payload)
1740
+ return false;
1741
+ const note = payload.note;
1742
+ return !(typeof note === 'string' && /^(?:queued launch readiness failed|launch readiness warning):/.test(note));
1743
+ }) : false;
1744
+ }
1707
1745
  function noteQueuedLaunchFailureUnlocked(id, error, terminal = true, label, live = false) {
1746
+ const rec = readRecord(id);
1747
+ if (rec && (terminal || label === 'launch readiness warning') && hasLaterLaunchReadinessEvent(rec))
1748
+ return;
1708
1749
  const reason = error instanceof Error ? error.message : String(error);
1709
1750
  const note = `${label ?? (terminal ? 'queued launch readiness failed' : 'launch readiness warning')}: ${reason}`;
1710
1751
  console.error(`spex: session ${id}: ${note}`);
1711
- const rec = readRecord(id);
1712
1752
  if (rec && !retirementReason(rec) && (rec.note !== note
1713
1753
  || (terminal && (rec.status !== 'error' || !rec.stopped || rec.launchReadinessStartedAt != null))
1714
1754
  || (!terminal && live && (rec.status === 'error' || rec.stopped)))) {
@@ -1788,12 +1828,16 @@ export function canonicalRecordProjection(rec, canonical) {
1788
1828
  // The application row is the only lifecycle fact after cutover. A JSON status is historical envelope data,
1789
1829
  // so it must not win merely because it says waiting/error/archived while the canonical row says otherwise.
1790
1830
  if (!canonical) {
1791
- return rec;
1831
+ return ('closedAt' in rec && rec.closedAt
1832
+ ? { ...rec, archived: true }
1833
+ : rec);
1792
1834
  }
1835
+ const closed = 'closedAt' in rec && !!rec.closedAt;
1793
1836
  return {
1794
1837
  ...rec,
1795
- status: canonical.status,
1796
- proposal: canonical.proposal,
1838
+ archived: closed ? true : rec.archived,
1839
+ status: (closed ? 'archived' : canonical.status),
1840
+ proposal: (closed ? null : canonical.proposal),
1797
1841
  note: canonical.note,
1798
1842
  parent: canonical.parentSessionId,
1799
1843
  };
@@ -2488,9 +2532,53 @@ function isExplicitConnectionRefused(error) {
2488
2532
  return errors.length > 0 && errors.every(isExplicitConnectionRefused);
2489
2533
  return isExplicitConnectionRefused(error.cause);
2490
2534
  }
2535
+ async function establishBackendConnection(target) {
2536
+ const parsed = new URL(target.url);
2537
+ const port = Number(parsed.port) || (parsed.protocol === 'https:' ? 443 : 80);
2538
+ return await new Promise((resolve, reject) => {
2539
+ const socket = createConnection({ host: parsed.hostname, port });
2540
+ let settled = false;
2541
+ const finish = (fn) => {
2542
+ if (settled)
2543
+ return;
2544
+ settled = true;
2545
+ clearTimeout(timer);
2546
+ socket.destroy();
2547
+ fn();
2548
+ };
2549
+ const timer = setTimeout(() => {
2550
+ // The event loop may have been blocked past the wall after the kernel completed the handshake. In that
2551
+ // case Node has not emitted `connect` yet, but `connecting` is already false; acceptance still proves
2552
+ // presence and must not be relabelled as an unavailable backend.
2553
+ if (!socket.connecting || socket.readyState === 'open')
2554
+ return finish(() => resolve(false));
2555
+ finish(() => {
2556
+ const error = new Error(`backend connection was not accepted at ${target.url} within 1500ms`);
2557
+ error.name = 'BackendError';
2558
+ Object.assign(error, { code: 'backend_availability_indeterminate' });
2559
+ reject(error);
2560
+ });
2561
+ }, 1500);
2562
+ timer.unref?.();
2563
+ socket.once('connect', () => finish(() => resolve(false)));
2564
+ socket.once('error', (error) => finish(() => {
2565
+ if (isExplicitConnectionRefused(error))
2566
+ return resolve(true);
2567
+ const failed = new Error(`backend availability is indeterminate at ${target.url}; refusing in-process session creation (${error instanceof Error ? error.message : error})`);
2568
+ failed.name = 'BackendError';
2569
+ Object.assign(failed, { code: 'backend_availability_indeterminate', cause: error });
2570
+ reject(failed);
2571
+ }));
2572
+ });
2573
+ }
2491
2574
  async function probeSessionCreateAuthority(target) {
2575
+ // TCP acceptance establishes presence. The identity route can be delayed by a busy backend event loop,
2576
+ // so it gets the ordinary create request deadline instead of a short availability deadline.
2577
+ const refused = await establishBackendConnection(target);
2578
+ if (refused)
2579
+ return true;
2492
2580
  const controller = new AbortController();
2493
- const timer = setTimeout(() => controller.abort(), 1500);
2581
+ const timer = setTimeout(() => controller.abort(new Error('backend authority request timed out')), sessionCreateTimeoutMs() + 5_000);
2494
2582
  timer.unref?.();
2495
2583
  let response;
2496
2584
  try {
@@ -2498,11 +2586,9 @@ async function probeSessionCreateAuthority(target) {
2498
2586
  }
2499
2587
  catch (error) {
2500
2588
  clearTimeout(timer);
2501
- if (isExplicitConnectionRefused(error))
2502
- return true;
2503
- const failed = new Error(`backend availability is indeterminate at ${target.url}; refusing in-process session creation (${error instanceof Error ? error.message : error})`);
2589
+ const failed = new Error(`backend authority read failed after connection at ${target.url}; refusing in-process session creation (${error instanceof Error ? error.message : error})`);
2504
2590
  failed.name = 'BackendError';
2505
- Object.assign(failed, { code: 'backend_availability_indeterminate', cause: error });
2591
+ Object.assign(failed, { code: 'backend_authority_read_failed', cause: error });
2506
2592
  throw failed;
2507
2593
  }
2508
2594
  try {
@@ -2988,7 +3074,7 @@ async function prepareSession(prompt, parent, launcher, name, context) {
2988
3074
  try {
2989
3075
  gitMutationStarted = true;
2990
3076
  traceSessionCreate(id, requestDigest, phase, 'start', 'worktree-add');
2991
- const added = await withGitAbortSignal(signal, () => gitTry(['-C', root, 'worktree', 'add', '-b', branch, path, startPoint], { extraEnv: DEFER_FOOTPRINT_REFRESH }));
3077
+ const added = await withGitAbortSignal(signal, () => gitTry(['-C', root, 'worktree', 'add', '--no-track', '-b', branch, path, startPoint], { extraEnv: DEFER_FOOTPRINT_REFRESH }));
2992
3078
  traceSessionCreate(id, requestDigest, phase, 'finish', 'worktree-add');
2993
3079
  if (added.ok)
2994
3080
  Object.assign(owned, { path: true, worktree: true, branch: true });
@@ -4711,12 +4797,25 @@ async function closeOwnedSessionUnlocked(id, wt, _source, unboundStopped = false
4711
4797
  throw new ResourceConflict(`refusing to finish close for ${id}: session record disappeared before publication`);
4712
4798
  writeRecord({
4713
4799
  ...latest,
4800
+ proposal: null,
4714
4801
  archived: true,
4715
4802
  closedAt: latest.closedAt || new Date().toISOString(),
4716
4803
  stopped: true,
4717
4804
  coldProof: latest.coldProof || coldProofFor(latest),
4718
4805
  adapterRecovery: null,
4719
4806
  });
4807
+ // The canonical lifecycle must settle at the same terminal boundary as the durable close fact. `archived`
4808
+ // is an internal terminal marker; public projections render its closed record as `retired`.
4809
+ const application = configuredSessionApplicationIfCutover();
4810
+ if (application?.readState(id)) {
4811
+ application.transitionSession(id, {
4812
+ status: 'archived',
4813
+ proposal: null,
4814
+ note: latest.note,
4815
+ parentSessionId: latest.parent,
4816
+ recipientSessionIds: canonicalWatchRecipients(application, id, 'archived'),
4817
+ });
4818
+ }
4720
4819
  let slot = null;
4721
4820
  try {
4722
4821
  slot = existsSync(wt.path) ? treeSlotDir(wt.path) : null;
@@ -5289,6 +5388,8 @@ export async function drainSession(id) {
5289
5388
  const removed = application.dequeuePendingMessage(id, msg.messageId);
5290
5389
  if (!removed || removed.messageId !== msg.messageId)
5291
5390
  throw new ResourceConflict(`canonical queue head changed while delivering ${id}`);
5391
+ if (!msg.senderSessionId)
5392
+ markHumanPromptActive(id);
5292
5393
  }
5293
5394
  });
5294
5395
  return;
@@ -5315,6 +5416,8 @@ export async function drainSession(id) {
5315
5416
  const removed = application.dequeueForRuntime(id, 'spex-governed', binding.bindingGeneration, msg.messageId);
5316
5417
  if (!removed || removed.messageId !== msg.messageId)
5317
5418
  throw new ResourceConflict(`canonical queue head changed while delivering ${id}`);
5419
+ if (!msg.senderSessionId)
5420
+ markHumanPromptActive(id);
5318
5421
  }
5319
5422
  });
5320
5423
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/spec-cli",
3
- "version": "0.7.0-next.0",
3
+ "version": "0.7.0-next.10",
4
4
  "type": "module",
5
5
  "description": "SpexCode CLI + server. The root spexcode package delegates to this compiled package; dashboard assets live in @spexcode/spec-dashboard.",
6
6
  "bin": {
@@ -35,12 +35,12 @@
35
35
  "test": "tsx --import ../scripts/test-home.mjs --test src/*.test.ts"
36
36
  },
37
37
  "dependencies": {
38
- "@spexcode/session-application": "0.7.0-next.0",
39
- "@spexcode/session-selflaunch": "0.7.0-next.0",
40
- "@spexcode/spec-core": "0.7.0-next.0",
41
- "@spexcode/spec-eval": "0.7.0-next.0",
42
- "@spexcode/spec-forge": "0.7.0-next.0",
43
- "@spexcode/transcript": "0.7.0-next.0",
38
+ "@spexcode/session-application": "0.7.0-next.10",
39
+ "@spexcode/session-selflaunch": "0.7.0-next.10",
40
+ "@spexcode/spec-core": "0.7.0-next.10",
41
+ "@spexcode/spec-eval": "0.7.0-next.10",
42
+ "@spexcode/spec-forge": "0.7.0-next.10",
43
+ "@spexcode/transcript": "0.7.0-next.10",
44
44
  "smol-toml": "^1.8.0"
45
45
  },
46
46
  "devDependencies": {
@@ -19,9 +19,15 @@ Land the current SpexCode session's branch; do not dispatch another merge reques
19
19
  3. Immediately before landing, verify
20
20
  `git merge-base --is-ancestor <source-head> <session-head>`. If it fails, sync again. A clean textual merge
21
21
  is not product proof.
22
- 4. In the source-of-truth checkout, make one `--no-ff` merge of the already-synced session tip. Do not resolve
23
- conflicts there. If unrelated dirty work prevents the merge, preserve it byte-for-byte and report the exact
24
- overlap rather than forcing, resetting, or committing it.
22
+ 4. Land with one `--no-ff` merge of the already-synced session tip, without touching the source checkout's
23
+ dirty work. Git refuses a merge over a dirty index, so do NOT clear it: add a temporary detached worktree of
24
+ the source head (`git worktree add --detach <tmp> <source-branch>`), make the `--no-ff` merge there, then
25
+ fast-forward the source checkout to that commit (`git merge --ff-only <tmp-head>`) and remove the temporary
26
+ worktree. A fast-forward never rewrites a path the merge did not change, so user-owned dirty files keep
27
+ their bytes and their staged/unstaged state. Never `git restore`, `reset`, `checkout`, `stash`, or overwrite
28
+ a user-owned path — an unstaged edit has no blob anywhere, so "save and put back" loses it, and a copy of the
29
+ same path from another worktree is a different version, not a restoration. Do not resolve conflicts in the
30
+ source checkout; if the merge genuinely overlaps a user-owned path, stop and report the exact overlap.
25
31
  5. Verify the source checkout has no `MERGE_HEAD`, the session tip is its ancestor, unrelated dirty
26
32
  fingerprints are unchanged, and the post-merge gates pass. Push the source-of-truth branch only after
27
33
  those checks.