create-walle 0.10.0 → 0.10.1

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.
Files changed (70) hide show
  1. package/README.md +84 -41
  2. package/lib/runtime-closure-policy.json +2 -2
  3. package/package.json +10 -4
  4. package/template/coding-task-manager/bin/capture-codex-rollout-records.js +417 -0
  5. package/template/coding-task-manager/bin/census-codex-lead-history.js +325 -0
  6. package/template/coding-task-manager/bin/seam-replay-codex.js +301 -0
  7. package/template/coding-task-manager/lib/client-diag-handler.js +20 -0
  8. package/template/coding-task-manager/lib/codex-history-terminal-renderer.js +33 -33
  9. package/template/coding-task-manager/lib/codex-local-links.js +267 -0
  10. package/template/coding-task-manager/lib/codex-markdown-lists.js +160 -0
  11. package/template/coding-task-manager/lib/codex-native-history-prefix.js +4 -0
  12. package/template/coding-task-manager/lib/codex-source-handoff.js +186 -0
  13. package/template/coding-task-manager/lib/db-owner-worker-client.js +22 -0
  14. package/template/coding-task-manager/lib/feature-manifest.js +0 -2
  15. package/template/coding-task-manager/lib/harness-history-transition.js +38 -11
  16. package/template/coding-task-manager/lib/headless-term-service.js +5 -6
  17. package/template/coding-task-manager/lib/history-transition-read-op.js +179 -23
  18. package/template/coding-task-manager/lib/kernel/diag-log.js +12 -0
  19. package/template/coding-task-manager/lib/lead-model-observer.js +17 -2
  20. package/template/coding-task-manager/lib/lead-model-switch.js +8 -0
  21. package/template/coding-task-manager/lib/lead-model-transition-runtime.js +42 -4
  22. package/template/coding-task-manager/lib/login-tripwire.js +17 -0
  23. package/template/coding-task-manager/lib/main-thread-lint-exemptions.json +4 -0
  24. package/template/coding-task-manager/lib/model-execution-recipe-metadata.js +16 -3
  25. package/template/coding-task-manager/lib/model-proxy/collaboration-codec.js +94 -0
  26. package/template/coding-task-manager/lib/model-proxy/responses-face.js +24 -2
  27. package/template/coding-task-manager/lib/native-history-control.js +60 -12
  28. package/template/coding-task-manager/lib/prompt-admission-trace.js +69 -0
  29. package/template/coding-task-manager/lib/raw-history-block.js +3 -1
  30. package/template/coding-task-manager/lib/read-pool-client.js +31 -22
  31. package/template/coding-task-manager/lib/read-pool-telemetry.js +14 -5
  32. package/template/coding-task-manager/lib/remote-command-runtime.js +5 -4
  33. package/template/coding-task-manager/lib/root-prompt-admission.js +10 -9
  34. package/template/coding-task-manager/lib/root-prompt-flow.js +8 -5
  35. package/template/coding-task-manager/lib/server-memory-sampler-runtime.js +10 -0
  36. package/template/coding-task-manager/lib/server-memory-sampler.js +41 -2
  37. package/template/coding-task-manager/lib/session-create/provider-process.js +4 -2
  38. package/template/coding-task-manager/lib/session-lifecycle.js +2 -50
  39. package/template/coding-task-manager/lib/session-model-action-preconditions.js +59 -3
  40. package/template/coding-task-manager/lib/session-resource-sampler.js +48 -0
  41. package/template/coding-task-manager/lib/session-status.js +2 -2
  42. package/template/coding-task-manager/lib/typing-interaction-trace.js +44 -2
  43. package/template/coding-task-manager/lib/typing-worker-stages.js +148 -1
  44. package/template/coding-task-manager/lib/usage-ledger/claude-parse.js +53 -2
  45. package/template/coding-task-manager/lib/usage-ledger/ingest.js +13 -4
  46. package/template/coding-task-manager/lib/usage-ledger/read.js +14 -0
  47. package/template/coding-task-manager/lib/usage-ledger/schema.js +49 -1
  48. package/template/coding-task-manager/lib/usage-ledger/store.js +337 -6
  49. package/template/coding-task-manager/lib/usage-ledger/task-read.js +123 -5
  50. package/template/coding-task-manager/lib/ws/input.js +1 -0
  51. package/template/coding-task-manager/protocol/message-table.json +0 -10
  52. package/template/coding-task-manager/protocol/messages.js +0 -10
  53. package/template/coding-task-manager/protocol/ws-schema.json +0 -20
  54. package/template/coding-task-manager/protocol/ws-schema.md +0 -2
  55. package/template/coding-task-manager/public/css/app.css +0 -1
  56. package/template/coding-task-manager/public/index.html +3 -17
  57. package/template/coding-task-manager/public/js/client-health-core.js +81 -0
  58. package/template/coding-task-manager/public/js/client-health-main.js +61 -0
  59. package/template/coding-task-manager/public/js/prompt-submission-client.js +5 -1
  60. package/template/coding-task-manager/public/js/protocol.js +0 -10
  61. package/template/coding-task-manager/public/js/row-join.js +14 -1
  62. package/template/coding-task-manager/public/js/session-usage-view.js +1 -0
  63. package/template/coding-task-manager/public/js/terminal-history-viewport.js +0 -2
  64. package/template/coding-task-manager/server.js +62 -186
  65. package/template/package-lock.json +2 -2
  66. package/template/package.json +1 -1
  67. package/template/wall-e/coding/host-capabilities.js +2 -2
  68. package/template/wall-e/fly.toml +1 -1
  69. package/template/wall-e/tools/local-tools.js +6 -1
  70. package/template/coding-task-manager/lib/giant-freeze-park.js +0 -31
