spexcode 0.7.0-next.4 → 0.7.0-next.6

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/session-application",
3
- "version": "0.7.0-next.4",
3
+ "version": "0.7.0-next.6",
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.4",
20
- "@spexcode/session-protocol": "0.7.0-next.4",
21
- "@spexcode/session-runtime": "0.7.0-next.4",
22
- "@spexcode/session-topology": "0.7.0-next.4"
19
+ "@spexcode/session-events": "0.7.0-next.6",
20
+ "@spexcode/session-protocol": "0.7.0-next.6",
21
+ "@spexcode/session-runtime": "0.7.0-next.6",
22
+ "@spexcode/session-topology": "0.7.0-next.6"
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.4",
3
+ "version": "0.7.0-next.6",
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.4" },
18
+ "dependencies": { "@spexcode/session-protocol": "0.7.0-next.6" },
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.4",
3
+ "version": "0.7.0-next.6",
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.4",
3
+ "version": "0.7.0-next.6",
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.4" },
18
+ "dependencies": { "@spexcode/session-protocol": "0.7.0-next.6" },
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.4",
3
+ "version": "0.7.0-next.6",
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.4",
28
- "@spexcode/session-runtime": "0.7.0-next.4"
27
+ "@spexcode/session-protocol": "0.7.0-next.6",
28
+ "@spexcode/session-runtime": "0.7.0-next.6"
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.4",
3
+ "version": "0.7.0-next.6",
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.4" },
18
+ "dependencies": { "@spexcode/session-protocol": "0.7.0-next.6" },
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
  }
@@ -18,7 +18,7 @@ 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, resolveCodexGenerationForSession } from './codex-runtime-generations.js';
22
22
  import { writeFileIfChanged } from './file-write.js';
23
23
  import { claudeTranscript, codexRolloutPath, codexTranscript, opencodeTranscript, piTranscript, unsupportedTranscript } from '@spexcode/transcript';
24
24
  import { harnessIdentity, HARNESS_IDENTITIES } from '@spexcode/spec-core';
@@ -220,12 +220,14 @@ function codexMutationGeneration(dir = runtimeRoot(), endpoint = legacyCodexGene
220
220
  return codexRuntimeGeneration(dir, endpoint);
221
221
  }
222
222
  const codexDescriptorKey = (endpoint) => endpoint.id === 'legacy' ? 'codex-app-server' : `codex-app-server:${endpoint.id}`;
