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.
- package/README.md +2 -2
- package/bin/create-walle.js +166 -6
- package/package.json +1 -1
- package/template/bin/ctm-launch.sh +70 -18
- package/template/bin/dev.sh +18 -0
- package/template/bin/ensure-stable-node.js +11 -0
- package/template/bin/node-bin.sh +9 -0
- package/template/claude-task-manager/api-prompts.js +214 -23
- package/template/claude-task-manager/db.js +884 -50
- package/template/claude-task-manager/docs/backfill-incremental-no-main-fallback.md +48 -0
- package/template/claude-task-manager/docs/conversation-import-freshness.md +21 -0
- package/template/claude-task-manager/docs/conversation-log-redesign.html +587 -0
- package/template/claude-task-manager/docs/session-title-authority.md +8 -3
- package/template/claude-task-manager/lib/auth-rules.js +13 -0
- package/template/claude-task-manager/lib/claude-desktop-sessions.js +63 -0
- package/template/claude-task-manager/lib/codex-config-guard.js +124 -0
- package/template/claude-task-manager/lib/codex-rollout-snapshot.js +93 -0
- package/template/claude-task-manager/lib/coding-agent-models.js +5 -4
- package/template/claude-task-manager/lib/db-owner-cooperative-scheduler.js +114 -0
- package/template/claude-task-manager/lib/db-owner-task-queue.js +67 -0
- package/template/claude-task-manager/lib/db-owner-worker-client.js +5 -1
- package/template/claude-task-manager/lib/desktop-fork.js +81 -0
- package/template/claude-task-manager/lib/headless-term-service.js +251 -4
- package/template/claude-task-manager/lib/message-identity.js +115 -0
- package/template/claude-task-manager/lib/mirror-feed-guards.js +25 -0
- package/template/claude-task-manager/lib/mirror-feed-sanitize.js +45 -0
- package/template/claude-task-manager/lib/path-suggest.js +77 -0
- package/template/claude-task-manager/lib/prompt-index-inputs.js +136 -0
- package/template/claude-task-manager/lib/real-node.js +36 -4
- package/template/claude-task-manager/lib/restore-auto-resume-policy.js +67 -0
- package/template/claude-task-manager/lib/restore-resume-batch.js +20 -0
- package/template/claude-task-manager/lib/restore-terminal-dims.js +109 -0
- package/template/claude-task-manager/lib/resume-cwd.js +124 -3
- package/template/claude-task-manager/lib/runtime-approval-recorder.js +152 -0
- package/template/claude-task-manager/lib/runtime-context-truth.js +236 -0
- package/template/claude-task-manager/lib/runtime-contract.js +195 -0
- package/template/claude-task-manager/lib/runtime-history-builder.js +205 -0
- package/template/claude-task-manager/lib/runtime-hook-bus.js +98 -0
- package/template/claude-task-manager/lib/runtime-input-queue.js +114 -0
- package/template/claude-task-manager/lib/runtime-input-recorder.js +156 -0
- package/template/claude-task-manager/lib/runtime-lineage.js +189 -0
- package/template/claude-task-manager/lib/runtime-registry.js +263 -0
- package/template/claude-task-manager/lib/runtime-session-history.js +41 -0
- package/template/claude-task-manager/lib/scrollback-snapshot-policy.js +37 -0
- package/template/claude-task-manager/lib/server-phase-conditions.js +103 -0
- package/template/claude-task-manager/lib/session-content-backfill.js +55 -8
- package/template/claude-task-manager/lib/session-db-read-contract.js +67 -0
- package/template/claude-task-manager/lib/session-history.js +93 -5
- package/template/claude-task-manager/lib/session-host-manager.js +154 -2
- package/template/claude-task-manager/lib/session-messages-defer.js +50 -0
- package/template/claude-task-manager/lib/session-messages-page.js +13 -0
- package/template/claude-task-manager/lib/session-messages-projection.js +48 -29
- package/template/claude-task-manager/lib/session-stream.js +80 -17
- package/template/claude-task-manager/lib/session-title-signals.js +54 -0
- package/template/claude-task-manager/lib/session-token-usage.js +13 -0
- package/template/claude-task-manager/lib/state-sync/cell-diff.js +41 -0
- package/template/claude-task-manager/lib/state-sync/frame-emitter.js +214 -0
- package/template/claude-task-manager/lib/state-sync/frame-rate.js +75 -0
- package/template/claude-task-manager/lib/state-sync/row-serializer.js +166 -0
- package/template/claude-task-manager/lib/terminal-fingerprint.js +19 -3
- package/template/claude-task-manager/lib/transcript-ingest-chunker.js +41 -0
- package/template/claude-task-manager/lib/transcript-store.js +99 -7
- package/template/claude-task-manager/lib/wal-checkpoint-policy.js +40 -0
- package/template/claude-task-manager/lib/walle-session-model-catalog.js +100 -9
- package/template/claude-task-manager/lib/worktree-output-binding.js +93 -0
- package/template/claude-task-manager/lib/write-coalescer.js +83 -0
- package/template/claude-task-manager/public/css/walle-session.css +4 -0
- package/template/claude-task-manager/public/css/walle.css +0 -66
- package/template/claude-task-manager/public/index.html +1707 -266
- package/template/claude-task-manager/public/js/feedback.js +8 -1
- package/template/claude-task-manager/public/js/message-renderer.js +72 -2
- package/template/claude-task-manager/public/js/session-phase.js +4 -0
- package/template/claude-task-manager/public/js/session-status-precedence.js +7 -173
- package/template/claude-task-manager/public/js/setup.js +46 -3
- package/template/claude-task-manager/public/js/state-sync-client.js +257 -0
- package/template/claude-task-manager/public/js/state-sync-predictor.js +41 -0
- package/template/claude-task-manager/public/js/stream-view.js +113 -9
- package/template/claude-task-manager/public/js/terminal-reconciler.js +24 -4
- package/template/claude-task-manager/public/js/walle-session.js +239 -19
- package/template/claude-task-manager/public/js/walle.js +32 -119
- package/template/claude-task-manager/queue-engine.js +140 -0
- package/template/claude-task-manager/server.js +2802 -416
- package/template/claude-task-manager/session-integrity.js +16 -1
- package/template/claude-task-manager/workers/db-owner-worker.js +23 -6
- package/template/claude-task-manager/workers/read-pool-worker.js +55 -1
- package/template/claude-task-manager/workers/session-host-pool-process.js +193 -0
- package/template/claude-task-manager/workers/session-host-process.js +47 -11
- package/template/claude-task-manager/workers/state-detectors/codex.js +33 -0
- package/template/package.json +1 -1
- package/template/wall-e/agent.js +191 -31
- package/template/wall-e/api-walle.js +97 -52
- package/template/wall-e/auth/flow-manager.js +78 -1
- package/template/wall-e/auth/provider-flows.js +56 -2
- package/template/wall-e/bin/walle-mcp-stdio.js +138 -5
- package/template/wall-e/brain.js +175 -13
- package/template/wall-e/chat.js +46 -1
- package/template/wall-e/embeddings.js +70 -0
- package/template/wall-e/events/event-bus.js +11 -1
- package/template/wall-e/http/auth.js +3 -1
- package/template/wall-e/http/model-admin.js +22 -0
- package/template/wall-e/lib/brain-owner-worker-client.js +36 -4
- package/template/wall-e/lib/diagnostics-flags.js +9 -0
- package/template/wall-e/lib/event-loop-monitor.js +84 -5
- package/template/wall-e/lib/mcp-scan-lifecycle.js +247 -0
- package/template/wall-e/lib/parent-brain-owner-client.js +109 -0
- package/template/wall-e/lib/runtime-process-inventory.js +114 -0
- package/template/wall-e/lib/runtime-worker-pool.js +214 -23
- package/template/wall-e/lib/scheduler-worker-jobs.js +49 -4
- package/template/wall-e/lib/scheduler.js +320 -35
- package/template/wall-e/lib/slack-identity.js +120 -0
- package/template/wall-e/lib/slack-permalink.js +107 -0
- package/template/wall-e/lib/slack-web.js +174 -0
- package/template/wall-e/lib/worker-thread-pool.js +55 -4
- package/template/wall-e/llm/claude-cli.js +21 -3
- package/template/wall-e/llm/cli-binary.js +90 -0
- package/template/wall-e/llm/codex-cli.js +113 -49
- package/template/wall-e/llm/default-fallback.js +10 -4
- package/template/wall-e/llm/mlx.js +46 -8
- package/template/wall-e/llm/model-catalog.js +129 -17
- package/template/wall-e/llm/provider-detector.js +112 -22
- package/template/wall-e/loops/backfill.js +32 -16
- package/template/wall-e/loops/ingest.js +50 -16
- package/template/wall-e/loops/tasks.js +521 -25
- package/template/wall-e/mcp-server.js +215 -6
- package/template/wall-e/memory/ctm-session-context.js +93 -0
- package/template/wall-e/skills/_bundled/google-calendar/run.js +15 -23
- package/template/wall-e/skills/_bundled/gws-workspace/gws-router +237 -0
- package/template/wall-e/skills/_bundled/gws-workspace/setup.js +112 -1
- package/template/wall-e/skills/_bundled/mcp-scan/run.js +265 -41
- package/template/wall-e/skills/_bundled/slack-mentions/run.js +434 -93
- package/template/wall-e/skills/internal-skill-registry.js +27 -5
- package/template/wall-e/skills/mcp-client.js +18 -3
- package/template/wall-e/skills/script-skill-runner.js +53 -5
- package/template/wall-e/skills/skill-planner.js +5 -26
- package/template/wall-e/training/real-trajectory-miner.js +24 -114
- package/template/wall-e/utils/dedup.js +165 -66
- package/template/wall-e/weather-runtime.js +12 -4
- package/template/wall-e/workers/brain-owner-worker.js +68 -0
- package/template/wall-e/workers/runtime-worker.js +4 -0
- 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
|
-
|
|
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
|
+
};
|