@@ -0,0 +1,186 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * WP-2 D-5/D-6 (docs/codex-lead-routed-switch-history-handoff-2026-10-21.html §6.3, §5 D-5/D-6).
5
+ *
6
+ * A GPT Codex lead whose last compaction holds an OpenAI-encrypted `compaction` item cannot carry
7
+ * that ciphertext to a routed target model. The user's decision (2026-10-21) is that the SOURCE GPT
8
+ * model writes a plaintext handoff summary before the switch.
9
+ *
10
+ * The summary is produced by an EPHEMERAL fork of the source thread, so:
11
+ * - the user's live source PTY and source thread are never touched (anti-goal: never submit into
12
+ * the live source session);
13
+ * - no rollout file is persisted for the fork (measured on Codex 0.163.0: the rollout-file set is
14
+ * byte-identical before and after);
15
+ * - the turn is a real model turn on the user's own ChatGPT account (risk R-1: it spends quota and
16
+ * can take up to HANDOFF_DEADLINE_MS, which is why the caller shows a "compacting" phase).
17
+ *
18
+ * This module owns the child process, the app-server JSON-RPC sequence, and the text validation. It
19
+ * writes nothing anywhere: the returned text travels in memory to lib/native-history-control.js,
20
+ * which injects it into the forked target as one developer message.
21
+ */
22
+
23
+ const { spawn } = require('node:child_process');
24
+ const crypto = require('node:crypto');
25
+ const path = require('node:path');
26
+ // The ONE process-launch/RPC implementation. `allowTurn: true` is what makes the handoff turn legal:
27
+ // every fork and readback path keeps the fail-closed `native_fork_emitted_model_turn` guard.
28
+ const { createBoundedRpc, terminateChild } = require('./native-history-control');
29
+
30
+ // Codex rust-v0.163.0 codex-rs/prompts/templates/compact/prompt.md, verbatim (Apache-2.0; the file
31
+ // carries the upstream copyright header). Kept byte-identical on purpose: the source model must be
32
+ // asked the same question Codex asks its own compactor, or the summary is a different artifact.
33
+ const CODEX_COMPACT_PROMPT = 'You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.\n'
34
+ + '\n'
35
+ + 'Include:\n'
36
+ + '- Current progress and key decisions made\n'
37
+ + '- Important context, constraints, or user preferences\n'
38
+ + '- What remains to be done (clear next steps)\n'
39
+ + '- Any critical data, examples, or references needed to continue\n'
40
+ + '\n'
41
+ + 'Be concise, structured, and focused on helping the next LLM seamlessly continue the work.\n';
42
+ // The CTM addition (§6.3): what a provider switch additionally needs that a same-provider compaction
43
+ // does not. It is not a tool-using turn -- the fork is read-only and every tool is refused.
44
+ const CTM_HANDOFF_ADDITION = '\n\nAlso include, verbatim and complete: every file path you changed or must change, exact error strings and test names still failing, commands that must run next, and the single next action. Do not call tools. Reply with the summary only.';
45
+ const HANDOFF_PROMPT = `${CODEX_COMPACT_PROMPT}${CTM_HANDOFF_ADDITION}`;
46
+
47
+ const HANDOFF_MIN_CHARS = 200;
48
+ const HANDOFF_MAX_CHARS = 32_768;
49
+ const HANDOFF_DEADLINE_MS = 240_000;
50
+ const HANDOFF_APP_SERVER_ARGS = Object.freeze(['app-server', '-c', 'approval_policy="never"',
51
+ '-c', 'sandbox_mode="read-only"']);
52
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
53
+ const MARKER_PREFIX = '<ctm_handoff';
54
+
55
+ /**
56
+ * Ask the SOURCE model for the plaintext handoff that will cover its sealed compaction.
57
+ *
58
+ * @param {{executablePath:string, homeDir:string, codexHome:string, workingDirectory:string, tmpDir?:string|null}} source
59
+ * @param {{sourceThreadId:string, lastTurnId:string}} checkpoint
60
+ * @param {{signal?:AbortSignal, timeoutMs?:number}} [opts]
61
+ * @returns {Promise<{ok:true, text:string, textSha256:string, turnId:string, model:string|null}
62
+ * | {ok:false, reason:'handoff_fork_failed'|'handoff_turn_failed'|'handoff_empty'|'handoff_too_long'
63
+ * |'handoff_marker_in_text'|'handoff_timeout'|'transition_cancelled'}>}
64
+ */
65
+ async function writeSourceHandoff(source, checkpoint, { signal, timeoutMs } = {}) {
66
+ if (signal?.aborted) return failure('transition_cancelled');
67
+ const sourceThreadId = nonempty(checkpoint?.sourceThreadId);
68
+ const lastTurnId = nonempty(checkpoint?.lastTurnId);
69
+ const executablePath = nonempty(source?.executablePath);
70
+ if (![executablePath, source?.homeDir, source?.codexHome, source?.workingDirectory]
71
+ .every((value) => typeof value === 'string' && path.isAbsolute(value))
72
+ || (source?.tmpDir != null && !path.isAbsolute(source.tmpDir))
73
+ || !UUID_RE.test(sourceThreadId) || !lastTurnId) return failure('handoff_fork_failed');
74
+ const deadlineMs = Number.isSafeInteger(timeoutMs) && timeoutMs > 0
75
+ ? Math.min(timeoutMs, HANDOFF_DEADLINE_MS) : HANDOFF_DEADLINE_MS;
76
+ const state = { turnId: '', completed: null, agentText: '', onComplete: null };
77
+ let stage = 'fork';
78
+ let child = null;
79
+ let rpc = null;
80
+ try {
81
+ child = spawn(executablePath, [...HANDOFF_APP_SERVER_ARGS], { cwd: source.workingDirectory,
82
+ env: handoffEnv(source), stdio: ['pipe', 'pipe', 'pipe'] });
83
+ child.stderr.resume();
84
+ rpc = createBoundedRpc(child, { signal, timeoutMs: deadlineMs, allowTurn: true,
85
+ onNotification: (method, params) => observe(state, method, params) });
86
+ const initialized = await rpc.request('initialize', {
87
+ clientInfo: { name: 'ctm-source-handoff', version: '1' }, capabilities: {},
88
+ });
89
+ if (!initialized || rpc.closed) return failure('handoff_fork_failed');
90
+ await rpc.notify('initialized', {});
91
+ const forked = await rpc.request('thread/fork', { threadId: sourceThreadId, lastTurnId,
92
+ ephemeral: true, excludeTurns: true });
93
+ const ephemeralId = nonempty(forked?.thread?.id || forked?.threadId || forked?.thread_id);
94
+ if (!UUID_RE.test(ephemeralId) || ephemeralId === sourceThreadId) return failure('handoff_fork_failed');
95
+ stage = 'turn';
96
+ const started = await rpc.request('turn/start', { threadId: ephemeralId,
97
+ input: [{ type: 'text', text: HANDOFF_PROMPT }] });
98
+ state.turnId = nonempty(started?.turn?.id || started?.turnId || started?.id);
99
+ if (!state.turnId) return failure('handoff_turn_failed');
100
+ const turn = await waitForTurn(state, signal, deadlineMs);
101
+ if (signal?.aborted) return failure('transition_cancelled');
102
+ if (!turn || turn.timedOut) return failure(turn ? 'handoff_timeout' : 'handoff_turn_failed');
103
+ if (nonempty(turn.status) !== 'completed') return failure('handoff_turn_failed');
104
+ const text = (agentMessageText(turn) || state.agentText).trim();
105
+ if (text.length < HANDOFF_MIN_CHARS) return failure('handoff_empty');
106
+ if (text.length > HANDOFF_MAX_CHARS) return failure('handoff_too_long');
107
+ // The target's own readback looks for this prefix; a summary that already contains one could
108
+ // forge coverage for an unrelated sealed item.
109
+ if (text.includes(MARKER_PREFIX)) return failure('handoff_marker_in_text');
110
+ return { ok: true, text, textSha256: sha256(text), turnId: state.turnId,
111
+ model: nonempty(turn.model || turn.modelId) || null };
112
+ } catch (error) {
113
+ if (signal?.aborted || error?.code === 'transition_cancelled') return failure('transition_cancelled');
114
+ if (error?.code === 'native_rpc_deadline') return failure('handoff_timeout');
115
+ return failure(stage === 'turn' ? 'handoff_turn_failed' : 'handoff_fork_failed');
116
+ } finally {
117
+ // The ephemeral fork must never outlive this call, on any path -- including a refusal or an abort.
118
+ if (rpc) await rpc.close();
119
+ else await terminateChild(child);
120
+ }
121
+ }
122
+
123
+ function handoffEnv(source) {
124
+ const env = { PATH: process.env.PATH || '/usr/bin:/bin', HOME: source.homeDir,
125
+ CODEX_HOME: source.codexHome, LANG: process.env.LANG || 'C',
126
+ CTM_PROCESS_ROLE: 'ctm-source-handoff' };
127
+ if (source.tmpDir) env.TMPDIR = source.tmpDir;
128
+ return env;
129
+ }
130
+
131
+ // The single notification sink: turn/completed carries the finished turn (with its items), and
132
+ // item/completed is the fallback if a Codex version reports the agent message only there.
133
+ function observe(state, method, params) {
134
+ if (method === 'item/completed') {
135
+ const item = params?.item;
136
+ if (nonempty(item?.type) === 'agentMessage' && typeof item.text === 'string'
137
+ && (!state.turnId || nonempty(params?.turnId || item.turn_id) === state.turnId)) {
138
+ state.agentText = item.text;
139
+ }
140
+ return;
141
+ }
142
+ if (method !== 'turn/completed') return;
143
+ const turn = params?.turn;
144
+ if (state.turnId && nonempty(turn?.id) !== state.turnId) return;
145
+ state.completed = isRecord(turn) ? turn : null;
146
+ const settle = state.onComplete;
147
+ state.onComplete = null;
148
+ if (settle) settle(state.completed);
149
+ }
150
+
151
+ function waitForTurn(state, signal, deadlineMs) {
152
+ if (state.completed) return Promise.resolve(state.completed);
153
+ return new Promise((resolve) => {
154
+ let settled = false;
155
+ const timer = setTimeout(() => finish({ timedOut: true }), deadlineMs);
156
+ const onAbort = () => finish(null);
157
+ function finish(value) {
158
+ if (settled) return;
159
+ settled = true;
160
+ clearTimeout(timer);
161
+ state.onComplete = null;
162
+ signal?.removeEventListener('abort', onAbort);
163
+ resolve(value);
164
+ }
165
+ state.onComplete = finish;
166
+ signal?.addEventListener('abort', onAbort, { once: true });
167
+ if (state.completed) finish(state.completed);
168
+ });
169
+ }
170
+
171
+ function agentMessageText(turn) {
172
+ if (!Array.isArray(turn?.items)) return '';
173
+ for (let index = turn.items.length - 1; index >= 0; index -= 1) {
174
+ const item = turn.items[index];
175
+ if (nonempty(item?.type) === 'agentMessage' && typeof item.text === 'string') return item.text;
176
+ }
177
+ return '';
178
+ }
179
+
180
+ function failure(reason) { return { ok: false, reason }; }
181
+ function sha256(value) { return crypto.createHash('sha256').update(value).digest('hex'); }
182
+ function nonempty(value) { return typeof value === 'string' && value.trim() ? value.trim() : ''; }
183
+ function isRecord(value) { return !!value && typeof value === 'object' && !Array.isArray(value); }
184
+
185
+ module.exports = { writeSourceHandoff, HANDOFF_PROMPT, HANDOFF_MIN_CHARS, HANDOFF_MAX_CHARS,
186
+ HANDOFF_DEADLINE_MS };
@@ -937,12 +937,34 @@ class CtmDbOwnerWorkerClient {
937
937
  }).catch(() => {});
