@spexcode/spec-cli 0.7.0-next.5 → 0.7.0-next.7

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,
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, prepareCodexGenerationClose, resolveCodexGenerationForClose, 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';
@@ -2282,7 +2283,21 @@ async function deliverViaCodexAppServer(rec, text) {
2282
2283
  // the socket is PER-PROJECT (the runtime root), shared by every worktree's thread; the owned thread id on
2283
2284
  // the record picks out THIS session's thread.
2284
2285
  const runtimeDir = rec.runtimeDir ?? runtimeRoot();
2285
- 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
+ }
2286
2301
  if (!endpoint)
2287
2302
  return { ok: false, error: `no exact Codex generation binding for session ${rec.session} — immediate poke unavailable` };
2288
2303
  const sock = endpoint.socketPath;
@@ -2297,6 +2312,14 @@ async function deliverViaCodexAppServer(rec, text) {
2297
2312
  return { ok: false, error: `${r.error} — immediate poke unavailable` };
2298
2313
  threadId = r.threadId;
2299
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}` };
2300
2323
  return sendCodexAppServerTurn(sock, threadId, text, rec.worktreePath, rec.mid);
2301
2324
  }
2302
2325
  // idempotent replace of the content between sentinels; the user's own content above/below is preserved. The
@@ -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();
@@ -345,13 +362,13 @@ const buildInventory = (procs, publicRecords = publicRecordInventory()) => {
345
362
  for (const p of procs.values()) {
346
363
  const acting = actingSession(p);
347
364
  const fallback = p.env.SPEXCODE_SESSION_ID;
348
- const sid = acting ?? (fallback ? byId.get(fallback) : undefined);
365
+ const sid = acting ?? (!hasTmuxAncestor(p.pid, procs) && !isTmuxServer(p) && fallback ? byId.get(fallback) : undefined);
349
366
  if (sid)
350
367
  ownership.set(p.pid, `session:${sid}`);
351
368
  }
352
369
  for (const rec of activeRecs) {
353
370
  const root = runtimePid(join(runtimeRoot(), 'sessions', rec.session_id, 'agent.pid'));
354
- if (root && procs.has(root))
371
+ if (root && procs.has(root) && !isTmuxServer(procs.get(root)))
355
372
  for (const pid of descendants(root, procs))
356
373
  if (!ownership.has(pid))
357
374
  ownership.set(pid, `session:${rec.session_id}`);
@@ -391,7 +408,7 @@ const buildInventory = (procs, publicRecords = publicRecordInventory()) => {
391
408
  }
392
409
  const root = repoRoot();
393
410
  for (const p of procs.values()) {
394
- 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))
395
412
  continue;
396
413
  const claimed = p.env.SPEXCODE_SESSION_ID;
397
414
  if (claimed && !byId.has(claimed))
@@ -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
@@ -1102,7 +1102,7 @@ export function toSession(rec, status, lv, activity = null) {
1102
1102
  const pp = prompt ? oneLinePreview(prompt) : null;
1103
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 };
1104
1104
  const harness = harnessById(rec.harness || defaultHarness.id);
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.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] } : {}) };
1106
1106
  }
1107
1107
  // @@@zcode child identity - ZCode owns the child id and SpexCode owns the session record. The writer accepts
1108
1108
  // only their exact declared pair; names, worktrees, branches, and timestamps are deliberately not candidates.
@@ -1176,7 +1176,7 @@ export async function listArchivedSessionIndex(probe) {
1176
1176
  if (entry.kind !== 'ok')
1177
1177
  continue;
1178
1178
  const rec = fromRaw(entry.raw);
1179
- if (!rec.governed || !rec.archived)
1179
+ if (!rec.governed || (!rec.archived && !rec.closedAt))
1180
1180
  continue;
1181
1181
  const parts = {
1182
1182
  id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch,
@@ -1305,7 +1305,11 @@ export async function listSessions(includeArchived = false) {
1305
1305
  // missing durable cold proof is also legacy: leaf liveness alone cannot prove a Codex loaded thread was
1306
1306
  // unloaded, so it remains visible until an explicit archive repair.
1307
1307
  const cleanCold = projectedRecord.archived && !changedDuringCensus.has(id) && hasValidColdProof(projectedRecord) && physical === 'offline' && (!residentRequired || resident?.healthy === true);
1308
- 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;
1309
1313
  const projectedLv = projected === projectedRecord
1310
1314
  ? sessionHarness.runtimeOwnership === 'adapter'
1311
1315
  ? adapterResidentLiveness(projectedRecord, resident)
@@ -1824,12 +1828,16 @@ export function canonicalRecordProjection(rec, canonical) {
1824
1828
  // The application row is the only lifecycle fact after cutover. A JSON status is historical envelope data,
1825
1829
  // so it must not win merely because it says waiting/error/archived while the canonical row says otherwise.
1826
1830
  if (!canonical) {
1827
- return rec;
1831
+ return ('closedAt' in rec && rec.closedAt
1832
+ ? { ...rec, archived: true }
1833
+ : rec);
1828
1834
  }
1835
+ const closed = 'closedAt' in rec && !!rec.closedAt;
1829
1836
  return {
1830
1837
  ...rec,
1831
- status: canonical.status,
1832
- proposal: canonical.proposal,
1838
+ archived: closed ? true : rec.archived,
1839
+ status: (closed ? 'archived' : canonical.status),
1840
+ proposal: (closed ? null : canonical.proposal),
1833
1841
  note: canonical.note,
1834
1842
  parent: canonical.parentSessionId,
1835
1843
  };
@@ -4789,12 +4797,25 @@ async function closeOwnedSessionUnlocked(id, wt, _source, unboundStopped = false
4789
4797
  throw new ResourceConflict(`refusing to finish close for ${id}: session record disappeared before publication`);
4790
4798
  writeRecord({
4791
4799
  ...latest,
4800
+ proposal: null,
4792
4801
  archived: true,
4793
4802
  closedAt: latest.closedAt || new Date().toISOString(),
4794
4803
  stopped: true,
4795
4804
  coldProof: latest.coldProof || coldProofFor(latest),
4796
4805
  adapterRecovery: null,
4797
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
+ }
4798
4819
  let slot = null;
4799
4820
  try {
4800
4821
  slot = existsSync(wt.path) ? treeSlotDir(wt.path) : null;
@@ -5367,6 +5388,8 @@ export async function drainSession(id) {
5367
5388
  const removed = application.dequeuePendingMessage(id, msg.messageId);
5368
5389
  if (!removed || removed.messageId !== msg.messageId)
5369
5390
  throw new ResourceConflict(`canonical queue head changed while delivering ${id}`);
5391
+ if (!msg.senderSessionId)
5392
+ markHumanPromptActive(id);
5370
5393
  }
5371
5394
  });
5372
5395
  return;
@@ -5393,6 +5416,8 @@ export async function drainSession(id) {
5393
5416
  const removed = application.dequeueForRuntime(id, 'spex-governed', binding.bindingGeneration, msg.messageId);
5394
5417
  if (!removed || removed.messageId !== msg.messageId)
5395
5418
  throw new ResourceConflict(`canonical queue head changed while delivering ${id}`);
5419
+ if (!msg.senderSessionId)
5420
+ markHumanPromptActive(id);
5396
5421
  }
5397
5422
  });
5398
5423
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/spec-cli",
3
- "version": "0.7.0-next.5",
3
+ "version": "0.7.0-next.7",
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.5",
39
- "@spexcode/session-selflaunch": "0.7.0-next.5",
40
- "@spexcode/spec-core": "0.7.0-next.5",
41
- "@spexcode/spec-eval": "0.7.0-next.5",
42
- "@spexcode/spec-forge": "0.7.0-next.5",
43
- "@spexcode/transcript": "0.7.0-next.5",
38
+ "@spexcode/session-application": "0.7.0-next.7",
39
+ "@spexcode/session-selflaunch": "0.7.0-next.7",
40
+ "@spexcode/spec-core": "0.7.0-next.7",
41
+ "@spexcode/spec-eval": "0.7.0-next.7",
42
+ "@spexcode/spec-forge": "0.7.0-next.7",
43
+ "@spexcode/transcript": "0.7.0-next.7",
44
44
  "smol-toml": "^1.8.0"
45
45
  },
46
46
  "devDependencies": {