klypix-mcp 1.109.0 → 1.110.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.109.0",
3
+ "version": "1.110.0",
4
4
  "mcpName": "io.github.dahshanlabs/klypix-mcp",
5
5
  "description": "Shared project memory for Claude Code, Codex and Cursor: decisions, corrections, overlap warnings.",
6
6
  "type": "module",
@@ -100,7 +100,7 @@
100
100
  "bench": "node bin/klypix-mcp.mjs bench",
101
101
  "test:bench": "node test/bench.mjs",
102
102
  "pretest": "node test/publish-workflow.mjs",
103
- "test": "node test/publish-verdict.mjs && node test/npx-owned-names.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/session-mailbox.mjs && node test/session-reopen.mjs && node test/session-handoff.mjs && node test/continuity-after-kill.mjs && node test/handoff-freshness.mjs && node test/handoff-hosts.mjs && node test/lock-notes.mjs && node test/whose-classify.mjs && node test/whose-ledger.mjs && node test/whose-rescue.mjs && node test/whose-hook.mjs && node test/live-projection.mjs && node test/lane-write-retry.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/network-bridge.mjs && node test/brain-rules.mjs && node test/rule-approval.mjs && node test/rule-approval-stdio.mjs && node test/brain-recall.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/brain-ask-words.mjs && node test/retrieval-fusion.mjs && node test/eval-retrieval.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/partial-notes.mjs && node test/lifecycle-prefix.mjs && node test/close-link-safety.mjs && node test/resolve-ledger.mjs && node test/plan-fulfillment.mjs && node test/arrange-receipts.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/semantic-preparation.mjs && node test/semantic-hash-parity.mjs && node test/enrichment.mjs && node test/provenance.mjs && node test/confirm-trail.mjs && node test/hook-fallback.mjs && node test/eval-hook-lane.mjs && node test/hook-unified-lane.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/marker-suffix-grammar.mjs && node test/marker-after-colon.mjs && node test/evidence-anchors.mjs && node test/brain-evidence.mjs && node test/crowd-safeguards.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/overlap-tiers.mjs && node test/coordination-bench.mjs && node test/release-lease.mjs && node test/release-reconcile.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/results-not-manifests.mjs && node test/merge-brains.mjs && node test/merge-readings.mjs && node test/revival-map.mjs && node test/merge-scale.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/canvas-groups.mjs && node test/git-tools.mjs && node test/link-compat.mjs && node test/uninstall.mjs && node test/current-guidance.mjs && node test/current-first.mjs && node test/status-shape.mjs && node test/status-hook.mjs && node test/read-canvas-saved-readings.mjs && node test/document-readings.mjs && node test/open-lease.mjs && node test/agent-card-style.mjs && node test/tool-schema-freeze.mjs && node test/small-identity.mjs && node test/app-lease-vectors.mjs && node test/app-tools-stdio.mjs && node test/card-contents-files.mjs && node test/video-prep.mjs && node test/codex-app-table.mjs && node test/plugin-mode.mjs && node test/app-bridge-vectors.mjs && node test/app-bridge-client.mjs && node test/app-tools-static.mjs && node test/app-tools-stdio-live.mjs",
103
+ "test": "node test/publish-verdict.mjs && node test/npx-owned-names.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/session-mailbox.mjs && node test/session-reopen.mjs && node test/session-handoff.mjs && node test/continuity-after-kill.mjs && node test/quick-restart.mjs && node test/codex-rollout-asks.mjs && node test/handoff-freshness.mjs && node test/handoff-hosts.mjs && node test/lock-notes.mjs && node test/whose-classify.mjs && node test/whose-ledger.mjs && node test/whose-rescue.mjs && node test/whose-hook.mjs && node test/live-projection.mjs && node test/live-scope.mjs && node test/lane-write-retry.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/network-bridge.mjs && node test/brain-rules.mjs && node test/rule-approval.mjs && node test/rule-approval-stdio.mjs && node test/brain-recall.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/brain-ask-words.mjs && node test/retrieval-fusion.mjs && node test/eval-retrieval.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/partial-notes.mjs && node test/lifecycle-prefix.mjs && node test/close-link-safety.mjs && node test/resolve-ledger.mjs && node test/plan-fulfillment.mjs && node test/arrange-receipts.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/semantic-preparation.mjs && node test/semantic-hash-parity.mjs && node test/enrichment.mjs && node test/provenance.mjs && node test/confirm-trail.mjs && node test/hook-fallback.mjs && node test/eval-hook-lane.mjs && node test/hook-unified-lane.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/marker-suffix-grammar.mjs && node test/marker-after-colon.mjs && node test/evidence-anchors.mjs && node test/brain-evidence.mjs && node test/crowd-safeguards.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/overlap-tiers.mjs && node test/coordination-bench.mjs && node test/release-lease.mjs && node test/release-reconcile.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/results-not-manifests.mjs && node test/merge-brains.mjs && node test/merge-readings.mjs && node test/revival-map.mjs && node test/merge-scale.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/canvas-groups.mjs && node test/git-tools.mjs && node test/link-compat.mjs && node test/uninstall.mjs && node test/current-guidance.mjs && node test/current-first.mjs && node test/status-shape.mjs && node test/status-hook.mjs && node test/read-canvas-saved-readings.mjs && node test/document-readings.mjs && node test/open-lease.mjs && node test/agent-card-style.mjs && node test/tool-schema-freeze.mjs && node test/small-identity.mjs && node test/app-lease-vectors.mjs && node test/app-tools-stdio.mjs && node test/card-contents-files.mjs && node test/video-prep.mjs && node test/codex-app-table.mjs && node test/plugin-mode.mjs && node test/app-bridge-vectors.mjs && node test/app-bridge-client.mjs && node test/app-tools-static.mjs && node test/app-tools-stdio-live.mjs",
104
104
  "test:memory": "node test/memory-runtime.mjs",
105
105
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
106
106
  "runtime": "node bin/klypix-runtime.mjs",
@@ -113,8 +113,50 @@ export function isProcessAlive(pid) {
113
113
  }
114
114
  }
115
115
 