938
938
  }
939
939
 
940
+ // WP-A P-2 (freeze-probes): the up-to-`limit` OLDEST client requests still outstanding on this
941
+ // worker, each { op, ageMs, started } -- what a memory step should be read against. One pass over
942
+ // the Map with a bounded insert (never a sort of the whole pending set: this runs on the sampler path).
943
+ _oldestPendingOps(limit = 5) {
944
+ const now = Date.now();
945
+ const oldest = [];
946
+ for (const pending of this._pending.values()) {
947
+ const entry = {
948
+ op: String(pending?.op || 'unknown'),
949
+ ageMs: Math.max(0, now - (Number(pending?.enqueuedAt) || now)),
950
+ started: Number(pending?.startedAt) > 0,
951
+ };
952
+ let at = oldest.length;
953
+ while (at > 0 && oldest[at - 1].ageMs < entry.ageMs) at -= 1;
954
+ if (at >= limit) continue;
955
+ oldest.splice(at, 0, entry);
956
+ if (oldest.length > limit) oldest.pop();
957
+ }
958
+ return oldest;
959
+ }
960
+
940
961
  getStatus() {
941
962
  const s = {
942
963
  ...this._status,
943
964
  ready: this._ready,
944
965
  closed: this._closed,
945
966
  pendingClientRequests: this._pending.size,
967
+ pendingOps: this._oldestPendingOps(),
946
968
  degraded: this.isDegraded(),
947
969
  degradedUntil: this._degradedUntil,
948
970
  consecutiveTimeouts: this._consecutiveTimeouts,
@@ -612,8 +612,6 @@ const FEATURES = [
612
612
  { id: "ctm_ctm_fp_memo", env: "CTM_FP_MEMO", owner: "ctm-core-team", stage: "stable", defaultOn: true, since: "2026-08-24", expires: null },
613
613
  // !== '0' comparison found; 1 site(s); first: lib/ws/state-sync.js:470
614
614
  { id: "ctm_ctm_frame_nack", env: "CTM_FRAME_NACK", owner: "ctm-core-team", stage: "stable", defaultOn: true, since: "2026-08-24", expires: null },
615
- // !== '0' comparison found; 1 site(s); first: server.js:36110
616
- { id: "ctm_ctm_freeze_park_giant", env: "CTM_FREEZE_PARK_GIANT", owner: "ctm-core-team", stage: "stable", defaultOn: true, since: "2026-08-24", expires: null },
617
615
  // numeric-tunable suffix/read shape; 1 site(s); first: lib/perf-tracker.js:829
618
616
  { id: "ctm_ctm_freeze_probe_ms", env: "CTM_FREEZE_PROBE_MS", owner: "ctm-core-team", stage: "ops", tunable: true, default: 0, since: "2026-08-24", expires: null, note: "numeric tunable" },
619
617
  // !== '0' comparison found; 1 site(s); first: server.js:445
@@ -5,7 +5,13 @@ const KNOWN_ORIGINS = new Set([...NATIVE_ORIGINS, 'routed-proxy', 'portable-tran
5
5
  // S-3: the adapter/schema ids and the version membership test come from the one qualified table.
6
6
  const { CODEX_CONTROL_ADAPTER, CODEX_COMPACTION_SCHEMA_RECEIPT, CODEX_PRE_TURN_COMPACTION_SCHEMA,
7
7
  isQualifiedCodexVersion } = require('./codex-harness-qualification');
8
- const PORTABLE_HISTORY_KINDS = new Set(['text', 'tool-pair', 'native-world-state', 'native-lifecycle']);
8
+ // S-4: the ONE portable-kind set. `native-media` is a message whose only non-text content parts are
9
+ // input_image parts (D-2); it is portable only to a Codex target, which is the planner's job below.
10
+ const PORTABLE_HISTORY_KINDS = new Set(['text', 'tool-pair', 'native-world-state', 'native-lifecycle', 'native-media']);
11
+ // D-5/D-6 (docs/codex-lead-routed-switch-history-handoff-2026-10-21.html §6.2): the plaintext handoff
12
+ // the source model writes is injected into the forked target, so it consumes target context that the
13
+ // source estimate does not count. 32 KiB of summary text is 8_192 tokens at four characters each.
14
+ const HANDOFF_TOKEN_RESERVE = 8_192;
9
15
 
10
16
  function inspectTransition({ sourceRecipe, targetRecipe, snapshot, signal } = {}) {
11
17
  if (signal?.aborted) return refuse('transition_cancelled');
@@ -17,9 +23,11 @@ function inspectTransition({ sourceRecipe, targetRecipe, snapshot, signal } = {}
17
23
  const targetProviderId = nonempty(targetRecipe.providerId);
18
24
  const origin = nonempty(sourceRecipe.origin);
19
25
  const codexHistorySource = origin === 'native-codex' || sourceRecipe.harness === 'codex-cli';
26
+ // D-3: qualification is per record (D-1) plus the installed binary that performs the fork. The
27
+ // rollout's CREATOR version (session_meta.cli_version, written once at creation) is not the writer
28
+ // of its later records, so it is not a gate here.
20
29
  const qualifiedCodexHistorySource = codexHistorySource
21
- && sourceRecipe.controlAdapterVersion === CODEX_CONTROL_ADAPTER
22
- && isQualifiedCodexVersion(sourceRecipe.programVersion);
30
+ && sourceRecipe.controlAdapterVersion === CODEX_CONTROL_ADAPTER;
23
31
  if (!origin) return refuse('source_origin_unknown');
24
32
  if (!KNOWN_ORIGINS.has(origin)) return refuse('source_origin_unsupported');
25
33
  if (!sourceProviderId || !targetProviderId) return refuse('provider_identity_unknown');
@@ -31,7 +39,7 @@ function inspectTransition({ sourceRecipe, targetRecipe, snapshot, signal } = {}
31
39
  if (nativeSource && sourceProviderId === targetProviderId) {
32
40
  return {
33
41
  action: 'reuse', reason: 'same_provider_native_history', sourceCheckpoint,
34
- estimateTokens, targetBudgetTokens: null, evidenceKind,
42
+ estimateTokens, targetBudgetTokens: null, evidenceKind, handoff: null,
35
43
  };
36
44
  }
37
45
 
@@ -40,12 +48,8 @@ function inspectTransition({ sourceRecipe, targetRecipe, snapshot, signal } = {}
40
48
  if (!sourceCheckpoint) return refuse('source_checkpoint_unknown');
41
49
  if (codexHistorySource && sourceProviderId !== targetProviderId
42
50
  && (!qualifiedCodexHistorySource
43
- || !isQualifiedCodexVersion(sourceRecipe.programVersion)
44
51
  || sourceRecipe.cyberAccessProgram !== null
45
52
  || snapshot.previousSettings?.cyberAccessProgram !== null
46
- || (snapshot.previousSettings?.programVersion !== undefined
47
- && snapshot.previousSettings?.programVersion !== null
48
- && !isQualifiedCodexVersion(snapshot.previousSettings.programVersion))
49
53
  || targetRecipe.harness !== 'codex-cli' || !isQualifiedCodexVersion(targetRecipe.harnessVersion))) {
50
54
  return refuse('native_fork_adapter_unqualified', { sourceCheckpoint });
51
55
  }
@@ -71,7 +75,27 @@ function inspectTransition({ sourceRecipe, targetRecipe, snapshot, signal } = {}
71
75
  }
72
76
  }
73
77
  if (snapshot.opaque === true || has(snapshot.historyKinds, 'opaque')) return refuse('opaque_history_not_portable', { sourceCheckpoint });
74
- if (snapshot.compacted === true) return refuse('compacted_source_fork_unqualified', { sourceCheckpoint });
78
+ // D-2: native media is portable exactly when the target is Codex, which strips images the target
79
+ // cannot accept (core/src/context_manager/normalize.rs:346 @ rust-v0.163.0). A non-Codex target
80
+ // gets a plain refusal instead of a silent rewrite of the source history.
81
+ if (has(snapshot.historyKinds, 'native-media') && targetRecipe.harness !== 'codex-cli') {
82
+ return refuse('history_media_not_portable', { sourceCheckpoint });
83
+ }
84
+ // D-5/D-6: a sealed `compaction` item is carried by a plaintext handoff the SOURCE model writes
85
+ // before the switch. Exactly one sealed digest can be covered, and only the native openai source
86
+ // (whose own model can be asked to write it) can produce one; everything else keeps a named refusal.
87
+ const sealed = (Array.isArray(snapshot.encryptedCompactions) ? snapshot.encryptedCompactions : [])
88
+ .filter((sha) => typeof sha === 'string' && /^[a-f0-9]{64}$/.test(sha));
89
+ if (sealed.length > 1) return refuse('encrypted_compaction_multiple', { sourceCheckpoint });
90
+ const handoff = sealed.length === 1 && snapshot.handoffCovers !== true
91
+ ? (qualifiedCodexHistorySource && origin === 'native-codex' && sourceProviderId === 'openai'
92
+ ? { required: true, compactionSha256: sealed[0] } : null)
93
+ : null;
94
+ if (sealed.length === 1 && snapshot.handoffCovers !== true && !handoff) {
95
+ return refuse('encrypted_compaction_handoff_unavailable', { sourceCheckpoint });
96
+ }
97
+ // D-4: a plaintext compaction is portable. Codex fork rebuilds the window from
98
+ // replacement_history, and no test, doc or incident ever justified the blanket refusal.
75
99
  if (snapshot.encrypted === true || has(snapshot.historyKinds, 'encrypted')) return refuse('encrypted_history_not_portable', { sourceCheckpoint });
76
100
  if (!Array.isArray(snapshot.historyKinds) || snapshot.historyKinds.length === 0
77
101
  || snapshot.historyKinds.some((kind) => !PORTABLE_HISTORY_KINDS.has(kind))) {
@@ -126,7 +150,10 @@ function inspectTransition({ sourceRecipe, targetRecipe, snapshot, signal } = {}
126
150
  : null;
127
151
  const evidence = { sourceCheckpoint, estimateTokens, targetBudgetTokens, evidenceKind,
128
152
  ...(preTurnCompaction ? { preTurnCompaction } : {}) };
129
- if (estimateTokens <= targetBudgetTokens) return { action: 'reuse', reason: 'history_fits_target', ...evidence };
153
+ // The injected handoff is target context the source estimate cannot see, so the comparison
154
+ // reserves it while every reported evidence field keeps the measured source value.
155
+ const budgetTokens = handoff?.required ? estimateTokens + HANDOFF_TOKEN_RESERVE : estimateTokens;
156
+ if (budgetTokens <= targetBudgetTokens) return { action: 'reuse', reason: 'history_fits_target', ...evidence, handoff };
130
157
 
131
158
  // Phase A has no qualified source-owned compact adapter. Native API acknowledgements alone
132
159
  // are not durable checkpoint receipts, so a recipe flag cannot authorize mutation here.
@@ -191,4 +218,4 @@ function refuse(reason, details = {}) {
191
218
  return { action: 'unsupported', reason, ...details };
192
219
  }
193
220
 
194
- module.exports = { inspectTransition, computeTargetBudget };
221
+ module.exports = { inspectTransition, computeTargetBudget, PORTABLE_HISTORY_KINDS, HANDOFF_TOKEN_RESERVE };
@@ -1345,6 +1345,8 @@ async function handleMessage(msg) {
1345
1345
  // WP-0b (§6): the xterm write callback for THIS feed -- exactly where the brief says
1346
1346
  // `mirror-written` belongs.
1347
1347
  _noteWorkerTypingStagePending(msg.sessionId, entry, 'mirror-written');
1348
+ // WP-3 (D-C2): per-key shadow echo matchers -- see lib/typing-worker-stages.js.
1349
+ _typingWorkerStages.matchEchoPredictions(postTransport, msg.sessionId, entry, msg.data);
1348
1350
  entry.state.liveOutputSeq += 1;
1349
1351
  entry.state.liveBytesSinceSeed += byteCount;
1350
1352
  if (entry.state.pendingInputSeq > entry.state.echoedThroughInputSeq) {
@@ -1470,12 +1472,9 @@ async function handleMessage(msg) {
1470
1472
  // every keystroke (HeadlessTerminalRouter.postMessage, lib/session-host-manager.js
1471
1473
  // :1815-1834, forwards by sessionId to the pool host when one owns the session, else the
1472
1474
  // legacy worker thread) -- carry the pending typing-interaction ids here, not on 'feed'.
1473
- if (Array.isArray(msg.interactionIds) && msg.interactionIds.length) {
1474
- entry.state.pendingTypingInteractionIds = _typingWorkerStages.mergePendingInteractionIds(
1475
- entry.state.pendingTypingInteractionIds, msg.interactionIds,
1476
- );
1477
- entry.state.typingIdsAwaitingFeed = true;
1478
- }
1475
+ // WP-3 (D-C2): the pending-id merge above moved into armEchoPrediction, which also arms
1476
+ // this keystroke's shadow echo prediction when the message carries one.
1477
+ _typingWorkerStages.armEchoPrediction(entry, msg);
1479
1478
  break;
1480
1479
  }
1481
1480