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
@@ -46,6 +46,13 @@ function startServerMemorySamplerRuntime(options = {}) {
46
46
  // WP-0b (§5 D-2): () => [{ owner, memory }] over the read-pool / db-owner worker clients.
47
47
  // Forwarded verbatim; absent -> the sampler's own () => [] default keeps workers[] empty.
48
48
  workersSource,
49
+ // WP-A P-2 (freeze-probes): () => the work in flight at sample time (read-pool ops, db-owner
50
+ // pending ops, recent main-loop spans). Forwarded verbatim; absent -> the sampler's () => null
51
+ // default keeps the row's `inFlight` null.
52
+ inFlightSource,
53
+ // WP-A P-3 (freeze-probes): { sampleFootprintRegions(pid) } for the main pid -- the categorized
54
+ // `/usr/bin/footprint` read. Forwarded verbatim; absent -> main.regions stays null.
55
+ regionSampler,
49
56
  samplerOptions,
50
57
  } = options;
51
58
  // WP-4 (design doc §5 D-5, §8 WP-4): reuse the SAME 'mirrors-report' IPC round trip
@@ -62,6 +69,9 @@ function startServerMemorySamplerRuntime(options = {}) {
62
69
  mirrorsSampler,
63
70
  // Explicit workersSource wins; otherwise a workersSource carried in samplerOptions (test seam).
64
71
  workersSource: typeof workersSource === 'function' ? workersSource : samplerOptions?.workersSource,
72
+ // WP-A P-2/P-3: forwarded the same way, so server.js's one call site wires all three probes.
73
+ inFlightSource: typeof inFlightSource === 'function' ? inFlightSource : samplerOptions?.inFlightSource,
74
+ regionSampler: regionSampler || samplerOptions?.regionSampler || null,
65
75
  listProcesses: () => serverProcessList({ sessionHostManager, desktopIndex, errorSink }),
66
76
  buildLedgerInput: () => ({
67
77
  processMemory: process.memoryUsage(),
@@ -135,6 +135,15 @@ function createServerMemorySampler(options = {}) {
135
135
  // liveDecoders } }] in raw bytes -- read from the read-pool/db-owner worker clients. Omitted ->
136
136
  // workers[] is empty and workersTotal sums are 0 (never fabricated from nothing).
137
137
  workersSource = () => [],
138
+ // WP-A P-2 (freeze-probes): () => any JSON-safe value describing the work in flight when the row
139
+ // was taken (read-pool ops, db-owner pending ops, recent main-loop spans). Stored verbatim on the
140
+ // row as `inFlight`; a throw or an absent source yields null ("unknown"), never a lost row.
141
+ inFlightSource = () => null,
142
+ // WP-A P-3 (freeze-probes): optional { sampleFootprintRegions(pid): Promise<{ totalMB, top }|null> }
143
+ // -- the CATEGORIZED `/usr/bin/footprint` read (lib/session-resource-sampler.js), a fresh spawn per
144
+ // call rather than the TTL-cached sampleFootprints() summary, so the region that just grew is
145
+ // named. Omitted -> main.regions stays null, never fabricated.
146
+ regionSampler = null,
138
147
  now = () => Date.now(),
139
148
  // Injectable so a test isn't forced to monkey-patch the process-global console (which
140
149
  // would race other concurrently-running tests in the same file).
@@ -199,6 +208,17 @@ function createServerMemorySampler(options = {}) {
199
208
  };
200
209
  }
201
210
 
211
+ // WP-A P-2: the in-flight snapshot for this row. A broken/absent source reports null rather than
212
+ // failing the tick (same disposition as readWorkerMemory above).
213
+ function readInFlight() {
214
+ try {
215
+ return (typeof inFlightSource === 'function' ? inFlightSource() : null) ?? null;
216
+ } catch {
217
+ /* expected: the read-pool/db-owner clients can be mid-restart; "unknown in flight" beats a lost row */
218
+ return null;
219
+ }
220
+ }
221
+
202
222
  async function sampleProcessesRss(pids) {
203
223
  if (!pids.length) return new Map();
204
224
  const psCommand = platform === 'darwin' ? '/bin/ps' : 'ps';
@@ -261,6 +281,18 @@ function createServerMemorySampler(options = {}) {
261
281
  const mu = process.memoryUsage();
262
282
  const mainProc = procs.find((p) => p.role === 'main') || null;
263
283
  const poolProc = procs.find((p) => p.role === 'session-host-pool') || null;
284
+ // WP-A P-3: the MAIN process' categorized footprint regions -- the only read that can NAME the
285
+ // category behind a step (the 4<->9 GB swing is entirely "Malloc Large"). `null` (never
286
+ // fabricated) when unwired, when the main pid is unknown, or when the read failed.
287
+ let regions = null;
288
+ if (regionSampler && typeof regionSampler.sampleFootprintRegions === 'function' && mainProc && mainProc.pid) {
289
+ try {
290
+ regions = (await regionSampler.sampleFootprintRegions(mainProc.pid)) ?? null;
291
+ } catch {
292
+ /* expected: /usr/bin/footprint can be missing, denied or killed; the row must still record */
293
+ regions = null;
294
+ }
295
+ }
264
296
  const main = {
265
297
  heapUsedMB: roundMB(mu.heapUsed),
266
298
  externalMB: roundMB(mu.external),
@@ -273,6 +305,8 @@ function createServerMemorySampler(options = {}) {
273
305
  footprintMB: mainProc ? mainProc.physFootprintMB : null,
274
306
  // WP-0b (§5 D-2): main-thread's OWN live zstd contexts (workers report theirs separately).
275
307
  liveDecoders: liveDecoderCount(),
308
+ // WP-A P-3: categorized footprint regions of this same pid (null when unwired/unsampled).
309
+ regions,
276
310
  };
277
311
  // WP-0b (§5 D-2, §9 S-9): per-worker heap/external/arrayBuffers + live decoder count, summed
278
312
  // into workersTotal so the row explains what the main isolate's process.memoryUsage() cannot.
@@ -338,7 +372,10 @@ function createServerMemorySampler(options = {}) {
338
372
  }
339
373
  const ledger = compactLedgerSubset(fullLedger);
340
374
 
341
- const sample = { schemaVersion: SCHEMA_VERSION, reason, procs, main, poolHost, ledger, workers, workersTotal };
375
+ // WP-A P-2: the work in flight at sample time, so a step in rss/footprint can be read against
376
+ // what was running when it happened (`null` = unknown/not wired).
377
+ const inFlight = readInFlight();
378
+ const sample = { schemaVersion: SCHEMA_VERSION, reason, procs, main, poolHost, ledger, workers, workersTotal, inFlight };
342
379
  if (typeof record === 'function') {
343
380
  try { record('server-memory', sample); } catch { /* expected: a broken diagnostic sink must not break the sampler loop itself */ }
344
381
  }
@@ -363,13 +400,15 @@ function createServerMemorySampler(options = {}) {
363
400
  // WP-0b (§5 D-2, §9 S-9): run an extra sample whenever main RSS stepped >= minStepMB since the
364
401
  // last recorded row. Never throws; returns the sample or null when skipped (no prior sample,
365
402
  // caller rss unavailable, below threshold, an unrepresentable prior rss, or a sample in flight).
403
+ // WP-A P-3: the default threshold is 768 MB, not 2048 -- the live 2026-10-21 measurement showed the
404
+ // server's real steps are ~970 MB of "Malloc Large", which the old 2048 default silently skipped.
366
405
  async function sampleIfStep(options = {}) {
367
406
  try {
368
407
  const opts = options && typeof options === 'object' ? options : {};
369
408
  const target = Number(opts.rssMB);
370
409
  if (!Number.isFinite(target)) return null;
371
410
  if (!_lastMain || !Number.isFinite(Number(_lastMain.rssMB))) return null;
372
- const threshold = Number.isFinite(Number(opts.minStepMB)) ? Number(opts.minStepMB) : 2048;
411
+ const threshold = Number.isFinite(Number(opts.minStepMB)) ? Number(opts.minStepMB) : 768;
373
412
  if (Math.abs(target - Number(_lastMain.rssMB)) < threshold) return null;
374
413
  return await sampleOnce({ reason: 'rss-step', rssMB: target });
375
414
  } catch {
@@ -76,7 +76,7 @@ function applyModelExecutionRecipeToSpawnEnv(env, recipe, expected = {}) {
76
76
  }
77
77
 
78
78
  function modelExecutionRecipeForSpawn({ sessionId, processGeneration, agent, launchPlan, leadRoute, transition,
79
- sourceEnvironmentReference, providerThreadId: explicitProviderThreadId }) {
79
+ sourceEnvironmentReference, providerThreadId: explicitProviderThreadId, nativeConfigReceipt }) {
80
80
  const routeId = leadRoute ? String(leadRoute.routeId || leadRoute.alias || '') : null;
81
81
  const profile = leadRoute?.profile || leadRoute?.preparedPlan?.profile || null;
82
82
  const ownership = leadRoute?.ownership || leadRoute?.preparedPlan?.ownership || null;
@@ -93,6 +93,8 @@ function modelExecutionRecipeForSpawn({ sessionId, processGeneration, agent, lau
93
93
  factsRevision: routeId ? (profile.factsRevision || ownership?.factsRevision || null) : null,
94
94
  transitionId: transition?.transitionId || leadRoute?.transitionId || ownership?.transitionId || null,
95
95
  ...(sourceEnvironmentReference ? { sourceEnvironmentReference } : {}),
96
+ ...(nativeConfigReceipt && ['present', 'missing'].includes(nativeConfigReceipt.state)
97
+ ? { nativeConfigReceipt: { state: nativeConfigReceipt.state, sha256: nativeConfigReceipt.sha256 ?? null } } : {}),
96
98
  };
97
99
  }
98
100
 
@@ -890,7 +892,7 @@ function createProviderSession(state) {
890
892
  const modelProcessGeneration = capturedModelProcessGeneration;
891
893
  const executionRecipe = modelExecutionRecipeForSpawn({ sessionId: id, processGeneration: modelProcessGeneration,
892
894
  agent: createAgentType, launchPlan: preparedLaunchPlan, leadRoute, transition: msg._leadModelTransition || null,
893
- sourceEnvironmentReference, providerThreadId: launchProviderThreadId({ agent: createAgentType,
895
+ sourceEnvironmentReference, nativeConfigReceipt, providerThreadId: launchProviderThreadId({ agent: createAgentType,
894
896
  launchPlan: preparedLaunchPlan, claudeResumeId, injectedSessionId: _injectedSessionId, args: launchArgs }) });
895
897
  let spawnEnv = { ...applyModelExecutionRecipeToSpawnEnv(leadApplied.spawnEnv, executionRecipe,
896
898
  { sessionId: id, processGeneration: modelProcessGeneration }), CTM_MODEL_PROCESS_GENERATION: String(modelProcessGeneration) };
@@ -128,9 +128,6 @@ function lifecycleConfigFromEnv(env = process.env) {
128
128
  prewarm: envBool(env, 'CTM_PREWARM', false),
129
129
  prewarmTtlMs: envInt(env, 'CTM_PREWARM_TTL_MS', 60_000, 1_000),
130
130
  prewarmTopK: envInt(env, 'CTM_PREWARM_TOP_K', 1, 1),
131
- freezeTier: idlePark && envBool(env, 'CTM_FREEZE_TIER', false),
132
- freezeAfterMs: envInt(env, 'CTM_FREEZE_AFTER_MS', 15 * 60_000, 1_000),
133
- freezeParkAfterMs: 2 * 60 * 60_000,
134
131
  // WP-3: default is derived from host RAM (see defaultSessionRssBudgetMB), so the
135
132
  // MemoryBudgetController is live out of the box. `CTM_SESSION_RSS_BUDGET_MB=0`
136
133
  // remains the explicit disable — a 0 budget makes observe() inert again.
@@ -147,11 +144,7 @@ function lifecycleConfigFromEnv(env = process.env) {
147
144
 
148
145
  function automaticParkSweepPolicy(config, options = {}) {
149
146
  const pressure = ['warn', 'critical'].includes(options.pressure) ? options.pressure : 'normal';
150
- const freezeTier = options.freezeTier === true;
151
- const configuredThresholdMs = Math.max(0, Number(config?.idleParkAfterMs) || 0);
152
- const idleParkAfterMs = freezeTier
153
- ? Math.max(configuredThresholdMs, Number(config?.freezeParkAfterMs) || 0)
154
- : configuredThresholdMs;
147
+ const idleParkAfterMs = Math.max(0, Number(config?.idleParkAfterMs) || 0);
155
148
  const disconnectCollapseMs = Math.max(
156
149
  idleParkAfterMs,
157
150
  Number(config?.disconnectCollapseMs) || 0,
@@ -223,7 +216,7 @@ function normalizeClientKind(value) {
223
216
  function evaluatePark(session, host, config) {
224
217
  const now = Number(host?.now) || Date.now();
225
218
  const protections = [];
226
- if (!session || !['live', 'frozen'].includes(session.state)) protections.push('not-live');
219
+ if (!session || session.state !== 'live') protections.push('not-live');
227
220
  if (session?.resumable === false) protections.push('not-resumable'); if (session?.parkable === false) protections.push('not-parkable-class');
228
221
  if (session?.liveStatus === 'running' || session?.liveStatus === 'generating' || session?.liveStatus === 'working') protections.push('running');
229
222
  if (session?.liveStatus === 'waiting_input' || session?.liveStatus === 'resuming') protections.push('waiting-input');
@@ -490,37 +483,6 @@ function describePressureCause(signals = {}, thresholds = {}, effectiveLevel = '
490
483
  || { kind: 'recovering', level, value: null };
491
484
  }
492
485
 
493
- function evaluateFreeze(session, host, config) {
494
- if (config?.freezeTier !== true) {
495
- return { eligible: false, protections: ['disabled'], idleMs: 0, thresholdMs: Number(config?.freezeAfterMs) || 0 };
496
- }
497
- if (!session || session.state !== 'live') {
498
- return { eligible: false, protections: ['not-live'], idleMs: 0, thresholdMs: Number(config?.freezeAfterMs) || 0 };
499
- }
500
- return evaluatePark(session, host, {
501
- ...config,
502
- idleParkAfterMs: Number(config.freezeAfterMs) || 15 * 60_000,
503
- disconnectCollapseMs: Number(config.freezeAfterMs) || 15 * 60_000,
504
- });
505
- }
506
-
507
- function selectFreezeVictim(sessions, host, config) {
508
- const eligible = [];
509
- for (const session of sessions || []) {
510
- const result = evaluateFreeze(session, host, config);
511
- if (result.eligible) eligible.push({ session, result });
512
- }
513
- eligible.sort((a, b) => {
514
- const activityDelta = (Number(a.session.lastMeaningfulActivityAt) || 0)
515
- - (Number(b.session.lastMeaningfulActivityAt) || 0);
516
- if (activityDelta !== 0) return activityDelta;
517
- const rssDelta = sessionMemoryMB(b.session) - sessionMemoryMB(a.session);
518
- if (rssDelta !== 0) return rssDelta;
519
- return String(a.session.id).localeCompare(String(b.session.id));
520
- });
521
- return eligible[0]?.session || null;
522
- }
523
-
524
486
  class PressureController {
525
487
  constructor() {
526
488
  this.level = 'normal';
@@ -773,7 +735,6 @@ class LifecycleDiagnostics {
773
735
  this.activationByClient = {};
774
736
  this.parkByReason = {};
775
737
  this.prewarmByOutcome = {};
776
- this.freezeByAction = {};
777
738
  this.shadow = { candidates: 0, falsePositives: 0, projectedReclaimMB: 0 };
778
739
  this.pressureTransitions = 0;
779
740
  this.events = [];
@@ -813,12 +774,6 @@ class LifecycleDiagnostics {
813
774
  this._event({ type: 'prewarm', id, outcome: normalized, reason: String(reason || '').slice(0, 48) });
814
775
  }
815
776
 
816
- recordFreeze({ id, action, idleMs = 0, reason = '' }) {
817
- const normalized = String(action || 'unknown').slice(0, 32);
818
- this._count(this.freezeByAction, normalized);
819
- this._event({ type: 'freeze', id, action: normalized, idleMs: Math.max(0, idleMs), reason: String(reason || '').slice(0, 48) });
820
- }
821
-
822
777
  recordShadow({ id, action = 'candidate', rssMB = 0, activityAfterMs = 0, reason = '', signals = {} }) {
823
778
  if (action === 'candidate') {
824
779
  this.shadow.candidates += 1;
@@ -876,7 +831,6 @@ class LifecycleDiagnostics {
876
831
  activationByClient: { ...this.activationByClient },
877
832
  parkByReason: { ...this.parkByReason },
878
833
  prewarmByOutcome: { ...this.prewarmByOutcome },
879
- freezeByAction: { ...this.freezeByAction },
880
834
  shadow: { ...this.shadow },
881
835
  pressureTransitions: this.pressureTransitions,
882
836
  wakeOutcomes: { ...this.wakeOutcomes },
@@ -901,11 +855,9 @@ module.exports = {
901
855
  classifyWakeOutcome,
902
856
  derivePressure,
903
857
  describePressureCause,
904
- evaluateFreeze,
905
858
  evaluateManualPark,
906
859
  evaluatePark,
907
860
  summarizeParkEligibility,
908
- selectFreezeVictim,
909
861
  selectParkVictim,
910
862
  sessionMemoryMB,
911
863
  };
@@ -72,6 +72,28 @@ function parkRefusalDetail(protections) {
72
72
  return 'The session could not be restarted right now. Try again in a moment.';
73
73
  }
74
74
 
75
+ // WP-0 D-10: a refusal code with no hand-written sentence still gets a family sentence that NAMES
76
+ // the code, so no refusal can reach the person as a generic line. Checked after the explicit map and
77
+ // before any caller-supplied message.
78
+ const HISTORY_REFUSAL_FAMILIES = Object.freeze([
79
+ [/^unsupported_native_/, 'This history carries a record type that has not been qualified for transfer.'],
80
+ [/^native_world_state_/, 'The native Codex world state could not be replayed safely.'],
81
+ [/^native_history_/, 'The native Codex history could not be prepared for transfer.'],
82
+ [/^tool_call_/, 'This history has an incomplete tool exchange, so it cannot be transferred safely.'],
83
+ [/^history_/, 'The source history could not be verified for this provider change.'],
84
+ [/^source_/, 'The source session could not be verified for this provider change.'],
85
+ [/^target_/, 'The target session could not be verified for this provider change.'],
86
+ [/^fork_/, 'The native fork could not be verified in Codex history.'],
87
+ [/^native_(?:rpc|process|fork|adapter|readback|initialize)_/, 'The native Codex process could not complete the handoff.'],
88
+ ]);
89
+
90
+ function historyRefusalFamilyMessage(reason, tail) {
91
+ for (const [pattern, sentence] of HISTORY_REFUSAL_FAMILIES) {
92
+ if (pattern.test(reason)) return `${sentence} (code: ${reason}) ${tail}`;
93
+ }
94
+ return '';
95
+ }
96
+
75
97
  // History checks run before the source is parked. Keep their bounded reason codes useful to the
76
98
  // person making the change without exposing scanner records, paths, or provider credentials.
77
99
  function historyPreparationMessage(reason, suppliedMessage = '', { postPark = false } = {}) {
@@ -83,10 +105,13 @@ function historyPreparationMessage(reason, suppliedMessage = '', { postPark = fa
83
105
  source_checkpoint_changed_after_park: `The source checkpoint changed during handoff verification. ${recovery}`,
84
106
  source_config_policy_unqualified: `The captured Codex configuration changed during handoff verification. ${recovery}`,
85
107
  native_history_control_failed: `Codex could not create the native history checkpoint. ${recovery}`,
108
+ native_handoff_inject_failed: `The handoff summary could not be written into the new Codex thread. ${recovery}`,
109
+ fork_target_handoff_missing: `The new Codex thread does not prove it received the handoff summary for its encrypted history. ${recovery}`,
86
110
  fork_readback_unverified: `The native fork could not be verified in Codex history. ${recovery}`,
87
111
  fork_journal_owner_changed: `The transition owner changed during handoff. ${recovery}`,
88
112
  };
89
- return messages[reason] || `The target handoff could not be verified. ${recovery}`;
113
+ return messages[reason] || historyRefusalFamilyMessage(reason, recovery)
114
+ || `The target handoff could not be verified. ${recovery}`;
90
115
  }
91
116
  const messages = {
92
117
  native_target_profile_unavailable: 'The native Codex model does not publish the context limits needed to verify this history move. The current session is still running unchanged.',
@@ -100,7 +125,6 @@ function historyPreparationMessage(reason, suppliedMessage = '', { postPark = fa
100
125
  // WP-2 (docs/codex-encrypted-history-lead-switch-2026-10-17.html §5/§8): the name stays; the copy
101
126
  // now says what the user actually has (an encrypted summary) and what did NOT happen to them.
102
127
  encrypted_history_not_portable: 'Earlier history exists only as an encrypted summary that the target model cannot read, so it cannot be carried over. The current session is still running unchanged.',
103
- compacted_source_fork_unqualified: 'This source history was compacted, and its active checkpoint cannot yet be verified across providers. The current session is still running unchanged.',
104
128
  source_compaction_unqualified: 'The source history is too large for the target, and safely compacting it is not supported yet. The current session is still running unchanged.',
105
129
  history_estimate_unknown: 'The source history size could not be verified safely. The current session is still running unchanged.',
106
130
  target_compaction_hash_unknown: 'The target profile cannot verify the source history compaction settings. The current session is still running unchanged.',
@@ -109,9 +133,41 @@ function historyPreparationMessage(reason, suppliedMessage = '', { postPark = fa
109
133
  unpaired_tool_history: 'This history has an incomplete tool exchange, so it cannot be transferred safely. The current session is still running unchanged.',
110
134
  source_origin_unknown: 'The source provider could not be verified. The current session is still running unchanged.',
111
135
  source_origin_unsupported: 'This source provider has no qualified history handoff yet. The current session is still running unchanged.',
136
+ transition_cancelled: 'The history check was cancelled before it finished. The current session is still running unchanged.',
137
+ history_scan_incomplete: 'The source history scan did not finish, so nothing was moved. The current session is still running unchanged.',
138
+ source_checkpoint_unknown: 'The source history has no verified checkpoint to carry over. The current session is still running unchanged.',
139
+ source_previous_model_mismatch: 'The model recorded on the source history does not match the captured source model. The current session is still running unchanged.',
140
+ source_model_changed: 'The source model changed while the history was being prepared. The current session is still running unchanged.',
141
+ lead_source_changed: 'The source session this switch started from is no longer the live one. The current session is still running unchanged.',
142
+ provider_identity_unknown: 'The source or target provider for this history move could not be identified. The current session is still running unchanged.',
143
+ target_budget_unknown: 'The target model does not publish the context budget needed to carry this history over. The current session is still running unchanged.',
144
+ history_estimate_evidence_unknown: 'The source history size was measured, but not in a way that can be verified for this move. The current session is still running unchanged.',
145
+ compaction_hash_mismatch: 'The source and target history compaction settings disagree, so the move cannot be verified safely. The current session is still running unchanged.',
146
+ native_transition_policy_unknown: 'The Codex permission policy for the source or the target could not be read. The current session is still running unchanged.',
147
+ native_transition_policy_mismatch: 'The Codex permission policy on the target differs from the source, so the history cannot be moved safely. The current session is still running unchanged.',
148
+ native_fork_adapter_unqualified: 'This Codex installation or adapter is not qualified to carry this history over. The current session is still running unchanged.',
149
+ native_policy_override_unqualified: 'This source session uses a Codex policy override that cannot yet be reproduced safely for history transfer. The current session is still running unchanged.',
150
+ native_token_usage_record_invalid: 'The Codex usage record for this session could not be verified, so the history size cannot be trusted. The current session is still running unchanged.',
151
+ history_media_not_portable: 'This history contains an image, and the target model cannot receive images. The current session is still running unchanged.',
152
+ // WP-2 (docs/codex-lead-routed-switch-history-handoff-2026-10-21.html §5 D-5/D-6, §6.5): the
153
+ // encrypted-compaction path. The source model writes a plaintext handoff before the switch, and
154
+ // each way that handoff can fail says which one it was.
155
+ encrypted_compaction_multiple: 'This history holds more than one encrypted compaction, and only one of them can be replaced by a handoff summary. The current session is still running unchanged.',
156
+ encrypted_compaction_handoff_unavailable: 'This history exists only as an encrypted summary, and the model that wrote it cannot be asked to write a plaintext handoff for this provider change. The current session is still running unchanged.',
157
+ handoff_fork_failed: 'The handoff summary could not be started from this session\'s history. The current session is still running unchanged.',
158
+ handoff_turn_failed: 'The source model did not finish writing the handoff summary. The current session is still running unchanged.',
159
+ handoff_empty: 'The source model returned a handoff summary that is too short to carry this history over. The current session is still running unchanged.',
160
+ handoff_too_long: 'The source model returned a handoff summary that is too long to carry this history over. The current session is still running unchanged.',
161
+ handoff_marker_in_text: 'The handoff summary contains text reserved for this move, so it cannot be trusted. The current session is still running unchanged.',
162
+ handoff_timeout: 'The source model did not finish writing the handoff summary in time. The current session is still running unchanged.',
163
+ // Raised while the forked target is being written, but the code is enumerated from its own
164
+ // source file, so it carries a sentence on both paths rather than falling back to the generic one.
165
+ native_handoff_inject_failed: 'The handoff summary could not be written into the new Codex thread. The current session is still running unchanged.',
112
166
  };
113
167
  const safe = typeof suppliedMessage === 'string' ? suppliedMessage.trim().slice(0, 240) : '';
114
- return messages[reason] || safe || 'History could not be verified for this provider change. The current session is still running unchanged.';
168
+ return messages[reason]
169
+ || historyRefusalFamilyMessage(reason, 'The current session is still running unchanged.')
170
+ || safe || 'History could not be verified for this provider change. The current session is still running unchanged.';
115
171
  }
116
172
 
117
173
  function refusal(status, code, extra) {
@@ -126,6 +126,33 @@ function parseFootprintJson(text) {
126
126
  return results;
127
127
  }
128
128
 
129
+ // WP-A P-1 (freeze-probes): the CATEGORIZED `footprint -j` document (no `--noCategories`) --
130
+ // { unit, 'bytes per unit', processes: [{ pid, footprint, categories: { name: { dirty, swapped, ... } } }] }.
131
+ // The 4<->9 GB server swing measured 2026-10-21 lives entirely in the "Malloc Large" category, which
132
+ // the --noCategories summary above cannot name. Pure; returns { totalMB, top: [{name,dirtyMB,swappedMB}] }
133
+ // for the FIRST process, null for unparseable input or a non-byte unit (same rules as parseFootprintJson).
134
+ function parseFootprintRegions(text) {
135
+ let doc;
136
+ try { doc = JSON.parse(String(text ?? '')); } catch { return null; } // not JSON at all (empty stdout on a failed run)
137
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return null;
138
+ if (doc.unit !== 'byte') return null;
139
+ const bytesPerUnit = Number(doc['bytes per unit']) || 1;
140
+ const proc = (Array.isArray(doc.processes) ? doc.processes : []).find(entry => entry && typeof entry === 'object');
141
+ if (!proc) return null;
142
+ const mb = (bytes) => Math.round((bytes / 1048576) * 10) / 10;
143
+ const rows = Object.entries(proc.categories && typeof proc.categories === 'object' ? proc.categories : {})
144
+ .map(([name, region]) => {
145
+ const dirty = (Number(region?.dirty) || 0) * bytesPerUnit;
146
+ const swapped = (Number(region?.swapped) || 0) * bytesPerUnit;
147
+ return { name: String(name).slice(0, 40), dirtyMB: mb(dirty), swappedMB: mb(swapped), total: dirty + swapped };
148
+ })
149
+ .sort((a, b) => b.total - a.total);
150
+ return {
151
+ totalMB: Number.isFinite(proc.footprint) ? mb(proc.footprint * bytesPerUnit) : null,
152
+ top: rows.slice(0, 6).map(({ name, dirtyMB, swappedMB }) => ({ name, dirtyMB, swappedMB })),
153
+ };
154
+ }
155
+
129
156
  // Cleanup never turns an already-decided sample (number or D-3 null) into a rejected sweep.
130
157
  function removeTempFileQuietly(file) {
131
158
  return fs.promises.rm(file, { force: true }).then(() => true, () => false);
@@ -540,6 +567,26 @@ class SessionResourceSampler {
540
567
  return results;
541
568
  }
542
569
 
570
+ // WP-A P-1: ONE fresh categorized `/usr/bin/footprint -j <tmp> <pid>` for the MAIN pid, every call.
571
+ // Deliberately NOT the TTL-cached sampleFootprints() path: a stale cache entry cannot name the region
572
+ // that just grew. Same gate/tmp/limits/cleanup mechanics as _sampleFootprintBatch, minus the cache.
573
+ // Never rejects: null on non-darwin, on the primary-only gate, or on any failure. Categorized
574
+ // `footprint` does not suspend its target (measured 2026-10-21), unlike vmmap/sample/leaks.
575
+ async sampleFootprintRegions(pid) {
576
+ const key = Number(pid);
577
+ if (this.platform !== 'darwin' || !(key > 0)) return null;
578
+ if (this.exec === execFileAsync && !hostIdentityAccessAllowed({ env: this.env })) return null;
579
+ const tmp = path.join(os.tmpdir(), `ctm-footprint-regions-${process.pid}-${crypto.randomUUID()}.json`);
580
+ try {
581
+ await this.exec(FOOTPRINT_BIN, ['-j', tmp, String(key)], { timeout: 10_000, maxBuffer: 4 * 1024 * 1024 });
582
+ return parseFootprintRegions(await fs.promises.readFile(tmp, 'utf8'));
583
+ } catch {
584
+ return null; // footprint missing/denied or temp file unreadable (D-3: null, never a throw)
585
+ } finally {
586
+ await removeTempFileQuietly(tmp);
587
+ }
588
+ }
589
+
543
590
  // D-1: sweep counterpart to sampleFootprints() (same batch and TTL cache), resolving to
544
591
  // `{ footprintMB, sampledAt }` per pid so a consumer can tell how fresh an entry is.
545
592
  async sampleFootprintSnapshots({ pids, now } = {}) {
@@ -558,6 +605,7 @@ module.exports = {
558
605
  FOOTPRINT_SAMPLE_TTL_MS,
559
606
  hostMemoryFromVmStat,
560
607
  parseFootprintJson,
608
+ parseFootprintRegions,
561
609
  parseMemoryPressure,
562
610
  parsePageouts,
563
611
  parseProcessTable,
@@ -11,7 +11,7 @@ const RESUMING_RESUME_PHASES = new Set(['preparing', 'spawning', 'loading', 'str
11
11
  // projectSessionStatus(session) is a pure reducer over the fields server.js already writes
12
12
  // today (`_lifecycleState`, `_resumeAttemptState.phase`, `_resumeBlocked`, `_pendingApproval`,
13
13
  // `_pressureShed`) — see server.js's RESUME_PHASE enum (lib/resume-attempt-state.js) and the
14
- // `_lifecycleState` values ('frozen'/'live'/'parking'/'parked'/'deferred'/'waking'/'degraded'/
14
+ // `_lifecycleState` values ('live'/'parking'/'parked'/'deferred'/'waking'/'degraded'/
15
15
  // 'exited') for the source vocabulary this projects from.
16
16
  //
17
17
  // requestTransition(sessionId, to, cause) is the ONE gate for changing the FSM's *phase*.
@@ -48,7 +48,7 @@ function projectSessionStatus(session) {
48
48
  else if (lifecycleState === 'parked' || lifecycleState === 'parking') phase = 'parked';
49
49
  else if (lifecycleState === 'degraded' || resumePhase === 'blocked') phase = 'degraded';
50
50
  else if (resumeBlocked || RESUMING_RESUME_PHASES.has(resumePhase)) phase = 'resuming';
51
- else if (lifecycleState === 'waking' || lifecycleState === 'deferred' || lifecycleState === 'frozen') phase = 'attaching';
51
+ else if (lifecycleState === 'waking' || lifecycleState === 'deferred') phase = 'attaching';
52
52
  else if (lifecycleState === 'live' || (lifecycleState === '' && (resumePhase === 'ready' || resumePhase === ''))) phase = 'live';
53
53
  else phase = 'detached';
54
54
 
@@ -1,5 +1,12 @@
1
1
  'use strict';
2
2
 
3
+ // WP-3 (composer-freeze-followups-load-sigstop-attribution-2026-10-20.html §5 D-C1/D-C2):
4
+ // READ THE STAGE NAMES HONESTLY. Every stage after `pty-written` EXCEPT `echo-bytes`/`echo-cell`
5
+ // is stamped on the session's whole pending id set by the FIRST output/feed/frame after the key
6
+ // -- "first X after the key", which is approximate, not causal (D-C1; the pty-output leg lands
7
+ // 0-3 ms after pty-written in 14 of 18 retained slow rows, which no agent CLI can do).
8
+ // `echo-bytes`/`echo-cell` are the only per-key matches today, and they are shadow-only
9
+ // instrumentation for WP-4/WP-5's decision (D-C2) -- nothing reads them as truth yet.
3
10
  const STAGES = Object.freeze([
4
11
  'browser-keydown',
5
12
  'server-received',
@@ -11,6 +18,11 @@ const STAGES = Object.freeze([
11
18
  'host-posted',
12
19
  'pty-written',
13
20
  'pty-output',
21
+ // WP-3 D-C2: the two shadow, per-key echo matchers -- VS Code typeahead's byte rule and
22
+ // mosh's cell rule (see lib/typing-worker-stages.js, which runs them in the mirror). Both are
23
+ // OPTIONAL (OPTIONAL_STAGES below), so neither can move lastCompletedBoundary.
24
+ 'echo-bytes',
25
+ 'echo-cell',
14
26
  // WP-0 (composer-freeze-stop-and-wait-emitter-2026-09-16.html §5 D-1, §6 API specs, §8 WP-0):
15
27
  // four stages stamped INSIDE the worker/pool-host, between `pty-output` and `mirror-revision`
16
28
  // -- the 16.1 s gap the census found with zero instrumentation coverage (§1.5). All four are
@@ -52,7 +64,11 @@ const STAGE_INDEX = new Map(STAGES.map((stage, index) => [stage, index]));
52
64
  // D-1: stages that may be absent from an otherwise-complete row without that absence meaning
53
65
  // "the interaction stalled here" -- the sequential boundary walk in note() skips these rather
54
66
  // than treating a miss as the end of the row's progress (see the walk below).
55
- const OPTIONAL_STAGES = new Set(['pty-output', 'worker-received', 'mirror-written', 'emit-offered', 'emit-refused']);
67
+ // WP-3 D-C2: 'echo-bytes'/'echo-cell' join them -- they are shadow stamps most keys never get.
68
+ const OPTIONAL_STAGES = new Set([
69
+ 'pty-output', 'worker-received', 'mirror-written', 'emit-offered', 'emit-refused',
70
+ 'echo-bytes', 'echo-cell',
71
+ ]);
56
72
  const WORKLOAD_FIELDS = Object.freeze([
57
73
  'rawBatchClass', 'synchronizedFrameCount', 'mirrorChangedRows', 'frameKind',
58
74
  'frameBytes', 'queuedBytes', 'queueDepth', 'codexRepairActions',
@@ -99,6 +115,23 @@ function stageMsFromKeydown(row, stage) {
99
115
  return stageDelta(row, stage, 'browser-keydown');
100
116
  }
101
117
 
118
+ // WP-3 D-C3: an expired row must not report an OPTIONAL stage as "where it got to" -- the
119
+ // reverse scan it used to do picked `pty-output` over `pty-written` for a key that stalled
120
+ // after a stray output chunk (I-6 / R9). `lastObservedStage` is therefore the last observed
121
+ // REQUIRED stage (fallback `browser-keydown`), and the last observed OPTIONAL stage is reported
122
+ // separately as `lastOptionalStage` ('' when there is none) so the shadow echo stamps stay
123
+ // readable without ever being mistaken for progress.
124
+ function observedStageSplit(row) {
125
+ let lastObservedStage = 'browser-keydown';
126
+ let lastOptionalStage = '';
127
+ for (const stage of STAGES) {
128
+ if (!row.stages[stage]) continue;
129
+ if (OPTIONAL_STAGES.has(stage)) lastOptionalStage = stage;
130
+ else lastObservedStage = stage;
131
+ }
132
+ return { lastObservedStage, lastOptionalStage };
133
+ }
134
+
102
135
  // D-7/S-14: builds the flat, ≤24-key `typing-interaction-slow` payload from the SAME stage
103
136
  // vector `note()`/`begin()` already maintain -- no second clock, no parallel bookkeeping.
104
137
  // `lastStage` is passed explicitly by the two callers below rather than read from
@@ -118,6 +151,10 @@ function buildSlowInteractionRow(row, ktpMs, lastStage) {
118
151
  printable: row.printable === true,
119
152
  ktpMs: Math.max(0, finite(ktpMs, 0)),
120
153
  lastStage: lastStage || row.lastCompletedBoundary || 'browser-keydown',
154
+ // WP-3 D-C3: the last OPTIONAL stage observed for this row ('' when none) -- carried on
155
+ // every slow/worst vector so `lastStage` can stay required-only without losing the shadow
156
+ // echo stamps.
157
+ lastOptionalStage: observedStageSplit(row).lastOptionalStage,
121
158
  dropReason: dropped ? boundedText(dropped.reason, 64) : '',
122
159
  gate: refused ? refused.gate : '',
123
160
  gateTop: refused ? refused.gate : '',
@@ -131,6 +168,9 @@ function buildSlowInteractionRow(row, ktpMs, lastStage) {
131
168
  serverReceivedMs: stageMsFromKeydown(row, 'server-received'),
132
169
  ptyWrittenMs: stageMsFromKeydown(row, 'pty-written'),
133
170
  ptyOutputMs: stageMsFromKeydown(row, 'pty-output'),
171
+ // WP-3 D-C2: the shadow per-key matchers' legs, same keydown rule as ptyOutputMs above.
172
+ echoBytesMs: stageMsFromKeydown(row, 'echo-bytes'),
173
+ echoCellMs: stageMsFromKeydown(row, 'echo-cell'),
134
174
  mirrorRevisionMs: stageMsFromKeydown(row, 'mirror-revision'),
135
175
  frameSentMs: stageMsFromKeydown(row, 'frame-sent'),
136
176
  socketReceivedMs: stageMsFromKeydown(row, 'socket-received'),
@@ -302,12 +342,14 @@ function createTypingInteractionTraceStore(options = {}) {
302
342
 
303
343
  function captureExpiredInteraction(row, at) {
304
344
  if (row.stages.painted) return;
305
- const lastObservedStage = STAGES.slice().reverse().find(stage => row.stages[stage]) || 'browser-keydown';
345
+ // WP-3 D-C3: required-only `lastObservedStage` + a separate `lastOptionalStage` (I-6/R9).
346
+ const { lastObservedStage, lastOptionalStage } = observedStageSplit(row);
306
347
  const ageMs = Math.max(0, at - row.startedAt);
307
348
  pushBounded(pendingExpired, rollupGroupKey(row.sessionId, row.agentType), {
308
349
  ageMs,
309
350
  clientId: row.clientId,
310
351
  lastObservedStage,
352
+ lastOptionalStage,
311
353
  });
312
354
  windowExpiredHistory.push({ at });
313
355
  if (windowExpiredHistory.length > WINDOW_HISTORY_CAP) windowExpiredHistory.shift();