223
- function codexEndpointForRecord(rec, dir = runtimeRoot()) {
223
+ function codexEndpointForRecord(rec, dir = runtimeRoot(), includeGone = false) {
224
224
  if (!rec.harnessSessionId)
225
225
  return null;
226
226
  const ledger = readCodexGenerationLedger(dir);
227
227
  if (ledger.revision === 0 && !ledger.current && !Object.keys(ledger.generations).length)
228
228
  return legacyCodexGenerationEndpoint(dir);
229
+ if (includeGone)
230
+ return resolveCodexGenerationForClose(dir, rec.session, rec.harnessSessionId)?.endpoint ?? null;
229
231
  return resolveCodexGenerationForSession(dir, rec.session, rec.harnessSessionId);
230
232
  }
231
233
  // the spex launcher (bin/spex.mjs), baked into the codex launch script (mirrors materialize.ts's SPEX) so
@@ -3028,7 +3030,7 @@ export const codexHarness = {
3028
3030
  interrupt: interruptCodexTurn,
3029
3031
  cleanupRuntime: async () => { },
3030
3032
  targetDescriptorKey: (rec) => {
3031
- const endpoint = codexEndpointForRecord(rec);
3033
+ const endpoint = codexEndpointForRecord(rec, runtimeRoot(), true);
3032
3034
  return endpoint ? codexDescriptorKey(endpoint) : null;
3033
3035
  },
3034
3036
  coldRetirementPreflight: async (rec) => {
@@ -3052,8 +3054,13 @@ export const codexHarness = {
3052
3054
  coldPreflight: async (rec) => {
3053
3055
  if (!rec.harnessSessionId)
3054
3056
  return { ok: false, reason: 'no exact Codex thread identity is registered' };
3055
- const endpoint = codexEndpointForRecord(rec);
3056
- return endpoint ? codexColdPreflight(rec.harnessSessionId, runtimeRoot(), undefined, endpoint)
3057
+ const dir = runtimeRoot();
3058
+ const binding = resolveCodexGenerationForClose(dir, rec.session, rec.harnessSessionId);
3059
+ if (binding?.gone) {
3060
+ return { ok: true, alreadyCold: true };
3061
+ }
3062
+ const endpoint = binding?.endpoint ?? codexEndpointForRecord(rec, dir);
3063
+ return endpoint ? codexColdPreflight(rec.harnessSessionId, dir, undefined, endpoint)
3057
3064
  : { ok: false, reason: 'no exact Codex generation binding is registered for this target' };
3058
3065
  },
3059
3066
  coldRuntime: async (rec, suppliedReceipt) => {
@@ -3061,7 +3068,12 @@ export const codexHarness = {
3061
3068
  return { ok: false, reason: 'no exact Codex thread identity is registered' };
3062
3069
  const threadId = rec.harnessSessionId;
3063
3070
  const dir = runtimeRoot();
3064
- const endpoint = codexEndpointForRecord(rec, dir);
3071
+ const binding = resolveCodexGenerationForClose(dir, rec.session, threadId);
3072
+ if (binding?.gone) {
3073
+ prepareCodexGenerationClose(dir, rec.session, threadId);
3074
+ return { ok: true };
3075
+ }
3076
+ const endpoint = binding?.endpoint ?? codexEndpointForRecord(rec, dir);
3065
3077
  if (!endpoint)
3066
3078
  return { ok: false, reason: 'no exact Codex generation binding is registered for this target' };
3067
3079
  const sock = endpoint.socketPath;
@@ -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))
@@ -471,6 +488,17 @@ const sessionStopBlocker = async (id, harnessId, recs = rawRecords(), knownProbe
471
488
  const entryTargetThread = entry.recs.find((rec) => rec.session_id === id)?.harness_session_id;
472
489
  if (entryTargetThread && ownerCounts.get(entryTargetThread) !== 1)
473
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
+ }
474
502
  if (!knownProbes && descriptor.mutationGuard) {
475
503
  if (!targetThread)
476
504
  return `${descriptor.label} target has no exact governed thread identity`;
@@ -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;
@@ -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;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/spec-cli",
3
- "version": "0.7.0-next.4",
3
+ "version": "0.7.0-next.6",
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.4",
39
- "@spexcode/session-selflaunch": "0.7.0-next.4",
40
- "@spexcode/spec-core": "0.7.0-next.4",
41
- "@spexcode/spec-eval": "0.7.0-next.4",
42
- "@spexcode/spec-forge": "0.7.0-next.4",
43
- "@spexcode/transcript": "0.7.0-next.4",
38
+ "@spexcode/session-application": "0.7.0-next.6",
39
+ "@spexcode/session-selflaunch": "0.7.0-next.6",
40
+ "@spexcode/spec-core": "0.7.0-next.6",
41
+ "@spexcode/spec-eval": "0.7.0-next.6",
42
+ "@spexcode/spec-forge": "0.7.0-next.6",
43
+ "@spexcode/transcript": "0.7.0-next.6",
44
44
  "smol-toml": "^1.8.0"
45
45
  },
46
46
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/spec-core",
3
- "version": "0.7.0-next.4",
3
+ "version": "0.7.0-next.6",
4
4
  "type": "module",
5
5
  "description": "SpexCode's dependency-minimal spec graph core.",
6
6
  "files": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/spec-eval",
3
- "version": "0.7.0-next.4",
3
+ "version": "0.7.0-next.6",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./dist/index.js",
@@ -26,7 +26,7 @@
26
26
  "test": "tsx --import ../scripts/test-home.mjs --test src/*.test.ts"
27
27
  },
28
28
  "dependencies": {
29
- "@spexcode/spec-core": "0.7.0-next.4"
29
+ "@spexcode/spec-core": "0.7.0-next.6"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^20.16.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/spec-forge",
3
- "version": "0.7.0-next.4",
3
+ "version": "0.7.0-next.6",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "test": "tsx --test src/*.test.ts"
23
23
  },
24
24
  "dependencies": {
25
- "@spexcode/spec-core": "0.7.0-next.4"
25
+ "@spexcode/spec-core": "0.7.0-next.6"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^20.16.0",
@@ -8,6 +8,7 @@ export type MutableTool = {
8
8
  output?: string;
9
9
  outputLines: number;
10
10
  outputBytes: number;
11
+ outcome?: ToolOutcome;
11
12
  };
12
13
  export type MutableTurn = {
13
14
  id: string | null;
@@ -16,12 +17,14 @@ export type MutableTurn = {
16
17
  text?: string;
17
18
  tools: MutableTool[];
18
19
  };
20
+ export type ToolOutcome = 'failed' | 'rejected';
19
21
  export type ParsedEvent = {
20
22
  at: number | null;
21
23
  turn: MutableTurn | null;
22
24
  toolOutputs?: readonly {
23
25
  id: string;
24
26
  text: string;
27
+ outcome?: ToolOutcome;
25
28
  }[];
26
29
  };
27
30
  export type Parse = (value: unknown) => ParsedEvent | null;
@@ -82,7 +82,7 @@ export function claudeEvent(value) {
82
82
  const outputs = blocks.flatMap((block) => {
83
83
  const b = object(block);
84
84
  const id = string(b?.tool_use_id);
85
- return b?.type === 'tool_result' && id ? [{ id, text: compact(b?.content ?? '') }] : [];
85
+ return b?.type === 'tool_result' && id ? [{ id, text: compact(b?.content ?? ''), ...(b?.is_error === true ? { outcome: 'failed' } : {}) }] : [];
86
86
  });
87
87
  if (outputs.length)
88
88
  return { at: eventAt, turn: null, toolOutputs: outputs };
@@ -204,9 +204,14 @@ export function codexAppServerEvent(value) {
204
204
  output = item.result ?? item.error;
205
205
  else if (type === 'dynamicToolCall')
206
206
  output = item.contentItems ?? item.output ?? item.error;
207
- return output === undefined || output === null
208
- ? { at: eventAt, turn: null }
209
- : { at: eventAt, turn: null, toolOutputs: [{ id, text: compact(output) }] };
207
+ // the item status is the app-server's own verdict: `failed`, or `declined` when the person refused the call —
208
+ // a declined call has no output, so the empty result is what ends its "running"
209
+ const status = string(item.status);
210
+ const outcome = status === 'failed' ? 'failed' : status === 'declined' ? 'rejected' : undefined;
211
+ if (output === undefined || output === null) {
212
+ return outcome ? { at: eventAt, turn: null, toolOutputs: [{ id, text: '', outcome }] } : { at: eventAt, turn: null };
213
+ }
214
+ return { at: eventAt, turn: null, toolOutputs: [{ id, text: compact(output), ...(outcome ? { outcome } : {}) }] };
210
215
  }
211
216
  // Agent-message deltas are fragments of one native item. The closure remembers only that item's text and
212
217
  // re-emits its native id, allowing IntervalCollector to replace the earlier turn in place.
@@ -268,7 +273,7 @@ export function piEvent(value) {
268
273
  }
269
274
  if (message.role === 'toolResult') {
270
275
  const id = string(message.toolCallId);
271
- return id ? { at: eventAt, turn: null, toolOutputs: [{ id, text: blockText(message.content) ?? compact(message.content ?? '') }] } : null;
276
+ return id ? { at: eventAt, turn: null, toolOutputs: [{ id, text: blockText(message.content) ?? compact(message.content ?? ''), ...(message.isError === true ? { outcome: 'failed' } : {}) }] } : null;
272
277
  }
273
278
  return null;
274
279
  }
@@ -303,7 +308,7 @@ export function geminiEvent(value) {
303
308
  for (const callValue of items(message.toolCalls)) {
304
309
  const call = object(callValue);
305
310
  const id = string(call?.id) ?? `tool-${turn.tools.length}`;
306
- turn.tools.push({ id, name: string(call?.name) ?? 'tool', input: call?.args === undefined ? undefined : compact(call.args), outputLines: 0, outputBytes: 0 });
311
+ turn.tools.push({ id, name: string(call?.name) ?? 'tool', input: call?.args === undefined ? undefined : compact(call.args), outputLines: 0, outputBytes: 0, ...(call?.status === 'error' ? { outcome: 'failed' } : {}) });
307
312
  }
308
313
  if (!turn.text && !turn.tools.length)
309
314
  return null;
@@ -325,7 +330,7 @@ export function openclawEvent(value) {
325
330
  }
326
331
  if (message.role === 'toolResult') {
327
332
  const id = string(message.toolCallId);
328
- return id ? { at: eventAt, turn: null, toolOutputs: [{ id, text: blockText(message.content) ?? compact(message.content ?? '') }] } : null;
333
+ return id ? { at: eventAt, turn: null, toolOutputs: [{ id, text: blockText(message.content) ?? compact(message.content ?? ''), ...(message.isError === true ? { outcome: 'failed' } : {}) }] } : null;
329
334
  }
330
335
  if (message.role === 'assistant') {
331
336
  const turn = { id: idOf(entry) ?? idOf(message), at: eventAt, role: 'assistant', tools: [] };
@@ -414,6 +419,8 @@ export function opencodeEvents(value) {
414
419
  tool.output = output.slice(0, MAX_OUTPUT_BYTES);
415
420
  tool.outputBytes = Buffer.byteLength(output);
416
421
  tool.outputLines = lineCount(output);
422
+ if (status === 'error')
423
+ tool.outcome = 'failed';
417
424
  }
418
425
  turn.tools.push(tool);
419
426
  }
@@ -466,6 +473,8 @@ export class IntervalCollector {
466
473
  tool.outputLines += lineCount(output.text);
467
474
  if (tool.output === undefined)
468
475
  tool.output = '';
476
+ if (output.outcome)
477
+ tool.outcome = output.outcome;
469
478
  const remaining = Math.max(0, MAX_OUTPUT_BYTES - Buffer.byteLength(tool.output));
470
479
  tool.output += output.text.slice(0, remaining);
471
480
  if (bytes > remaining)
@@ -9,6 +9,7 @@ export type TranscriptTool = Readonly<{
9
9
  output?: string;
10
10
  outputLines: number;
11
11
  outputBytes: number;
12
+ outcome?: 'failed' | 'rejected';
12
13
  }>;
13
14
  export type TranscriptTurn = Readonly<{
14
15
  id: string;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/transcript",
3
- "version": "0.7.0-next.4",
3
+ "version": "0.7.0-next.6",
4
4
  "type": "module",
5
5
  "description": "Normalized agent transcripts: one parser per harness, a bounded interval reader over a native thread file or an in-memory event stream, and the full/delta frame protocol every transport and renderer share.",
6
6
  "files": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spexcode",
3
- "version": "0.7.0-next.4",
3
+ "version": "0.7.0-next.6",
4
4
  "type": "module",
5
5
  "description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
6
6
  "license": "MIT",
@@ -44,7 +44,7 @@
44
44
  "prepack": "node scripts/sync-init-plugins.mjs --check && node scripts/prepack.mjs"
45
45
  },
46
46
  "dependencies": {
47
- "@spexcode/spec-cli": "0.7.0-next.4"
47
+ "@spexcode/spec-cli": "0.7.0-next.6"
48
48
  },
49
49
  "publishConfig": {
50
50
  "access": "public"