create-walle 0.9.28 → 0.9.30

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 (140) hide show
  1. package/README.md +2 -2
  2. package/bin/create-walle.js +166 -6
  3. package/package.json +1 -1
  4. package/template/bin/ctm-launch.sh +70 -18
  5. package/template/bin/dev.sh +18 -0
  6. package/template/bin/ensure-stable-node.js +11 -0
  7. package/template/bin/node-bin.sh +9 -0
  8. package/template/claude-task-manager/api-prompts.js +214 -23
  9. package/template/claude-task-manager/db.js +884 -50
  10. package/template/claude-task-manager/docs/backfill-incremental-no-main-fallback.md +48 -0
  11. package/template/claude-task-manager/docs/conversation-import-freshness.md +21 -0
  12. package/template/claude-task-manager/docs/conversation-log-redesign.html +587 -0
  13. package/template/claude-task-manager/docs/session-title-authority.md +8 -3
  14. package/template/claude-task-manager/lib/auth-rules.js +13 -0
  15. package/template/claude-task-manager/lib/claude-desktop-sessions.js +63 -0
  16. package/template/claude-task-manager/lib/codex-config-guard.js +124 -0
  17. package/template/claude-task-manager/lib/codex-rollout-snapshot.js +93 -0
  18. package/template/claude-task-manager/lib/coding-agent-models.js +5 -4
  19. package/template/claude-task-manager/lib/db-owner-cooperative-scheduler.js +114 -0
  20. package/template/claude-task-manager/lib/db-owner-task-queue.js +67 -0
  21. package/template/claude-task-manager/lib/db-owner-worker-client.js +5 -1
  22. package/template/claude-task-manager/lib/desktop-fork.js +81 -0
  23. package/template/claude-task-manager/lib/headless-term-service.js +251 -4
  24. package/template/claude-task-manager/lib/message-identity.js +115 -0
  25. package/template/claude-task-manager/lib/mirror-feed-guards.js +25 -0
  26. package/template/claude-task-manager/lib/mirror-feed-sanitize.js +45 -0
  27. package/template/claude-task-manager/lib/path-suggest.js +77 -0
  28. package/template/claude-task-manager/lib/prompt-index-inputs.js +136 -0
  29. package/template/claude-task-manager/lib/real-node.js +36 -4
  30. package/template/claude-task-manager/lib/restore-auto-resume-policy.js +67 -0
  31. package/template/claude-task-manager/lib/restore-resume-batch.js +20 -0
  32. package/template/claude-task-manager/lib/restore-terminal-dims.js +109 -0
  33. package/template/claude-task-manager/lib/resume-cwd.js +124 -3
  34. package/template/claude-task-manager/lib/runtime-approval-recorder.js +152 -0
  35. package/template/claude-task-manager/lib/runtime-context-truth.js +236 -0
  36. package/template/claude-task-manager/lib/runtime-contract.js +195 -0
  37. package/template/claude-task-manager/lib/runtime-history-builder.js +205 -0
  38. package/template/claude-task-manager/lib/runtime-hook-bus.js +98 -0
  39. package/template/claude-task-manager/lib/runtime-input-queue.js +114 -0
  40. package/template/claude-task-manager/lib/runtime-input-recorder.js +156 -0
  41. package/template/claude-task-manager/lib/runtime-lineage.js +189 -0
  42. package/template/claude-task-manager/lib/runtime-registry.js +263 -0
  43. package/template/claude-task-manager/lib/runtime-session-history.js +41 -0
  44. package/template/claude-task-manager/lib/scrollback-snapshot-policy.js +37 -0
  45. package/template/claude-task-manager/lib/server-phase-conditions.js +103 -0
  46. package/template/claude-task-manager/lib/session-content-backfill.js +55 -8
  47. package/template/claude-task-manager/lib/session-db-read-contract.js +67 -0
  48. package/template/claude-task-manager/lib/session-history.js +93 -5
  49. package/template/claude-task-manager/lib/session-host-manager.js +154 -2
  50. package/template/claude-task-manager/lib/session-messages-defer.js +50 -0
  51. package/template/claude-task-manager/lib/session-messages-page.js +13 -0
  52. package/template/claude-task-manager/lib/session-messages-projection.js +48 -29
  53. package/template/claude-task-manager/lib/session-stream.js +80 -17
  54. package/template/claude-task-manager/lib/session-title-signals.js +54 -0
  55. package/template/claude-task-manager/lib/session-token-usage.js +13 -0
  56. package/template/claude-task-manager/lib/state-sync/cell-diff.js +41 -0
  57. package/template/claude-task-manager/lib/state-sync/frame-emitter.js +214 -0
  58. package/template/claude-task-manager/lib/state-sync/frame-rate.js +75 -0
  59. package/template/claude-task-manager/lib/state-sync/row-serializer.js +166 -0
  60. package/template/claude-task-manager/lib/terminal-fingerprint.js +19 -3
  61. package/template/claude-task-manager/lib/transcript-ingest-chunker.js +41 -0
  62. package/template/claude-task-manager/lib/transcript-store.js +99 -7
  63. package/template/claude-task-manager/lib/wal-checkpoint-policy.js +40 -0
  64. package/template/claude-task-manager/lib/walle-session-model-catalog.js +100 -9
  65. package/template/claude-task-manager/lib/worktree-output-binding.js +93 -0
  66. package/template/claude-task-manager/lib/write-coalescer.js +83 -0
  67. package/template/claude-task-manager/public/css/walle-session.css +4 -0
  68. package/template/claude-task-manager/public/css/walle.css +0 -66
  69. package/template/claude-task-manager/public/index.html +1707 -266
  70. package/template/claude-task-manager/public/js/feedback.js +8 -1
  71. package/template/claude-task-manager/public/js/message-renderer.js +72 -2
  72. package/template/claude-task-manager/public/js/session-phase.js +4 -0
  73. package/template/claude-task-manager/public/js/session-status-precedence.js +7 -173
  74. package/template/claude-task-manager/public/js/setup.js +46 -3
  75. package/template/claude-task-manager/public/js/state-sync-client.js +257 -0
  76. package/template/claude-task-manager/public/js/state-sync-predictor.js +41 -0
  77. package/template/claude-task-manager/public/js/stream-view.js +113 -9
  78. package/template/claude-task-manager/public/js/terminal-reconciler.js +24 -4
  79. package/template/claude-task-manager/public/js/walle-session.js +239 -19
  80. package/template/claude-task-manager/public/js/walle.js +32 -119
  81. package/template/claude-task-manager/queue-engine.js +140 -0
  82. package/template/claude-task-manager/server.js +2802 -416
  83. package/template/claude-task-manager/session-integrity.js +16 -1
  84. package/template/claude-task-manager/workers/db-owner-worker.js +23 -6
  85. package/template/claude-task-manager/workers/read-pool-worker.js +55 -1
  86. package/template/claude-task-manager/workers/session-host-pool-process.js +193 -0
  87. package/template/claude-task-manager/workers/session-host-process.js +47 -11
  88. package/template/claude-task-manager/workers/state-detectors/codex.js +33 -0
  89. package/template/package.json +1 -1
  90. package/template/wall-e/agent.js +191 -31
  91. package/template/wall-e/api-walle.js +97 -52
  92. package/template/wall-e/auth/flow-manager.js +78 -1
  93. package/template/wall-e/auth/provider-flows.js +56 -2
  94. package/template/wall-e/bin/walle-mcp-stdio.js +138 -5
  95. package/template/wall-e/brain.js +175 -13
  96. package/template/wall-e/chat.js +46 -1
  97. package/template/wall-e/embeddings.js +70 -0
  98. package/template/wall-e/events/event-bus.js +11 -1
  99. package/template/wall-e/http/auth.js +3 -1
  100. package/template/wall-e/http/model-admin.js +22 -0
  101. package/template/wall-e/lib/brain-owner-worker-client.js +36 -4
  102. package/template/wall-e/lib/diagnostics-flags.js +9 -0
  103. package/template/wall-e/lib/event-loop-monitor.js +84 -5
  104. package/template/wall-e/lib/mcp-scan-lifecycle.js +247 -0
  105. package/template/wall-e/lib/parent-brain-owner-client.js +109 -0
  106. package/template/wall-e/lib/runtime-process-inventory.js +114 -0
  107. package/template/wall-e/lib/runtime-worker-pool.js +214 -23
  108. package/template/wall-e/lib/scheduler-worker-jobs.js +49 -4
  109. package/template/wall-e/lib/scheduler.js +320 -35
  110. package/template/wall-e/lib/slack-identity.js +120 -0
  111. package/template/wall-e/lib/slack-permalink.js +107 -0
  112. package/template/wall-e/lib/slack-web.js +174 -0
  113. package/template/wall-e/lib/worker-thread-pool.js +55 -4
  114. package/template/wall-e/llm/claude-cli.js +21 -3
  115. package/template/wall-e/llm/cli-binary.js +90 -0
  116. package/template/wall-e/llm/codex-cli.js +113 -49
  117. package/template/wall-e/llm/default-fallback.js +10 -4
  118. package/template/wall-e/llm/mlx.js +46 -8
  119. package/template/wall-e/llm/model-catalog.js +129 -17
  120. package/template/wall-e/llm/provider-detector.js +112 -22
  121. package/template/wall-e/loops/backfill.js +32 -16
  122. package/template/wall-e/loops/ingest.js +50 -16
  123. package/template/wall-e/loops/tasks.js +521 -25
  124. package/template/wall-e/mcp-server.js +215 -6
  125. package/template/wall-e/memory/ctm-session-context.js +93 -0
  126. package/template/wall-e/skills/_bundled/google-calendar/run.js +15 -23
  127. package/template/wall-e/skills/_bundled/gws-workspace/gws-router +237 -0
  128. package/template/wall-e/skills/_bundled/gws-workspace/setup.js +112 -1
  129. package/template/wall-e/skills/_bundled/mcp-scan/run.js +265 -41
  130. package/template/wall-e/skills/_bundled/slack-mentions/run.js +434 -93
  131. package/template/wall-e/skills/internal-skill-registry.js +27 -5
  132. package/template/wall-e/skills/mcp-client.js +18 -3
  133. package/template/wall-e/skills/script-skill-runner.js +53 -5
  134. package/template/wall-e/skills/skill-planner.js +5 -26
  135. package/template/wall-e/training/real-trajectory-miner.js +24 -114
  136. package/template/wall-e/utils/dedup.js +165 -66
  137. package/template/wall-e/weather-runtime.js +12 -4
  138. package/template/wall-e/workers/brain-owner-worker.js +68 -0
  139. package/template/wall-e/workers/runtime-worker.js +4 -0
  140. package/template/website/index.html +3 -0
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * desktop-fork.js — server-side orchestrator for converting a read-only Claude Desktop
5
+ * conversation into a resumable Claude Code session (snapshot-and-fork).
6
+ *
7
+ * It is the meat behind POST /api/claude-desktop/convert, factored out of the HTTP handler
8
+ * so it can be unit-tested against a real DB without a live server. It composes the two
9
+ * lower layers:
10
+ * - lib/claude-desktop-sessions.materializeForkTranscript — writes a valid, resumable
11
+ * `.jsonl` from the conversation's cached text (Step 1), and
12
+ * - db.getForkForDesktopUuid / db.upsertAgentSessionIdentity — the dedup link keyed on the
13
+ * originating Desktop uuid (Step 2).
14
+ *
15
+ * Conversion is explicit and one-way: only text carries over (Desktop never recorded tool
16
+ * calls / working dir), and continuing the fork never syncs back to Desktop. Dedup makes it
17
+ * idempotent — the same conversation is never forked twice; a fork whose transcript was
18
+ * deleted self-heals by re-materializing under the same fork id.
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const crypto = require('crypto');
23
+
24
+ const claudeDesktopSessions = require('./claude-desktop-sessions');
25
+
26
+ function _firstUserText(session) {
27
+ const m = (Array.isArray(session.messages) ? session.messages : []).find((x) => x.role === 'user');
28
+ return m ? String(m.text || '').trim().slice(0, 200) : '';
29
+ }
30
+
31
+ function _userMsgCount(session) {
32
+ return (Array.isArray(session.messages) ? session.messages : []).filter((m) => m.role === 'user').length;
33
+ }
34
+
35
+ function convertDesktopConversation(opts = {}) {
36
+ const session = opts.session;
37
+ const cwd = opts.cwd;
38
+ const db = opts.db || require('../db');
39
+ if (!session || !session.uuid) throw new Error('desktop-fork: session with uuid is required');
40
+ if (!cwd) throw new Error('desktop-fork: cwd is required');
41
+
42
+ const desktopUuid = session.uuid;
43
+
44
+ // Idempotency / self-heal: a conversation already linked to a fork is never re-forked. If
45
+ // that fork's transcript is still on disk, return it untouched. If the transcript was
46
+ // deleted, re-materialize under the SAME fork id (the link stays valid).
47
+ const existing = db.getForkForDesktopUuid(desktopUuid);
48
+ if (existing && existing.jsonl_path && fs.existsSync(existing.jsonl_path)) {
49
+ return { forkSessionId: existing.agent_session_id, jsonlPath: existing.jsonl_path, cwd, existing: true };
50
+ }
51
+
52
+ const forkSessionId = (existing && existing.agent_session_id)
53
+ || (typeof opts.forkUuidFn === 'function' ? opts.forkUuidFn() : crypto.randomUUID());
54
+
55
+ const { jsonlPath, lineCount } = claudeDesktopSessions.materializeForkTranscript(session, {
56
+ forkSessionId,
57
+ cwd,
58
+ desktopUuid,
59
+ version: opts.version,
60
+ homeDir: opts.homeDir,
61
+ claudeConfigDir: opts.claudeConfigDir,
62
+ uuidFn: opts.lineUuidFn,
63
+ now: opts.now,
64
+ });
65
+
66
+ // Record (or re-affirm) the dedup link. COALESCE on the upsert preserves any ctm_session_id
67
+ // the importer may have attached if the JsonlWatcher raced us to the new transcript.
68
+ db.upsertAgentSessionIdentity(forkSessionId, {
69
+ provider: 'claude',
70
+ jsonlPath,
71
+ projectPath: cwd,
72
+ model: session.model || '',
73
+ firstMessage: _firstUserText(session),
74
+ userMsgCount: _userMsgCount(session),
75
+ forkedFromDesktopUuid: desktopUuid,
76
+ });
77
+
78
+ return { forkSessionId, jsonlPath, cwd, lineCount, existing: false };
79
+ }
80
+
81
+ module.exports = { convertDesktopConversation };
@@ -35,6 +35,9 @@ const {
35
35
  MAX_SNAPSHOT_SCROLLBACK_ROWS,
36
36
  normalizeSnapshotScrollbackRows,
37
37
  } = require('./terminal-snapshot-options');