116
+ // ── Quick restart (round-4 fix, continuity test 2026-10-30) ─────────────────
117
+ // The grace above made a session killed with its process tree (taskkill /T,
118
+ // a crash, a closed terminal) read as LIVE for 90 s (Claude, host-declared
119
+ // pid) or for the full TTL (Codex, guessed pid only). A new session started
120
+ // 30 s after the kill saw a running peer, was told to leave its files alone,
121
+ // and stood down. The rule below needs no grace because it does not trust one
122
+ // pid: a row is stopped at once only when EVERY process that could still be
123
+ // that session is provably gone on this machine —
124
+ // · the host itself: the host-declared pid (CLAUDE_PID / KLYPIX_HOST_PID),
125
+ // and/or `hostTreePid`, the process that launched KLYPIX's MCP supervisor
126
+ // (the host, or a wrapper the host keeps alive), recorded by the worker;
127
+ // · KLYPIX's own long-lived writer for the row: the MCP channel owner (an
128
+ // exact self-recorded pid, written on every heartbeat), on a fresh channel;
129
+ // · the row's hostPid whatever its source.
130
+ // At least one host pid and the MCP owner are required, so a wrongly-declared
131
+ // host pid (the case the grace guards) can never sweep a session whose MCP
132
+ // worker is still running, and a row with only a lifecycle hook (no MCP
133
+ // owner) or only guessed pids from an older build keeps today's rules.
134
+ // "Cannot say" from any probe (null) keeps the row: a false "stopped" frees a
135
+ // live session's files, which is worse than a false "live".
136
+ // PARITY: global-brain-hook.mjs keeps a verbatim twin (isDeadHostTreeRow).
137
+ export const DEAD_TREE_SETTLE_MS = 5 * 1000;
138
+ const pidOf = (value) => { const n = Number(value); return Number.isInteger(n) && n > 0 ? n : null; };
139
+ export function isDeadHostTreeRow(session, now = Date.now(), probe = isProcessAlive) {
140
+ if (!session || (session.via === 'cloud' || session.via === 'network')) return false;
141
+ if (!session.machine || String(session.machine) !== String(MACHINE_ID)) return false;
142
+ if (now - Number(session.lastSeen || 0) < DEAD_TREE_SETTLE_MS) return false;
143
+ const host = [];
144
+ if (String(session.hostPidSource || '') === 'env' && pidOf(session.hostPid)) host.push(pidOf(session.hostPid));
145
+ if (pidOf(session.hostTreePid)) host.push(pidOf(session.hostTreePid));
146
+ if (!host.length) return false;
147
+ const seen = session.channelSeen && typeof session.channelSeen === 'object' && !Array.isArray(session.channelSeen)
148
+ ? Number(session.channelSeen.mcp) : NaN;
149
+ if (!Number.isFinite(seen) || now - seen >= MCP_SESSION_FRESH_MS) return false;
150
+ const owner = pidOf(session.channelOwners && typeof session.channelOwners === 'object' ? session.channelOwners.mcp : null);
151
+ if (!owner) return false;
152
+ const pids = [...new Set([...host, owner, pidOf(session.hostPid)].filter(Boolean))];
153
+ if (pids.length < 2) return false;
154
+ return pids.every((pid) => probe(pid) === false);
155
+ }
156
+
116
157
  export function isDeadHostRow(session, now = Date.now(), probe = isProcessAlive) {
117
158
  if (!session || (session.via === 'cloud' || session.via === 'network')) return false;
159
+ if (isDeadHostTreeRow(session, now, probe)) return true;
118
160
  if (String(session.hostPidSource || '') !== 'env') return false;
119
161
  if (!session.machine || String(session.machine) !== String(MACHINE_ID)) return false;
120
162
  if (now - Number(session.lastSeen || 0) < DEAD_HOST_GRACE_MS) return false;
@@ -649,6 +691,12 @@ export function normalizeWork(raw) {
649
691
  // 1.103.0: the card at handoffCardId was DRAFTED BY KLYPIX from this record
650
692
  // (the session never wrote one) — every renderer says so.
651
693
  if (raw.handoffAuto === true) out.handoffAuto = true;
694
+ // Round-4 fix: a hookless Codex session records no prompt; its asks (and
695
+ // last words) are recovered from its own rollout file once it has stopped.
696
+ // `asksSource` says so; `rolloutTriedAt` keeps a missing rollout from
697
+ // being searched for again on every listing.
698
+ if (raw.asksSource === 'codex-rollout') out.asksSource = 'codex-rollout';
699
+ if (ms(raw.rolloutTriedAt)) out.rolloutTriedAt = ms(raw.rolloutTriedAt);
652
700
  return Object.keys(out).length ? out : null;
653
701
  }
654
702
  export function mergeWork(previous, patch) {
@@ -1155,6 +1203,10 @@ export function upsertSession({
1155
1203
  cwd = null,
1156
1204
  hostPid = null,
1157
1205
  hostPidSource = null,
1206
+ // The process that launched KLYPIX's MCP supervisor (round-4 fix): the host
1207
+ // or a wrapper it keeps alive. Read only by isDeadHostTreeRow, together with
1208
+ // the MCP owner; never identity.
1209
+ hostTreePid = null,
1158
1210
  logicalSessionId = null,
1159
1211
  identitySource = null,
1160
1212
  aliases,
@@ -1314,6 +1366,7 @@ export function upsertSession({
1314
1366
  // Host-process correlation is diagnostic topology only. A desktop parent
1315
1367
  // can own many chats, so hostPid must never imply identity or deduping.
1316
1368
  hostPid: Number(hostPid) || previous.hostPid || null,
1369
+ hostTreePid: pidOf(hostTreePid) || pidOf(previous.hostTreePid) || null,
1317
1370
  // Provenance of the pid decides what it may be used for: 'env' (declared
1318
1371
  // by the host itself, safe to liveness-probe) vs 'ppid' (a best-effort
1319
1372
  // parent guess, correlation only). ADDITIVE — absent means unknown, and
@@ -2100,6 +2153,7 @@ export function rotateEndedSessionIdentity({
2100
2153
  cwd = null,
2101
2154
  hostPid = null,
2102
2155
  hostPidSource = null,
2156
+ hostTreePid = null,
2103
2157
  identitySource = 'mcp-request',
2104
2158
  home,
2105
2159
  now = Date.now(),
@@ -2158,6 +2212,7 @@ export function rotateEndedSessionIdentity({
2158
2212
  deliveryReachability: sessionDeliveryReachability({ channels: Object.keys(channelSeen), transport }),
2159
2213
  cwd: cwd ? path.resolve(cwd) : (existing?.cwd || path.dirname(brainPath)),
2160
2214
  hostPid: Number(hostPid) || existing?.hostPid || null,
2215
+ hostTreePid: pidOf(hostTreePid) || pidOf(existing?.hostTreePid) || null,
2161
2216
  hostPidSource: (Number(hostPid) && hostPidSource) ? String(hostPidSource).slice(0, 16)
2162
2217
  : existing?.hostPidSource || null,
2163
2218
  logicalSessionId: toKey,
@@ -2204,6 +2259,7 @@ export function switchMcpSessionIdentity({
2204
2259
  cwd = null,
2205
2260
  hostPid = null,
2206
2261
  hostPidSource = null,
2262
+ hostTreePid = null,
2207
2263
  identitySource = 'mcp-request',
2208
2264
  home,
2209
2265
  now = Date.now(),
@@ -2302,6 +2358,7 @@ export function switchMcpSessionIdentity({
2302
2358
  deliveryReachability: sessionDeliveryReachability({ channels: Object.keys(channelSeen), transport }),
2303
2359
  cwd: cwd ? path.resolve(cwd) : (existing?.cwd || path.dirname(brainPath)),
2304
2360
  hostPid: Number(hostPid) || existing?.hostPid || null,
2361
+ hostTreePid: pidOf(hostTreePid) || pidOf(existing?.hostTreePid) || null,
2305
2362
  hostPidSource: (Number(hostPid) && hostPidSource) ? String(hostPidSource).slice(0, 16)
2306
2363
  : existing?.hostPidSource || null,
2307
2364
  logicalSessionId: toKey,
@@ -3196,6 +3253,8 @@ export function listUnfinishedWork({ brainPath, home, now = Date.now(), selfId =
3196
3253
  // 1.109: every prompt it was given (newest last, with line breaks) and
3197
3254
  // whether it was cut off mid-reply — "what is left" lives in these.
3198
3255
  asks: Array.isArray(work.asks) ? work.asks : (work.lastPrompt ? [{ text: work.lastPrompt }] : []),
3256
+ asksSource: work.asksSource || null,
3257
+ rolloutTriedAt: work.rolloutTriedAt || null,
3199
3258
  promptAt: work.promptAt || null,
3200
3259
  cutOff: workCutOff(work),
3201
3260
  lastWords: work.lastWords || '',
@@ -22,6 +22,7 @@ import {
22
22
  } from './agent-presence.mjs';
23
23
  import { recordCodexHookExecution } from './codex-hooks.mjs';
24
24
  import { opBrainTaskContext } from './klypix-core.mjs';
25
+ import { recoverCodexRolloutRecords } from './session-handoff.mjs';
25
26
  // Namespace import (already in-process via the klypix-core chain, so zero added
26
27
  // load cost) so a bundle whose klypix-format predates classifyDecay degrades
27
28
  // gracefully — a named import of a missing export would kill the whole hook.
@@ -465,6 +466,8 @@ const CONTINUE_PROMPT_RE = /\b(continue|resume|pick (it |this |that )?up|carry o
465
466
  const HANDOFF_ASK_PROMPT_RE = /\b(hand[- ]?off|hand (it|this|the work) (over|off)|before (we|i|you) (hit|reach|approach|run out of) (the )?(limit|context|window|quota)|(context|usage|token|rate) (limit|window|quota)|out of context|switch(ing)? to (claude|another (agent|session|tool)))\b/i;
466
467
  const CODEX_HANDOFF_HINT = '🤝 KLYPIX: to hand this session\'s work to the next one (any agent — Claude Code or Codex), call brain_handoff with the task, what is done, what is next, the files and how to verify; the next session on this project sees it at its first brain_sync and takes it with {action:"take"}. Your last reply and commits are recorded automatically at each turn end regardless — but a deliberate handoff says what you know. Never make the human paste a summary into the next session.';
467
468
  function unfinishedWorkBlock(brainPath, sessionId) {
469
+ // Round-4 fix: a stopped hookless Codex session's prompts, from its rollout.
470
+ try { recoverCodexRolloutRecords({ brainPath, selfId: sessionId }); } catch { /* courtesy */ }
468
471
  try {
469
472
  return formatUnfinishedWork(listUnfinishedWork({ brainPath, selfId: sessionId }));
470
473
  } catch {
@@ -495,8 +498,9 @@ async function lockNotesBlock(projectDir, brainPath, sessionId, sessions, { budg
495
498
  }
496
499
 
497
500
  // Live projection (1.104.0): the same presence / unfinished / keep-off strings
498
- // this hook emits, written into CLAUDE.local.md and the Codex global
499
- // instructions file for the NEXT session of any host (session start and Stop).
501
+ // this hook emits, written into CLAUDE.local.md for the NEXT session (session
502
+ // start and Stop). Never into the machine-wide Codex global file (round-4 fix:
503
+ // a project's section there leaked into Codex sessions in other projects).
500
504
  // Dynamic import so a stale deployment no-ops; never throws.
501
505
  async function projectLiveSafe({ projectDir, sessions, sessionId, lockNotes = '', unfinished = '' }) {
502
506
  try {
@@ -913,8 +913,32 @@ const isProcessAlive = (pid) => {
913
913
  return null; // unrecognized error — "cannot say", never "dead" (TTL rules)
914
914
  }
915
915
  };
916
+ // Quick restart (round-4 fix) — VERBATIM twin of agent-presence.mjs
917
+ // isDeadHostTreeRow: stopped at once, with no grace, only when every process
918
+ // that could still be the session is provably gone on this machine (a host pid
919
+ // — declared, or the recorded hostTreePid — AND the fresh MCP channel's owner,
920
+ // AND hostPid whatever its source). "Cannot say" keeps the row.
921
+ const DEAD_TREE_SETTLE_MS = 5 * 1000;
922
+ const pidOfRow = (v) => { const n = Number(v); return Number.isInteger(n) && n > 0 ? n : null; };
923
+ const isDeadHostTreeRow = (s, now) => {
924
+ if (!s || (s.via === 'cloud' || s.via === 'network')) return false;
925
+ if (!s.machine || String(s.machine) !== MACHINE_ID) return false;
926
+ if (now - Number(s.lastSeen || 0) < DEAD_TREE_SETTLE_MS) return false;
927
+ const host = [];
928
+ if (String(s.hostPidSource || '') === 'env' && pidOfRow(s.hostPid)) host.push(pidOfRow(s.hostPid));
929
+ if (pidOfRow(s.hostTreePid)) host.push(pidOfRow(s.hostTreePid));
930
+ if (!host.length) return false;
931
+ const seen = s.channelSeen && typeof s.channelSeen === 'object' && !Array.isArray(s.channelSeen) ? Number(s.channelSeen.mcp) : NaN;
932
+ if (!Number.isFinite(seen) || now - seen >= MCP_SESSION_FRESH_MS) return false;
933
+ const owner = pidOfRow(s.channelOwners && typeof s.channelOwners === 'object' ? s.channelOwners.mcp : null);
934
+ if (!owner) return false;
935
+ const pids = [...new Set([...host, owner, pidOfRow(s.hostPid)].filter(Boolean))];
936
+ if (pids.length < 2) return false;
937
+ return pids.every(p => isProcessAlive(p) === false);
938
+ };
916
939
  const isDeadHostRow = (s, now) => {
917
940
  if (!s || (s.via === 'cloud' || s.via === 'network')) return false;
941
+ if (isDeadHostTreeRow(s, now)) return true;
918
942
  if (String(s.hostPidSource || '') !== 'env') return false;
919
943
  if (!s.machine || String(s.machine) !== MACHINE_ID) return false;
920
944
  if (now - Number(s.lastSeen || 0) < DEAD_HOST_GRACE_MS) return false;
@@ -1672,6 +1696,17 @@ function unfinishedFooter(sid, { max = 4 } = {}) {
1672
1696
  return '\n\n' + lines.join('\n');
1673
1697
  } catch { return ''; }
1674
1698
  }
1699
+ // Round-4 fix: a stopped hookless Codex session records no prompt; its newest
1700
+ // prompts (and last words) are read from its own rollout file and stored in
1701
+ // the lane under the same redaction and size rules, before the footer below
1702
+ // reads the record. Same code as brain_sync (session-handoff.mjs); dynamic so
1703
+ // a stale flat deployment no-ops; never throws.
1704
+ async function recoverCodexAsks(sid) {
1705
+ try {
1706
+ const lib = await import(new URL('./session-handoff.mjs', import.meta.url).href);
1707
+ if (typeof lib.recoverCodexRolloutRecords === 'function') lib.recoverCodexRolloutRecords({ brainPath: BRAIN, selfId: sid || '' });
1708
+ } catch { /* courtesy */ }
1709
+ }
1675
1710
  // 📌 Lock notes (1.102.0, bench S5/S5b): a "do not touch" / hands-off / WIP
1676
1711
  // line in this project's AGENTS.md / CLAUDE.md, checked against the lane's
1677
1712
  // live sessions, local branches and uncommitted changes — at session start,
@@ -1704,8 +1739,10 @@ async function lockNotesFooter(sid, { budgetMs } = {}) {
1704
1739
  // Live projection (1.104.0, bench rounds 2–3): write the machine-local half of
1705
1740
  // the brain's state — who is live on this PC and what they hold, the work a
1706
1741
  // stopped session left, the evidence beside every keep-off note — into the
1707
- // files the hosts load by themselves and never commit (CLAUDE.local.md and the
1708
- // Codex global instructions file), at session start and at every Stop. The
1742
+ // project file Claude Code loads by itself and never commits (CLAUDE.local.md;
1743
+ // never the machine-wide Codex global file — round-4 fix: a project's section
1744
+ // there was read by Codex sessions in other projects), at session start and at
1745
+ // every Stop. The
1709
1746
  // inputs are the SAME strings this hook prints, so the file and the context
1710
1747
  // cannot disagree. Dynamic import so a stale flat deployment no-ops; never throws.
1711
1748
  async function writeLiveProjection(sid, { lockText = null, unfinishedText = null, budgetMs } = {}) {
@@ -5361,7 +5398,9 @@ async function promptRetrieve(lib) {
5361
5398
  // ⏸️ unfinished work (session handoff): only on a continue-shaped human
5362
5399
  // prompt — SessionStart already printed it once; 🤝 the deliberate-handoff
5363
5400
  // hint when the human asks for one or says the limit is near.
5364
- const unfinished = humanText !== null && CONTINUE_RE.test(humanText) ? unfinishedFooter(sid) : '';
5401
+ const wantsUnfinished = humanText !== null && CONTINUE_RE.test(humanText);
5402
+ if (wantsUnfinished) await recoverCodexAsks(sid);
5403
+ const unfinished = wantsUnfinished ? unfinishedFooter(sid) : '';
5365
5404
  const handoffHint = humanText !== null ? handoffAskHint(humanText) : '';
5366
5405
  // ── Unified recall (1.87) — the bench-measured two-lane policy ──────────
5367
5406
  // Ported line-for-line from the 2026-09-28 "unified balanced" bench winner
@@ -6453,6 +6492,7 @@ async function read(lib) {
6453
6492
  // The full brief keeps its order.
6454
6493
  const messages = messageFooter(input.session_id || '', input.transcript_path, lib);
6455
6494
  const lockLine = await lockNotesFooter(input.session_id);
6495
+ await recoverCodexAsks(input.session_id);
6456
6496
  const unfinishedLine = unfinishedFooter(input.session_id);
6457
6497
  const out = ultra + messages + lockLine + versionLine + presenceLine + shipObsLine + gitHookNotice + laneWarning + healLine + draftLine
6458
6498
  + receiptLine
@@ -6460,8 +6500,8 @@ async function read(lib) {
6460
6500
  + unfinishedLine
6461
6501
  + selfCheckFooter() + doctorFooter();
6462
6502
  process.stdout.write(out);
6463
- // The same lock / unfinished strings go into CLAUDE.local.md and the Codex
6464
- // global file for the NEXT session of any host (live projection).
6503
+ // The same lock / unfinished strings go into CLAUDE.local.md for the NEXT
6504
+ // session (live projection; never into a machine-wide global file).
6465
6505
  await writeLiveProjection(input.session_id, { lockText: lockLine, unfinishedText: unfinishedLine });
6466
6506
  // Heartbeat: prove the brief actually injected (and how big) so a dead or
6467
6507
  // stale live-copy of the hook stops being a silent no-op.
@@ -47,7 +47,7 @@ import {
47
47
  } from './app-lease.mjs';
48
48
  import { LIMITS, assetBytes, dataUrlImage, fitImage, imageSkipReason, locateAsset, mb } from './card-files.mjs';
49
49
  import { findProjectBrain, neutralizeMarkers, postPresenceMessage, resolveMessageTarget, readReleaseLease, listActiveSessions, listKnownSessions, sessionStatusLabel, stampSessionWork, listUnfinishedWork, formatUnfinishedWork, formatCheckoutLines, formatAskLines, resumeCommandFor } from './agent-presence.mjs';
50
- import { normalizeHandoffInput, renderHandoffCard, handoffMachineField, parseHandoffCard, listOpenHandoffCards, formatHandoffCards, isHandoffLine1, HANDOFF_REASONS, clientWord as handoffClientWord, autoHandoffCandidates, draftHandoffFromWork, captureHandoffTurns, inspectHandoffFreshness, formatHandoffFreshness } from './session-handoff.mjs';
50
+ import { normalizeHandoffInput, renderHandoffCard, handoffMachineField, parseHandoffCard, listOpenHandoffCards, formatHandoffCards, isHandoffLine1, HANDOFF_REASONS, clientWord as handoffClientWord, autoHandoffCandidates, draftHandoffFromWork, captureHandoffTurns, inspectHandoffFreshness, formatHandoffFreshness, recoverCodexRolloutRecords } from './session-handoff.mjs';
51
51
  import { collectRepoState, commitsInRange, makeContainmentProbe } from './repo-state.mjs';
52
52
  import { normalizeCrowdNote, lineagesFor, formatLineage, openObjections, formatObjections, withholdFilter, independentPass, isPeerOpinionOnTopic } from './crowd-safeguards.mjs';
53
53
 
@@ -2513,6 +2513,9 @@ export async function opBrainHandoff({ vault, canvas, action = 'write', from = {
2513
2513
  // is archived. Both are courtesies: neither can fail the list.
2514
2514
  let drafted = [];
2515
2515
  let closed = [];
2516
+ // Round-4 fix: a stopped hookless Codex session's prompts come from its
2517
+ // rollout BEFORE the list, the draft and the block read the record.
2518
+ try { recoverCodexRolloutRecords({ brainPath: file, now, selfId: me.sessionId }); } catch { /* courtesy */ }
2516
2519
  if (autoDraft) { try { drafted = await autoDraftHandoffs({ vault, canvas: file, from: me, now }); } catch { drafted = []; } }
2517
2520
  let struct = null;
2518
2521
  try { ({ struct } = await parseKlypix(fs.readFileSync(file))); } catch (e) { return err(`brain_handoff list failed: ${e.message}`); }
@@ -2600,6 +2603,7 @@ export async function opBrainHandoff({ vault, canvas, action = 'write', from = {
2600
2603
  const stampLine = `${verb}: ${handoffClientWord(me.client)} ${me.sessionId.slice(0, 8)} · ${stamp(now)}${extraNote ? ` · ${extraNote}` : ''}`;
2601
2604
  // ── a lane record (automatic layer) ──
2602
2605
  if (!id && session) {
2606
+ if (action === 'take') { try { recoverCodexRolloutRecords({ brainPath: file, now, sessionId: String(session), includeWorking: true, max: 1 }); } catch { /* courtesy */ } }
2603
2607
  const patch = action === 'take'
2604
2608
  ? { takenBy: { id: me.sessionId, client: me.client, at: now } }
2605
2609
  : { doneAt: now, doneBy: me.sessionId };
@@ -11,20 +11,29 @@
11
11
  // LIVE half — which sessions are on this PC right now and what files they
12
12
  // hold, what work a stopped session left, and the evidence beside every
13
13
  // keep-off note — reached the agent only through hook output and brain_sync,
14
- // never through a file. This module writes that half into the two files the
15
- // hosts load by themselves and never commit:
14
+ // never through a file. This module writes that half into the one
15
+ // project-scoped file a host loads by itself and never commits:
16
16
  //
17
17
  // • `CLAUDE.local.md` at the project root — Claude Code loads it beside
18
18
  // CLAUDE.md ("personal project-specific preferences; add to .gitignore");
19
19
  // its HTML comments are stripped before injection, so the markers cost
20
20
  // nothing. KLYPIX keeps it out of git through `.git/info/exclude` (local
21
21
  // to this clone, never a committed file).
22
- // • the Codex global instructions file — `~/.codex/AGENTS.override.md` when
23
- // the person has one, else `~/.codex/AGENTS.md`, the file klypix-mcp
24
- // already manages. Codex loads the global file first, then one file per
25
- // directory from the project root down, so a project-level override would
26
- // HIDE the project's AGENTS.md: never written. One section per project,
27
- // keyed by the project's path, pruned when stale.
22
+ //
23
+ // NOT the Codex global instructions file (round-4 fix, continuity test
24
+ // 2026-10-30). 1.104–1.109 also wrote one section per project into
25
+ // `~/.codex/AGENTS.md` (or AGENTS.override.md). That file is MACHINE-WIDE:
26
+ // every Codex session in every project loads all of it, so a section written
27
+ // for project A was read by Codex sessions in project B, and two of three
28
+ // such sessions first tried to take project A's stopped session. Codex has
29
+ // no project-scoped, uncommitted instruction file (a project-level
30
+ // AGENTS.override.md would hide the project's AGENTS.md), so Codex gets the
31
+ // live half where it is already project-scoped: brain_sync and, when
32
+ // installed, the Codex hooks' output for its own cwd. Every write now REMOVES
33
+ // the live sections earlier versions left in the global file
34
+ // (cleanCodexGlobalLive); nothing project-specific is written there again.
35
+ // The same rule holds for every other global, non-project instruction file
36
+ // (~/.claude/CLAUDE.md, ~/.gemini/GEMINI.md, …): no live state, ever.
28
37
  //
29
38
  // CONTRACT. Snapshot semantics, said in the text: written at session start and
30
39
  // stop, not live, not shared, brain_sync is the live source. Bounded (budget
@@ -218,30 +227,48 @@ export function pruneLiveSections(raw, { keep = null, now = Date.now(), staleMs
218
227
  return { text: next, dropped: drop.size };
219
228
  }
220
229
 
221
- export function writeCodexGlobal(projectDir, body, { now = Date.now(), home = os.homedir(), file = null } = {}) {
222
- const root = path.resolve(projectDir);
223
- const target = file || codexGlobalInstructionFile(home);
224
- try {
225
- const key = liveKey(root);
226
- let raw = '';
227
- try { raw = fs.readFileSync(target, 'utf8'); } catch { /* fresh */ }
228
- const pruned = pruneLiveSections(raw, { keep: key, now });
229
- const block = wrapLiveBlock(key, body, { at: now, projectDir: root });
230
- const next = upsertLiveBlock(pruned.text, key, block);
231
- if (stable(next) === stable(raw)) return { path: target, action: 'unchanged', dropped: pruned.dropped };
232
- atomicWrite(target, next);
233
- return { path: target, action: raw ? 'updated' : 'created', dropped: pruned.dropped };
234
- } catch (e) {
235
- return { path: target, action: 'failed', error: e?.message || String(e) };
230
+ /**
231
+ * Remove every live section from the Codex global instruction files (both
232
+ * AGENTS.override.md and AGENTS.md: a person may add or drop an override at
233
+ * any time). Only the klypix-live blocks go; the person's text and the static
234
+ * klypix-codex managed block stay byte-for-byte. Never throws.
235
+ */
236
+ export function cleanCodexGlobalLive({ home = os.homedir(), files = null } = {}) {
237
+ const dir = path.join(process.env.CODEX_HOME || path.join(home, '.codex'));
238
+ const targets = Array.isArray(files) && files.length ? files : [path.join(dir, 'AGENTS.override.md'), path.join(dir, 'AGENTS.md')];
239
+ const out = { action: 'unchanged', removed: 0, paths: [] };
240
+ for (const f of targets) {
241
+ try {
242
+ const raw = fs.readFileSync(f, 'utf8');
243
+ const n = listLiveSections(raw).length;
244
+ if (!n) continue;
245
+ atomicWrite(f, stripLiveBlocks(raw));
246
+ out.removed += n; out.paths.push(f); out.action = 'cleaned';
247
+ } catch (e) {
248
+ if (e?.code !== 'ENOENT') { out.action = out.action === 'cleaned' ? 'cleaned' : 'failed'; out.error = e?.message || String(e); }
249
+ }
236
250
  }
251
+ return out;
252
+ }
253
+
254
+ /**
255
+ * Deprecated (round-4 fix): the Codex global file is machine-wide, so a
256
+ * project's live block there leaks into every other project. Kept as an
257
+ * export for callers built against 1.104–1.109; it never writes a block and
258
+ * only cleans up what those versions left.
259
+ */
260
+ export function writeCodexGlobal(_projectDir, _body, { home = os.homedir(), file = null } = {}) {
261
+ const res = cleanCodexGlobalLive({ home, files: file ? [file] : null });
262
+ return { path: file || codexGlobalInstructionFile(home), action: res.action === 'cleaned' ? 'cleaned' : (res.action === 'failed' ? 'failed' : 'not-written'), removed: res.removed, ...(res.error ? { error: res.error } : {}) };
237
263
  }
238
264
 
239
- /** Both files, never throws. The caller supplies what the hook already computed. */
265
+ /** The project-scoped file, never throws. The caller supplies what the hook already computed. */
240
266
  export function projectLive({ projectDir, now = Date.now(), home = os.homedir(), version = '', peers = [], unfinishedText = '', lockNotesText = '', claudeFile = null, codexFile = null } = {}) {
241
267
  const body = renderLiveProjection({ projectDir, now, version, peers, unfinishedText, lockNotesText });
242
268
  return {
243
269
  claude: writeClaudeLocal(projectDir, body, { now, file: claudeFile }),
244
- codex: writeCodexGlobal(projectDir, body, { now, home, file: codexFile }),
270
+ // Never a project's block in the machine-wide Codex file; leftovers removed.
271
+ codex: writeCodexGlobal(projectDir, body, { home, file: codexFile }),
245
272
  bytes: Buffer.byteLength(body),
246
273
  };
247
274
  }
@@ -1335,6 +1335,14 @@ function clearResultClaimPending({ brainPath, sessionId, home }) {
1335
1335
 
1336
1336
  // The host-pid this server belongs to (Claude Code exports CLAUDE_PID to every
1337
1337
  // child process). Used for the hostmap rotation below and for twin recognition.
1338
+ export function resolveHostTreePid(env = process.env, ppid = process.ppid) {
1339
+ if (String(env?.KLYPIX_MCP_SUPERVISED || '') === '1') {
1340
+ const pid = Number(env?.KLYPIX_MCP_HOST_TREE_PID || 0);
1341
+ return Number.isInteger(pid) && pid > 1 ? pid : null;
1342
+ }
1343
+ return Number.isInteger(ppid) && ppid > 1 ? ppid : null;
1344
+ }
1345
+
1338
1346
  export function resolveHostPid(env = process.env) {
1339
1347
  const pid = Number(env?.CLAUDE_PID || env?.KLYPIX_HOST_PID || 0);
1340
1348
  return Number.isFinite(pid) && pid > 0 ? pid : null;
@@ -1470,9 +1478,24 @@ export function createMcpPresence({
1470
1478
  // safe to liveness-probe; a 'ppid' guess may be a transient wrapper, so the
1471
1479
  // dead-host sweep never probes it — correlation only. The hostmap path keeps
1472
1480
  // using ONLY the env pid (a guessed pid must never adopt a hostmap identity).
1473
- const laneHostPid = hostPid
1474
- || (Number.isInteger(process.ppid) && process.ppid > 0 ? process.ppid : null);
1475
- const laneHostPidSource = hostPid ? 'env' : (laneHostPid ? 'ppid' : null);
1481
+ // Quick restart (round-4 fix): the process that launched KLYPIX's MCP
1482
+ // supervisor — the host itself, or a wrapper the host keeps alive. A
1483
+ // supervised worker is told it by its supervisor (its own ppid is the
1484
+ // supervisor); an unsupervised one has it as its ppid. Read only by the
1485
+ // lane's isDeadHostTreeRow, beside the MCP owner pid; never identity.
1486
+ const laneHostTreePid = resolveHostTreePid(env);
1487
+ // An env host pid that is NOT the process that launched this MCP server was
1488
+ // inherited from further up (round-4 field case: Claude Code launched from
1489
+ // a shell that already carried another Claude's CLAUDE_PID — Claude sets its
1490
+ // own pid for its hooks but its MCP servers inherit the outer one). Writing
1491
+ // it as host-declared would pin the row to a process that outlives the
1492
+ // session, so the worker leaves the row's host pid to the lifecycle hook
1493
+ // (which Claude gives the right one). A wrapper launch (cmd / npx) also
1494
+ // differs; it loses nothing, because the hook writes the real pid.
1495
+ const envHostPidTrusted = Boolean(hostPid) && (!laneHostTreePid || Number(hostPid) === Number(laneHostTreePid));
1496
+ const laneHostPid = envHostPidTrusted ? hostPid
1497
+ : (hostPid ? null : (Number.isInteger(process.ppid) && process.ppid > 0 ? process.ppid : null));
1498
+ const laneHostPidSource = envHostPidTrusted ? 'env' : (laneHostPid ? 'ppid' : null);
1476
1499
  const effectiveInboxPollMs = Number(env?.KLYPIX_MCP_INBOX_POLL_MS)
1477
1500
  || Number(inboxPollMs)
1478
1501
  || MCP_INBOX_POLL_MS;
@@ -1906,6 +1929,7 @@ export function createMcpPresence({
1906
1929
  cwd: path.dirname(brainPath),
1907
1930
  hostPid: laneHostPid,
1908
1931
  hostPidSource: laneHostPidSource,
1932
+ hostTreePid: laneHostTreePid,
1909
1933
  home,
1910
1934
  now: stamp,
1911
1935
  expectedPid: process.pid,
@@ -1958,6 +1982,7 @@ export function createMcpPresence({
1958
1982
  cwd: path.dirname(brainPath),
1959
1983
  hostPid: laneHostPid,
1960
1984
  hostPidSource: laneHostPidSource,
1985
+ hostTreePid: laneHostTreePid,
1961
1986
  home,
1962
1987
  now: stamp,
1963
1988
  expectedPid: process.pid,
@@ -2194,6 +2219,7 @@ export function createMcpPresence({
2194
2219
  cwd: path.dirname(brainPath),
2195
2220
  hostPid: laneHostPid,
2196
2221
  hostPidSource: laneHostPidSource,
2222
+ hostTreePid: laneHostTreePid,
2197
2223
  logicalSessionId,
2198
2224
  identitySource,
2199
2225
  home,
@@ -930,6 +930,9 @@ class Supervisor {
930
930
  // probes a guessed pid (see agent-presence.mjs isDeadHostRow).
931
931
  hostPid: this.parentPid,
932
932
  hostPidSource: 'ppid',
933
+ // Round-4 fix: who launched this supervisor — read by the lane's
934
+ // isDeadHostTreeRow only together with this supervisor's own pid.
935
+ hostTreePid: this.parentPid,
933
936
  });
934
937
  // Hibernation intentionally has no model-context consumer. Peek only:
935
938
  // a best-effort UI warning may wake the human, but the durable note stays
@@ -1178,6 +1181,10 @@ class Supervisor {
1178
1181
  ...process.env,
1179
1182
  KLYPIX_MCP_SUPERVISED: '1',
1180
1183
  KLYPIX_MCP_SUPERVISOR_PID: String(process.pid),
1184
+ // Round-4 fix: the worker's own ppid is this supervisor, so it is told
1185
+ // who launched the supervisor (the host, or its wrapper) for the lane's
1186
+ // quick-restart liveness rule.
1187
+ ...(this.parentPid && this.parentPid > 1 ? { KLYPIX_MCP_HOST_TREE_PID: String(this.parentPid) } : {}),
1181
1188
  KLYPIX_MCP_CONNECTION_ID: this.connectionId,
1182
1189
  // Pin the session id across a hibernation wake (KLYPIX_SESSION_ID wins
1183
1190
  // resolveMcpSessionId's precedence chain) so the woken worker adopts the
@@ -30,7 +30,7 @@ import path from 'path';
30
30
  import os from 'os';
31
31
  import crypto from 'crypto';
32
32
  import { HANDOFF_RE, handoffTitle, isOpenHandoffCard } from './klypix-format.mjs';
33
- import { sessionWorkIsRecent, normalizeModel, normalizeRole, rolesMatch } from './agent-presence.mjs';
33
+ import { sessionWorkIsRecent, normalizeModel, normalizeRole, rolesMatch, listUnfinishedWork, stampSessionWork, normalizeAsks, redactSecretsInText, neutralizeMarkers } from './agent-presence.mjs';
34
34
  export { sessionWorkIsRecent };
35
35
 
36
36
  // A host-neutral, append-only evidence journal. The caller supplies observed
@@ -246,6 +246,87 @@ export function handoffTranscript(sessionId, client, { home = os.homedir(), maxB
246
246
  } catch { return unknown('Transcript could not be read'); }
247
247
  }
248
248
 
249
+ // ── A hookless Codex session's record, from its own rollout (round-4 fix) ──
250
+ // Continuity test round 3 (2026-10-30): a Codex session with no lifecycle
251
+ // hooks records only what it declared through brain_sync — an intent and
252
+ // files — so when it stopped, the automatic unfinished-work block, the take
253
+ // reply and the drafted card had no task list (Claude sessions that skipped
254
+ // brain_handoff review renumbered the steps from the intent and git). Codex
255
+ // writes every conversation to a rollout file under CODEX_HOME/sessions,
256
+ // named after the thread id the lane stores. Once such a session has
257
+ // stopped, its newest prompts are read from that file and stored in the lane
258
+ // under the SAME rules as a hook-recorded prompt (normalizeAsks: newest 3,
259
+ // 4,000 characters each, 8,000 total, line breaks kept, secrets redacted,
260
+ // capture markers neutralized); its last assistant message becomes its last
261
+ // words when it has none. The file is found by exact name only (the same
262
+ // bounded walk as brain_handoff review), read with a smaller byte bound, and
263
+ // a miss is not retried for ROLLOUT_RETRY_MS. Nothing is read for a session
264
+ // that is still live, or that already has recorded prompts.
265
+ export const ROLLOUT_READ_MAX_BYTES = 32 * 1024 * 1024;
266
+ export const ROLLOUT_RETRY_MS = 10 * 60 * 1000;
267
+ export const ROLLOUT_LAST_WORDS_MAX = 600;
268
+ // Codex injects framework text as role "user": one message whose parts are
269
+ // <recommended_plugins>…, "# AGENTS.md instructions for …<INSTRUCTIONS>…" and
270
+ // <environment_context>… (Codex 0.153, round-4 evidence). The transcript
271
+ // reader joins a message's parts, so each injected block is removed wherever
272
+ // it starts a line; what is left (often nothing) is the human's prompt. Only
273
+ // framework-shaped tags: an underscore name, INSTRUCTIONS, or "permissions
274
+ // instructions" — never an HTML-like tag a person might type.
275
+ const INJECTED_BLOCK_RES = [
276
+ /^# AGENTS\.md instructions for [^\n]*\n+<INSTRUCTIONS>[\s\S]*?<\/INSTRUCTIONS>[ \t]*\n?/gm,
277
+ /^<([a-z][a-z0-9]*_[a-z0-9_]+)(?:\s[^>\n]*)?>[\s\S]*?<\/\1>[ \t]*\n?/gm,
278
+ /^<(INSTRUCTIONS|permissions instructions)>[\s\S]*?<\/\1>[ \t]*\n?/gm,
279
+ ];
280
+ export function stripInjectedCodexText(text) {
281
+ let out = String(text || '');
282
+ for (const re of INJECTED_BLOCK_RES) out = out.replace(re, '');
283
+ return out.trim();
284
+ }
285
+ export function codexRolloutWork(sessionId, { home = os.homedir(), transcript = handoffTranscript } = {}) {
286
+ let t;
287
+ try { t = transcript(sessionId, 'codex', { home, maxBytes: ROLLOUT_READ_MAX_BYTES, maxEntries: 6000 }); }
288
+ catch { return { status: 'unavailable', reason: 'Transcript could not be read' }; }
289
+ if (!t || t.status !== 'available') return { status: 'unavailable', reason: t?.reason || 'Transcript unavailable' };
290
+ const turns = (Array.isArray(t.turns) ? t.turns : [])
291
+ .map((x) => (x && x.role === 'user' ? { ...x, text: stripInjectedCodexText(x.text) } : x))
292
+ .filter((x) => x && String(x.text || '').trim());
293
+ const asks = normalizeAsks(turns.filter((x) => x.role === 'user').map((x) => ({ text: x.text, at: x.at })));
294
+ const last = [...turns].reverse().find((x) => x.role === 'assistant');
295
+ let lastWords = '';
296
+ if (last) {
297
+ const text = neutralizeMarkers(redactSecretsInText(String(last.text))).replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
298
+ lastWords = text.length > ROLLOUT_LAST_WORDS_MAX ? `…${text.slice(-(ROLLOUT_LAST_WORDS_MAX - 1))}` : text;
299
+ }
300
+ return { status: 'available', asks, lastWords, lastWordsAt: last?.at || null, file: t.file };
301
+ }
302
+ /** Stamp recovered asks onto stopped hookless Codex records. Sync, bounded, never throws. */
303
+ export function recoverCodexRolloutRecords({ brainPath, home, codexUserHome = os.homedir(), now = Date.now(), selfId = '', sessionId = null, includeWorking = false, max = 3, transcript = handoffTranscript } = {}) {
304
+ const out = [];
305
+ if (!brainPath) return out;
306
+ let list = [];
307
+ try { list = listUnfinishedWork({ brainPath, home, now, selfId, includeWorking }); } catch { return out; }
308
+ const want = sessionId ? String(sessionId).toLowerCase() : '';
309
+ const targets = list.filter((r) => /codex/i.test(String(r.client || ''))
310
+ && !(Array.isArray(r.asks) && r.asks.length)
311
+ && (!want || String(r.id).toLowerCase() === want || (want.length >= 8 && String(r.id).toLowerCase().startsWith(want)))
312
+ && !(Number(r.rolloutTriedAt) > 0 && now - Number(r.rolloutTriedAt) < ROLLOUT_RETRY_MS));
313
+ for (const r of targets.slice(0, max)) {
314
+ const got = codexRolloutWork(r.id, { home: codexUserHome, transcript });
315
+ const patch = { rolloutTriedAt: now };
316
+ if (got.status === 'available' && got.asks.length) {
317
+ patch.asks = got.asks;
318
+ patch.lastPrompt = got.asks[got.asks.length - 1].text;
319
+ patch.asksSource = 'codex-rollout';
320
+ if (!r.lastWords && got.lastWords) { patch.lastWords = got.lastWords; if (got.lastWordsAt) patch.lastWordsAt = got.lastWordsAt; }
321
+ }
322
+ try {
323
+ const s = stampSessionWork({ brainPath, sessionId: r.id, patch, home, now });
324
+ out.push({ id: r.id, recovered: Boolean(patch.asks), asks: patch.asks ? patch.asks.length : 0, stamped: s.ok === true, reason: got.status === 'available' ? null : got.reason });
325
+ } catch { /* a courtesy: the record still lists without it */ }
326
+ }
327
+ return out;
328
+ }
329
+
249
330
  export function inspectHandoffFreshness(h, { cursor = 0, revision, project, transcript = handoffTranscript(h.from?.sessionId, h.from?.client) } = {}) {
250
331
  if (transcript.status !== 'available' && project) transcript = capturedTranscript(project, h.from?.sessionId, h.from?.client, os.homedir()) || transcript;
251
332
  if (transcript.status !== 'available') return { status: 'unknown', reason: transcript.reason, turns: [], nextCursor: null };