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 +2 -2
- package/src/agent-presence.mjs +59 -0
- package/src/codex-brain-hook.mjs +6 -2
- package/src/global-brain-hook.mjs +45 -5
- package/src/klypix-core.mjs +5 -1
- package/src/live-projection.mjs +52 -25
- package/src/mcp-presence.mjs +29 -3
- package/src/mcp-supervisor.mjs +7 -0
- package/src/session-handoff.mjs +82 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
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",
|
package/src/agent-presence.mjs
CHANGED
|
@@ -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 || '',
|
package/src/codex-brain-hook.mjs
CHANGED
|
@@ -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
|
|
499
|
-
//
|
|
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
|
-
//
|
|
1708
|
-
// Codex global
|
|
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
|
|
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
|
|
6464
|
-
//
|
|
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.
|
package/src/klypix-core.mjs
CHANGED
|
@@ -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 };
|
package/src/live-projection.mjs
CHANGED
|
@@ -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
|
|
15
|
-
//
|
|
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
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
}
|
package/src/mcp-presence.mjs
CHANGED
|
@@ -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
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
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,
|
package/src/mcp-supervisor.mjs
CHANGED
|
@@ -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
|
package/src/session-handoff.mjs
CHANGED
|
@@ -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 };
|