38
+ const { createFrameEmitter } = require('./state-sync/frame-emitter');
39
+ const { noteOutput: _stateSyncNoteOutput, isFlooding: _stateSyncIsFlooding, settleDelay: _stateSyncSettleDelay } = require('./state-sync/frame-rate');
40
+ const { serializeRow } = require('./state-sync/row-serializer');
38
41
 
39
42
  let postMessage = () => {};
40
43
 
@@ -53,6 +56,125 @@ const VERIFY_FAIL_COOLDOWN_MS = 30 * 1000;
53
56
  // Per-session headless terminal instances + state
54
57
  const terminals = new Map(); // sessionId -> { term, serialize, state }
55
58
 
59
+ // --- Option B state-sync (DEFAULT-ON; opt out with CTM_STATE_SYNC=0 ⇒ this is entirely inert and the
60
+ // byte-stream path runs unchanged). The worker process inherits CTM_STATE_SYNC from the server's env, so
61
+ // this default MUST match the server's STATE_SYNC_ENABLED default (server.js) — a mismatch desyncs the
62
+ // frame contract between the two processes. ---
63
+ const STATE_SYNC_ENABLED = process.env.CTM_STATE_SYNC !== '0';
64
+ // VIEWPORT-ONLY (mosh model: sync the visible screen, NOT scrollback). The cell-diff positions every
65
+ // row by absolute CUP (\x1b[r;1H), and the client xterm is only viewport-height — so any synced row past
66
+ // the viewport height clamps to the bottom row, smashing scrollback + viewport into garble and misplacing
67
+ // the composer cursor (the real-Claude/Codex "unusable, can't type" bug). The grid MUST equal the viewport.
68
+ // Live client scrollback accumulation is a separate concern (handled by snapshot/restore + the byte path);
69
+ // re-enabling a positive value here re-introduces the clamp garble, so default 0 and treat >0 as experimental.
70
+ const STATE_SYNC_SCROLLBACK_ROWS = Math.max(0, Number(process.env.CTM_STATE_SYNC_SCROLLBACK_ROWS) || 0);
71
+ const STATE_SYNC_FRAME_MIN_MS = Math.max(8, Number(process.env.CTM_STATE_SYNC_FRAME_INTERVAL_MS) || 16);
72
+ const STATE_SYNC_FRAME_MAX_MS = Math.max(STATE_SYNC_FRAME_MIN_MS, Number(process.env.CTM_STATE_SYNC_FRAME_MAX_MS) || 50);
73
+ // Trailing-debounce settle: emit the SETTLED frame this long after the last write goes quiet (interactive
74
+ // path). Small ⇒ a keystroke's multi-read repaint coalesces into ONE frame in ~10-16ms instead of being
75
+ // emitted half-painted then delayed to the slow ceiling. Capped by the min/max interval (see settleDelay).
76
+ const STATE_SYNC_SETTLE_MS = Math.max(0, Number(process.env.CTM_STATE_SYNC_SETTLE_MS) || 8);
77
+ // Background (non-VISIBLE) sessions still ingest every byte into their headless mirrors, but default to
78
+ // ZERO frame extraction. Serializing grids for hidden busy sessions burns the same worker needed for the
79
+ // focused tab's typing echo; nobody can see those pixels. A tab-switch forces a keyframe from the current
80
+ // mirror, so the selected session catches up immediately. Set CTM_STATE_SYNC_BG_MS>0 only for experiments.
81
+ function parseStateSyncBgMs(value) {
82
+ if (value === undefined || value === null || value === '') return 0;
83
+ const n = Number(value);
84
+ if (!Number.isFinite(n) || n <= 0) return 0;
85
+ return Math.max(STATE_SYNC_FRAME_MAX_MS, Math.floor(n));
86
+ }
87
+ const STATE_SYNC_BG_MS = parseStateSyncBgMs(process.env.CTM_STATE_SYNC_BG_MS);
88
+ let _stateSyncVisibleKnown = false; // has the client reported a visible set yet?
89
+ const _stateSyncVisibleSessions = new Set(); // sessionIds the user is actually looking at (fast path)
90
+ const _stateSyncEmitter = STATE_SYNC_ENABLED ? createFrameEmitter({
91
+ send: (id, msg) => { try { postMessage({ ...msg, sessionId: id }); } catch {} }, // sessionId for server routing; id for the client
92
+ extractGrid: (id) => extractGrid(id, { scrollbackRows: STATE_SYNC_SCROLLBACK_ROWS }),
93
+ now: Date.now,
94
+ frameOpts: { minIntervalMs: STATE_SYNC_FRAME_MIN_MS, maxIntervalMs: STATE_SYNC_FRAME_MAX_MS },
95
+ }) : null;
96
+ const _stateSyncFlushTimers = new Map(); // sessionId -> trailing-flush timer
97
+ const _stateSyncFlood = new Map(); // sessionId -> output-rate flood state
98
+ const _stateSyncHiddenDirtySessions = new Set();
99
+
100
+ // Lost-ack watchdog: the emitter's diff base only advances on a client ACK, so a frame whose ACK never
101
+ // reaches the worker would pin the session in-flight forever and freeze its terminal. Periodically sweep
102
+ // for frames stuck in flight past STATE_SYNC_INFLIGHT_STALL_MS and force a fresh keyframe (full repaint).
103
+ const STATE_SYNC_INFLIGHT_STALL_MS = Math.max(
104
+ STATE_SYNC_FRAME_MAX_MS * 4,
105
+ Number(process.env.CTM_STATE_SYNC_INFLIGHT_STALL_MS) || 3000,
106
+ );
107
+ const STATE_SYNC_INFLIGHT_SWEEP_MS = Math.max(250, Math.floor(STATE_SYNC_INFLIGHT_STALL_MS / 3));
108
+ if (_stateSyncEmitter) {
109
+ const _stalledSweep = setInterval(() => {
110
+ try { _stateSyncEmitter.sweepStalledInFlight(Date.now(), { stallMs: STATE_SYNC_INFLIGHT_STALL_MS }); } catch {}
111
+ }, STATE_SYNC_INFLIGHT_SWEEP_MS);
112
+ if (typeof _stalledSweep.unref === 'function') _stalledSweep.unref();
113
+ }
114
+
115
+ function _stateSyncFramesSuppressed(sessionId) {
116
+ return _stateSyncVisibleKnown && !_stateSyncVisibleSessions.has(sessionId) && STATE_SYNC_BG_MS <= 0;
117
+ }
118
+
119
+ function _stateSyncMarkHiddenDirty(sessionId) {
120
+ if (sessionId) _stateSyncHiddenDirtySessions.add(sessionId);
121
+ }
122
+
123
+ // Called after output has been applied to the mirror. We emit on the TRAILING edge of a write burst:
124
+ // a single keystroke makes a TUI repaint that arrives as several PTY reads, so emitting on the FIRST read
125
+ // captures a half-painted screen and the rate floor then delays the read that carries the new char (the
126
+ // measured ~70ms typing lag). Instead we (re)schedule a settle-emit a few ms after writes go quiet —
127
+ // capped by settleDelay's max-wait so continuous output still streams at the ceiling rather than starving.
128
+ // `bypassRate` lets the settled frame through the emitter's floor (this debounce IS the rate limiter).
129
+ function _stateSyncOnMirrorWrite(sessionId, bytes) {
130
+ if (!_stateSyncEmitter) return;
131
+ let flood = _stateSyncFlood.get(sessionId);
132
+ if (!flood) { flood = {}; _stateSyncFlood.set(sessionId, flood); }
133
+ const now = Date.now();
134
+ _stateSyncNoteOutput(flood, bytes, now);
135
+ const flooding = _stateSyncIsFlooding(flood, now, {});
136
+ _stateSyncEmitter.setFlooding(sessionId, flooding);
137
+ if (!flood.firstPendingAt) flood.firstPendingAt = now; // first un-emitted write (max-wait anchor)
138
+ // Not visible (and the client has told us what IS visible) ⇒ no frames by default, so hidden busy
139
+ // sessions can't starve the focused tab's typing. Unknown visibility (pre-handshake) ⇒ treat as visible.
140
+ const background = _stateSyncVisibleKnown && !_stateSyncVisibleSessions.has(sessionId);
141
+ if (_stateSyncFramesSuppressed(sessionId)) {
142
+ _stateSyncMarkHiddenDirty(sessionId);
143
+ clearTimeout(_stateSyncFlushTimers.get(sessionId));
144
+ _stateSyncFlushTimers.delete(sessionId);
145
+ flood.firstPendingAt = 0;
146
+ return;
147
+ }
148
+ const delay = _stateSyncSettleDelay(flood, now, {
149
+ settleMs: STATE_SYNC_SETTLE_MS, minIntervalMs: STATE_SYNC_FRAME_MIN_MS, maxIntervalMs: STATE_SYNC_FRAME_MAX_MS,
150
+ bgIntervalMs: STATE_SYNC_BG_MS, flooding, background,
151
+ });
152
+ if (!Number.isFinite(delay)) {
153
+ _stateSyncMarkHiddenDirty(sessionId);
154
+ clearTimeout(_stateSyncFlushTimers.get(sessionId));
155
+ _stateSyncFlushTimers.delete(sessionId);
156
+ flood.firstPendingAt = 0;
157
+ return;
158
+ }
159
+ clearTimeout(_stateSyncFlushTimers.get(sessionId));
160
+ _stateSyncFlushTimers.set(sessionId, setTimeout(() => {
161
+ _stateSyncFlushTimers.delete(sessionId);
162
+ flood.firstPendingAt = 0; // this emit clears the pending window
163
+ if (terminals.has(sessionId)) {
164
+ _stateSyncEmitter.setFlooding(sessionId, _stateSyncIsFlooding(flood, Date.now(), {}));
165
+ _stateSyncEmitter.onMirrorUpdated(sessionId, { bypassRate: true });
166
+ }
167
+ }, delay));
168
+ }
169
+ function _stateSyncReset(sessionId) {
170
+ if (!_stateSyncEmitter) return;
171
+ clearTimeout(_stateSyncFlushTimers.get(sessionId));
172
+ _stateSyncFlushTimers.delete(sessionId);
173
+ _stateSyncFlood.delete(sessionId);
174
+ _stateSyncHiddenDirtySessions.delete(sessionId);
175
+ _stateSyncEmitter.reset(sessionId);
176
+ }
177
+
56
178
  function getOrCreate(sessionId, cols = 120, rows = 30) {
57
179
  let entry = terminals.get(sessionId);
58
180
  if (!entry) {
@@ -197,6 +319,10 @@ function _approvalOutputHasHint(text) {
197
319
  // 80 rows = ~2 viewports of headroom on a typical 30-row terminal. Wider
198
320
  // than any Codex prompt observed in production while bounding the scan cost.
199
321
  const APPROVAL_SCROLLBACK_ROWS = 80;
322
+ // Bounded window of recent scrollback rows folded into the content fingerprint so an in-place
323
+ // stranded fragment baked into history is detected (the viewport + row count stay unchanged).
324
+ // MUST match the client mirror (public/js/terminal-reconciler.js SCROLLBACK_FP_TAIL_ROWS).
325
+ const SCROLLBACK_FP_TAIL_ROWS = 256;
200
326
  function getVisibleText(term) {
201
327
  const buf = term.buffer.active;
202
328
  const lines = [];
@@ -234,7 +360,16 @@ function viewportFingerprint(term) {
234
360
  rowStrings.push(line ? line.translateToString(true) : '');
235
361
  }
236
362
  const scrollbackLen = Math.max(0, buf.length - rows);
237
- return fingerprintRows(rowStrings, { cols: term.cols, rows, scrollbackLen });
363
+ // Bounded recent-scrollback CONTENT tail (the rows just above the viewport) so an in-place
364
+ // stranded fragment baked into history is detected even when the viewport + row count match.
365
+ // Bounded to keep the cost low; MUST match the client's window (terminal-reconciler.js).
366
+ const tail = [];
367
+ const tailStart = Math.max(0, start - SCROLLBACK_FP_TAIL_ROWS);
368
+ for (let i = tailStart; i < start; i++) {
369
+ const line = buf.getLine(i);
370
+ tail.push(line ? line.translateToString(true) : '');
371
+ }
372
+ return fingerprintRows(rowStrings, { cols: term.cols, rows, scrollbackLen, scrollbackTail: tail });
238
373
  }
239
374
 
240
375
  function getVisibleRows(term) {
@@ -271,10 +406,28 @@ const WRITE_TIMEOUT_MS = 5000;
271
406
  // badge rather than leaving the user guessing why auto-approve is silent.
272
407
  const _writeStallCounts = new Map(); // sessionId -> count
273
408
 
409
+ function _writeHeadlessSync(entry, data) {
410
+ const core = entry && entry.term && entry.term._core;
411
+ if (!core || typeof core.writeSync !== 'function') return false;
412
+ try {
413
+ // @xterm/headless exposes this only on the internal core object. Option B needs the mirror updated
414
+ // before the next 16ms frame deadline; the public async write callback can lag far behind that.
415
+ core.writeSync(data);
416
+ return true;
417
+ } catch {
418
+ return false;
419
+ }
420
+ }
421
+
274
422
  function enqueueWrite(sessionId, data) {
275
423
  const entry = getOrCreate(sessionId);
276
424
  const prev = writeQueues.get(sessionId) || Promise.resolve();
277
425
  const next = prev.then(() => new Promise(resolve => {
426
+ if (STATE_SYNC_ENABLED && _writeHeadlessSync(entry, data)) {
427
+ _stateSyncOnMirrorWrite(sessionId, (data && data.length) || 0);
428
+ resolve();
429
+ return;
430
+ }
278
431
  let settled = false;
279
432
  const timer = setTimeout(() => {
280
433
  if (settled) return;
@@ -291,6 +444,7 @@ function enqueueWrite(sessionId, data) {
291
444
  settled = true;
292
445
  clearTimeout(timer);
293
446
  resolve();
447
+ _stateSyncOnMirrorWrite(sessionId, (data && data.length) || 0); // Option B: emit frame (inert when off)
294
448
  });
295
449
  }));
296
450
  // Periodically reset the promise chain to prevent unbounded growth.
@@ -353,6 +507,13 @@ async function handleMessage(msg) {
353
507
  switch (msg.type) {
354
508
  case 'create': {
355
509
  getOrCreate(msg.sessionId, msg.cols, msg.rows);
510
+ // Option B: seed this session's visibility (sent in the create message because the host wasn't
511
+ // registered yet when the server knew it). undefined ⇒ state-sync off ⇒ leave visibility unknown.
512
+ if (typeof msg.visible === 'boolean') {
513
+ _stateSyncVisibleKnown = true;
514
+ if (msg.visible) _stateSyncVisibleSessions.add(msg.sessionId);
515
+ else _stateSyncVisibleSessions.delete(msg.sessionId);
516
+ }
356
517
  break;
357
518
  }
358
519
 
@@ -507,6 +668,58 @@ async function handleMessage(msg) {
507
668
  case 'resize': {
508
669
  const entry = terminals.get(msg.sessionId);
509
670
  if (entry) entry.term.resize(msg.cols, msg.rows);
671
+ // Option B: dims changed → the grid reflows → force a keyframe at the new dims (inert when off).
672
+ if (_stateSyncEmitter && entry) {
673
+ if (_stateSyncFramesSuppressed(msg.sessionId)) _stateSyncMarkHiddenDirty(msg.sessionId);
674
+ else _stateSyncEmitter.onKeyframeReq(msg.sessionId, {});
675
+ }
676
+ break;
677
+ }
678
+
679
+ // Option B: client confirmed it applied frame `seq` (no-op when state-sync is off).
680
+ case 'ack': {
681
+ if (_stateSyncEmitter) _stateSyncEmitter.onAck(msg.sessionId, Number(msg.seq || 0));
682
+ break;
683
+ }
684
+
685
+ // Option B: client is missing the diff base (desync / cold attach / scroll-up) → send a keyframe.
686
+ // msg.deferIfBlank (restore-hold release): defer the keyframe while the mirror is a blank cleared
687
+ // screen so the client never paints a blank frame with the cursor parked at row 1 mid-resume.
688
+ case 'keyframe-req': {
689
+ if (_stateSyncEmitter) {
690
+ if (_stateSyncFramesSuppressed(msg.sessionId)) _stateSyncMarkHiddenDirty(msg.sessionId);
691
+ else _stateSyncEmitter.onKeyframeReq(msg.sessionId, { deferIfBlank: !!msg.deferIfBlank });
692
+ }
693
+ break;
694
+ }
695
+
696
+ // Option B: the server reports ONE session's visibility (routed by sessionId so it reaches the right
697
+ // session-host process — sessions run in per-session hosts, so a global broadcast wouldn't arrive).
698
+ // Non-visible sessions default to NO frame extraction so a hidden busy session can't starve the
699
+ // focused tab. A session that just BECAME visible gets an immediate keyframe so the switch paints fresh.
700
+ case 'set-visibility': {
701
+ if (!_stateSyncEmitter || !msg.sessionId) break;
702
+ _stateSyncVisibleKnown = true;
703
+ const wasVisible = _stateSyncVisibleSessions.has(msg.sessionId);
704
+ if (msg.visible) {
705
+ _stateSyncVisibleSessions.add(msg.sessionId);
706
+ } else {
707
+ _stateSyncVisibleSessions.delete(msg.sessionId);
708
+ if (_stateSyncFlushTimers.has(msg.sessionId)) {
709
+ clearTimeout(_stateSyncFlushTimers.get(msg.sessionId));
710
+ _stateSyncFlushTimers.delete(msg.sessionId);
711
+ const flood = _stateSyncFlood.get(msg.sessionId);
712
+ if (flood) flood.firstPendingAt = 0;
713
+ _stateSyncMarkHiddenDirty(msg.sessionId);
714
+ }
715
+ }
716
+ const wasHiddenDirty = msg.visible && _stateSyncHiddenDirtySessions.delete(msg.sessionId);
717
+ if (msg.visible && terminals.has(msg.sessionId) && (!wasVisible || wasHiddenDirty)) {
718
+ // deferIfBlank: switching TO a still-resuming session whose mirror is a blank cleared screen
719
+ // (cursor at home) must not paint a blank frame with the caret at row 1 — defer until the
720
+ // redraw lands. A settled session is non-blank, so it keyframes immediately (no behavior change).
721
+ _stateSyncEmitter.onKeyframeReq(msg.sessionId, { deferIfBlank: true });
722
+ }
510
723
  break;
511
724
  }
512
725
 
@@ -619,6 +832,7 @@ async function handleMessage(msg) {
619
832
  if (entry) {
620
833
  entry.term.reset();
621
834
  writeQueues.delete(msg.sessionId);
835
+ _stateSyncReset(msg.sessionId);
622
836
  }
623
837
  break;
624
838
  }
@@ -630,6 +844,7 @@ async function handleMessage(msg) {
630
844
  entry.term.dispose();
631
845
  terminals.delete(msg.sessionId);
632
846
  writeQueues.delete(msg.sessionId);
847
+ _stateSyncReset(msg.sessionId);
633
848
  }
634
849
  break;
635
850
  }
@@ -639,7 +854,7 @@ async function handleMessage(msg) {
639
854
  function dispatchHeadlessMessage(msg) {
640
855
  const sessionId = msg && msg.sessionId;
641
856
  if (!sessionId) {
642
- handleMessage(msg).catch((err) => {
857
+ return handleMessage(msg).catch((err) => {
643
858
  try {
644
859
  postMessage({
645
860
  type: 'worker-error',
@@ -648,9 +863,8 @@ function dispatchHeadlessMessage(msg) {
648
863
  });
649
864
  } catch {}
650
865
  });
651
- return;
652
866
  }
653
- enqueueSessionOp(sessionId, () => handleMessage(msg));
867
+ return enqueueSessionOp(sessionId, () => handleMessage(msg));
654
868
  }
655
869
 
656
870
  function bindHeadlessTerminalService(options = {}) {
@@ -672,7 +886,40 @@ function bindHeadlessTerminalService(options = {}) {
672
886
  };
673
887
  }
674
888
 
889
+ // Extract the live viewport + a bounded recent-scrollback window as a row-serialized grid for
890
+ // state-sync (Option B). Each row is SELF-CONTAINED ANSI — SerializeAddon's per-row `range` resets its
891
+ // own SGR — so the cell-diff can rewrite any single row independently. Absolute buffer indexing keyed
892
+ // off buf.baseY (the LIVE viewport top; NOT viewportY — client-side scroll is local and must not move
893
+ // the synced region). Returns { cols, rows, rowAnsi: string[], baseRow }.
894
+ function extractGrid(sessionId, opts) {
895
+ const entry = terminals.get(sessionId);
896
+ if (!entry || !entry.term) return { cols: 0, rows: 0, rowAnsi: [], baseRow: 0 };
897
+ const term = entry.term;
898
+ const buf = term.buffer.active;
899
+ const length = Number(buf.length || 0);
900
+ const viewportRows = Number(term.rows || 0);
901
+ if (!length || !viewportRows) return { cols: Number(term.cols || 0), rows: 0, rowAnsi: [], baseRow: 0 };
902
+ const scrollbackRows = Math.max(0, Number(opts && opts.scrollbackRows) || 0);
903
+ const baseY = Math.max(0, Number(buf.baseY || 0));
904
+ const start = Math.max(0, baseY - scrollbackRows);
905
+ const end = Math.min(length - 1, baseY + viewportRows - 1);
906
+ const rowAnsi = [];
907
+ for (let r = start; r <= end; r++) {
908
+ rowAnsi.push(serializeRow(buf.getLine(r), term.cols));
909
+ }
910
+ // Cursor position relative to the grid (1-based), so the client can place the TUI composer cursor.
911
+ const cursorAbs = baseY + Number(buf.cursorY || 0);
912
+ const cursor = { row: Math.max(1, (cursorAbs - start) + 1), col: Math.max(1, Number(buf.cursorX || 0) + 1) };
913
+ return { cols: Number(term.cols || 0), rows: rowAnsi.length, rowAnsi, baseRow: start, cursor };
914
+ }
915
+
675
916
  module.exports = {
676
917
  bindHeadlessTerminalService,
677
918
  handleMessage: dispatchHeadlessMessage,
919
+ viewportFingerprint, // exported for fingerprint unit tests (scrollback-fingerprint.test.js)
920
+ extractGrid,
921
+ getOrCreate, // exported for state-sync unit tests
922
+ STATE_SYNC_SCROLLBACK_ROWS, // exported so the regression test can pin the live window to viewport-only
923
+ STATE_SYNC_BG_MS,
924
+ parseStateSyncBgMs,
678
925
  };
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+
3
+ // Canonical, stable per-message identity for the conversation log.
4
+ //
5
+ // The server merge (conversation-tail-merge.js) already collapses the two
6
+ // delivery-path copies of one turn (durable rows + live stream tail) into a
7
+ // single message, using a 3-tier heuristic (parentUuid / timestamp / content).
8
+ // This module produces ONE deterministic `id` for that final message so the
9
+ // CLIENT can dedup and apply append/replace-by-id instead of re-deriving the
10
+ // same heuristics a second time (_parentUuidSeen / _messageFingerprintSeen).
11
+ //
12
+ // Design rules:
13
+ // - The id must stay STABLE while a streaming turn's text grows (so the client
14
+ // replaces in place) — hence it keys off uuid/parentUuid, never the live text,
15
+ // when those are present.
16
+ // - The id must MATCH across the row-store copy and the stream-ring copy of one
17
+ // turn — hence text is dedup-normalized (image tokens stripped) before hashing,
18
+ // mirroring conversation-tail-merge.dedupText.
19
+ // - tool_call and tool_result share a callId but are distinct messages — hence
20
+ // the structured kind is part of the id.
21
+
22
+ const crypto = require('crypto');
23
+ const { timestampValue } = require('./conversation-tail-merge');
24
+
25
+ // `[Image #N]` placeholder tokens — mirror conversation-tail-merge.dedupText so a
26
+ // turn's two copies (richer row-store label vs raw stream-ring prompt) hash alike.
27
+ const IMAGE_TOKEN_RE = /\[Image #\d+\]/gi;
28
+
29
+ // The weak tier hashes only this many chars of the normalized text. It MUST stay
30
+ // below the live stream's text cap (session-stream.js MAX_TEXT_LEN = 50KB): a live
31
+ // event truncates long prompts to 50KB + `...truncated(...)` while the durable row
32
+ // keeps the full text, so hashing only a sub-50KB prefix makes the two copies of a
33
+ // giant prompt converge to one id. 32KB is well under 50KB and above any realistic
34
+ // prompt, so for normal messages the cap is a no-op (id unchanged).
35
+ const WEAK_HASH_TEXT_CAP = 32 * 1024;
36
+
37
+ function dedupText(text) {
38
+ return String(text || '')
39
+ .replace(IMAGE_TOKEN_RE, ' ')
40
+ .replace(/\s+/g, ' ')
41
+ .trim();
42
+ }
43
+
44
+ function textHash(text) {
45
+ return crypto.createHash('sha1').update(String(text || '')).digest('hex').slice(0, 16);
46
+ }
47
+
48
+ // Text from either a durable message ({text}) or a live stream event ({data:{text}}).
49
+ function messageText(message) {
50
+ return String(
51
+ message?.text ?? message?.content ?? message?.message ?? message?.data?.text ?? ''
52
+ ).trim();
53
+ }
54
+
55
+ // Derive the canonical id for a single message. Works on BOTH shapes — a durable
56
+ // message ({role, parentUuid, text}) and a live stream event ({role, data:{parentUuid,
57
+ // text}}) — so the same turn hashes to one id across the durable and live paths.
58
+ // Returns '' for a message with no role or no text (callers stamp a positional fallback).
59
+ function messageStableId(message) {
60
+ const role = String(message?.role || '').trim();
61
+ const text = messageText(message);
62
+ if (!role || !text) return '';
63
+
64
+ const kind = String(message?.metadata?.kind || message?.data?.metadata?.kind || '').trim();
65
+ if (kind) {
66
+ // Structured rows never carry uuid/parentUuid (structured-capture contract).
67
+ // A callId pairs a tool_call with its tool_result but does not identify the
68
+ // message — the kind does. Without a callId, fall back to the row's content.
69
+ const callId = String(message?.metadata?.callId || message?.data?.metadata?.callId || '').trim();
70
+ return `s:${kind}:${callId || textHash(dedupText(text))}`;
71
+ }
72
+
73
+ // parentUuid FIRST (before uuid): it is the only identity key the live stream
74
+ // event carries (data.parentUuid) AND the durable row keeps, so it is the one
75
+ // that survives the live↔durable boundary. The row store does not persist the
76
+ // message's own uuid, so preferring uuid here would split a turn's two copies.
77
+ const parentUuid = String(
78
+ message?.parentUuid || message?.data?.parentUuid || message?.metadata?.parentUuid || ''
79
+ ).trim();
80
+ if (parentUuid) return `p:${role}:${parentUuid}`;
81
+
82
+ const uuid = String(message?.uuid || '').trim();
83
+ if (uuid) return `u:${role}:${uuid}`;
84
+
85
+ // No stable provider id (Codex / Wall-E / resume-rewritten). Key off the
86
+ // normalized text + normalized timestamp — the same signals the merge's weak
87
+ // tiers use, so two copies that survived the merge hash identically here.
88
+ const ts = timestampValue(message?.timestamp);
89
+ const norm = dedupText(text);
90
+ const capped = norm.length > WEAK_HASH_TEXT_CAP ? norm.slice(0, WEAK_HASH_TEXT_CAP) : norm;
91
+ return `w:${role}:${ts}:${textHash(capped)}`;
92
+ }
93
+
94
+ // Stamp a unique, deterministic `id` on every message in a list. Two messages
95
+ // that derive the same base id (e.g. two identical structured rows at the same
96
+ // timestamp) are disambiguated by appended occurrence count, stable by order.
97
+ // Returns NEW message objects; the input list is not mutated.
98
+ function assignStableIds(messages) {
99
+ const list = Array.isArray(messages) ? messages : [];
100
+ const counts = new Map();
101
+ return list.map((message, index) => {
102
+ let base = messageStableId(message);
103
+ if (!base) base = `x:${index}`; // positional fallback for role/text-less rows
104
+ const n = counts.get(base) || 0;
105
+ counts.set(base, n + 1);
106
+ const id = n === 0 ? base : `${base}#${n}`;
107
+ return { ...message, id };
108
+ });
109
+ }
110
+
111
+ module.exports = {
112
+ messageStableId,
113
+ assignStableIds,
114
+ dedupText,
115
+ };
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+ // Pure guards for the server-side headless-mirror feed + snapshot paths. Extracted so the byte-drop
3
+ // fixes are unit-testable (server.js itself isn't requireable in tests). See the 285f93c8 torn-Codex
4
+ // investigation: bytes were lost feeding the mirror under load, and a partial mirror was serialized
5
+ // over the good scrollback file so it re-served torn forever.
6
+
7
+ // The per-session feed flush (`_flushHeadlessFeed`) must consult these guards BEFORE consuming its
8
+ // pending buffer. A miss means "can't deliver to this (legacy) mirror right now" — the caller keeps
9
+ // the buffer instead of the old clear-then-return that silently dropped a 16ms batch whenever a
10
+ // resume replaced the session object (`sameSession` false) or session-host ownership flipped.
11
+ function headlessFeedShouldDeliver({ hasSession, sameSession, hostOwnsLiveHeadless } = {}) {
12
+ return !!hasSession && !!sameSession && !hostOwnsLiveHeadless;
13
+ }
14
+
15
+ // A session scrollback snapshot may be persisted to its file ONLY when the mirror is fully populated.
16
+ // On restore the mirror is re-fed in chunks (status 'queued' → 'hydrating' → 'ready'); a periodic or
17
+ // shutdown snapshot firing mid-hydration serializes a PARTIAL frame and overwrites the good file,
18
+ // which then re-serves torn on every subsequent restore. A never-restored live session has no
19
+ // hydration status (undefined) and is always snapshot-able. A failed/aborted hydration
20
+ // ('missing'/'timeout') left a partial mirror too, so it must not be persisted either.
21
+ function snapshotHydrationReady(status) {
22
+ return !status || status === 'ready';
23
+ }
24
+
25
+ module.exports = { headlessFeedShouldDeliver, snapshotHydrationReady };
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ // Option B (state-sync) scroll-up fidelity — hosted mirror feed sanitizer.
4
+ //
5
+ // Under state-sync the client renders VIEWPORT-ONLY frames; its scrollback comes solely from
6
+ // the byte-path snapshot, which is serialized from the session's headless MIRROR. If that mirror
7
+ // honors an erase-saved-lines (ESC[3J) that the agent emits on resume/redraw, the mirror's seeded
8
+ // scrollback is wiped, every snapshot becomes viewport-only, and the user cannot scroll up.
9
+ //
10
+ // The primary process already strips ESC[3J for non-Codex agents before feeding ITS in-process
11
+ // headless worker (server.js `shouldStripEraseScrollback`, kept because "older shell/Claude replay
12
+ // paths rely on scrollback not being wiped"). But hosted PTYs (the default) feed their EMBEDDED
13
+ // mirror directly in the session-host process, raw — so that protection never reached them. This
14
+ // helper ports the exact rule to the hosted feed: strip ESC[3J for everyone except Codex, which
15
+ // deliberately pairs ESC[2J+ESC[3J and carries its own rollout-history scrollback.
16
+ //
17
+ // Only the live PTY->mirror feed is sanitized; the raw byte stream sent to the client is unchanged,
18
+ // and the restore seed (initialHeadlessData) is never passed through here.
19
+
20
+ const { detectAgentType } = require('./agent-capabilities');
21
+
22
+ const ERASE_SCROLLBACK = '\x1b[3J';
23
+ const ERASE_SCROLLBACK_RE = /\x1b\[3J/g;
24
+
25
+ // Codex is the lone agent that wants ESC[3J honored by the mirror; everyone else (Claude, shells,
26
+ // gemini, etc.) relies on scrollback NOT being wiped. Null/unknown cmd defaults to stripping.
27
+ function shouldStripEraseScrollbackForMirror(cmd) {
28
+ return detectAgentType(cmd || '') !== 'codex';
29
+ }
30
+
31
+ // Pure: returns `data` with ESC[3J removed when the agent is non-Codex, else `data` unchanged.
32
+ // Non-string input and ESC[3J-free input are returned as-is (fast path).
33
+ function sanitizeHostedMirrorFeed(data, cmd) {
34
+ if (!data || typeof data !== 'string') return data;
35
+ if (!shouldStripEraseScrollbackForMirror(cmd)) return data;
36
+ if (data.indexOf(ERASE_SCROLLBACK) < 0) return data;
37
+ return data.replace(ERASE_SCROLLBACK_RE, '');
38
+ }
39
+
40
+ module.exports = {
41
+ sanitizeHostedMirrorFeed,
42
+ shouldStripEraseScrollbackForMirror,
43
+ ERASE_SCROLLBACK,
44
+ ERASE_SCROLLBACK_RE,
45
+ };