create-walle 0.9.29 → 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/package.json +1 -1
- package/template/bin/ctm-launch.sh +66 -21
- package/template/bin/dev.sh +13 -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 +182 -8
- package/template/claude-task-manager/db.js +168 -13
- package/template/claude-task-manager/docs/session-title-authority.md +8 -3
- 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 +42 -2
- 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 +7 -2
- package/template/claude-task-manager/lib/mirror-feed-sanitize.js +45 -0
- package/template/claude-task-manager/lib/runtime-context-truth.js +16 -6
- package/template/claude-task-manager/lib/scrollback-snapshot-policy.js +37 -0
- package/template/claude-task-manager/lib/session-history.js +88 -4
- package/template/claude-task-manager/lib/session-messages-page.js +13 -0
- package/template/claude-task-manager/lib/session-messages-projection.js +11 -27
- package/template/claude-task-manager/lib/session-stream.js +61 -16
- 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/frame-emitter.js +43 -2
- package/template/claude-task-manager/lib/transcript-ingest-chunker.js +41 -0
- package/template/claude-task-manager/lib/transcript-store.js +12 -1
- package/template/claude-task-manager/lib/walle-session-model-catalog.js +100 -9
- 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 +766 -89
- package/template/claude-task-manager/public/js/state-sync-client.js +40 -1
- package/template/claude-task-manager/public/js/walle-session.js +211 -19
- package/template/claude-task-manager/public/js/walle.js +6 -110
- package/template/claude-task-manager/server.js +564 -90
- package/template/claude-task-manager/workers/db-owner-worker.js +15 -6
- package/template/claude-task-manager/workers/read-pool-worker.js +37 -0
- package/template/claude-task-manager/workers/session-host-pool-process.js +6 -1
- package/template/claude-task-manager/workers/session-host-process.js +6 -1
- package/template/claude-task-manager/workers/state-detectors/codex.js +33 -0
- package/template/package.json +1 -1
- package/template/wall-e/agent.js +78 -16
- package/template/wall-e/api-walle.js +24 -43
- package/template/wall-e/bin/walle-mcp-stdio.js +138 -5
- package/template/wall-e/brain.js +122 -1
- package/template/wall-e/chat.js +46 -1
- package/template/wall-e/http/model-admin.js +22 -0
- package/template/wall-e/lib/brain-owner-worker-client.js +20 -0
- package/template/wall-e/lib/parent-brain-owner-client.js +109 -0
- package/template/wall-e/lib/runtime-worker-pool.js +15 -1
- package/template/wall-e/lib/scheduler-worker-jobs.js +30 -1
- package/template/wall-e/lib/scheduler.js +71 -2
- 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 +49 -0
- package/template/wall-e/llm/cli-binary.js +17 -4
- package/template/wall-e/llm/codex-cli.js +105 -60
- package/template/wall-e/llm/model-catalog.js +129 -17
- package/template/wall-e/loops/backfill.js +32 -16
- package/template/wall-e/loops/ingest.js +50 -16
- package/template/wall-e/mcp-server.js +215 -6
- package/template/wall-e/skills/_bundled/gws-workspace/gws-router +61 -4
- package/template/wall-e/skills/_bundled/slack-mentions/run.js +167 -52
- package/template/wall-e/skills/skill-planner.js +5 -26
- 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 +60 -0
- package/template/wall-e/workers/runtime-worker.js +4 -0
|
@@ -813,6 +813,10 @@ function runtimeKernelSchemaSql() {
|
|
|
813
813
|
ON ctm_runtime_events(ctm_session_id, turn_id, created_at_ms, id);
|
|
814
814
|
CREATE INDEX IF NOT EXISTS idx_ctm_runtime_events_type
|
|
815
815
|
ON ctm_runtime_events(event_type, created_at_ms);
|
|
816
|
+
-- Serves "latest event of a type for a session" (latestContextTruth's per-session token/compact
|
|
817
|
+
-- lookup) as an O(1) index seek instead of fetching+parsing the oldest 1000 events per poll.
|
|
818
|
+
CREATE INDEX IF NOT EXISTS idx_ctm_runtime_events_session_type
|
|
819
|
+
ON ctm_runtime_events(ctm_session_id, event_type, id);
|
|
816
820
|
|
|
817
821
|
CREATE TABLE IF NOT EXISTS ctm_runtime_turns (
|
|
818
822
|
ctm_session_id TEXT NOT NULL,
|
|
@@ -2505,6 +2509,34 @@ function migrateSchemaIfNeeded() {
|
|
|
2505
2509
|
// Non-fatal: dropping a dormant cache table; the app runs fine if it lingers.
|
|
2506
2510
|
}
|
|
2507
2511
|
}
|
|
2512
|
+
if (getSchemaVersion() < 11) {
|
|
2513
|
+
try {
|
|
2514
|
+
migrateToV11();
|
|
2515
|
+
} catch (e) {
|
|
2516
|
+
console.error('[db] Schema migration to v11 FAILED:', e.message);
|
|
2517
|
+
console.error('[db] Stack:', e.stack);
|
|
2518
|
+
// Non-fatal: the column only enables Claude Desktop → Code fork dedup; absent it,
|
|
2519
|
+
// getForkForDesktopUuid simply returns null and conversion stays available.
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
/**
|
|
2525
|
+
* Schema v11: link a converted Claude Desktop conversation to its resumable Claude Code
|
|
2526
|
+
* fork. A read-only Desktop conversation can be snapshot-and-forked into a real Code session
|
|
2527
|
+
* (see lib/claude-desktop-sessions.js materializeForkTranscript); we record the originating
|
|
2528
|
+
* Desktop uuid on the fork's agent_sessions row so the same conversation is never offered for
|
|
2529
|
+
* conversion twice — the sidebar shows "Resume fork" instead. Idempotent via PRAGMA pre-check.
|
|
2530
|
+
*/
|
|
2531
|
+
function migrateToV11() {
|
|
2532
|
+
const d = getDb();
|
|
2533
|
+
const cols = d.prepare('PRAGMA table_info(agent_sessions)').all();
|
|
2534
|
+
if (!cols.find((c) => c.name === 'forked_from_desktop_uuid')) {
|
|
2535
|
+
d.prepare("ALTER TABLE agent_sessions ADD COLUMN forked_from_desktop_uuid TEXT DEFAULT ''").run();
|
|
2536
|
+
}
|
|
2537
|
+
d.exec("CREATE INDEX IF NOT EXISTS idx_agent_forked_desktop ON agent_sessions(forked_from_desktop_uuid) WHERE forked_from_desktop_uuid != ''");
|
|
2538
|
+
setSchemaVersion(11);
|
|
2539
|
+
console.log('[db] Schema migrated to v11 (Claude Desktop fork linkage)');
|
|
2508
2540
|
}
|
|
2509
2541
|
|
|
2510
2542
|
/**
|
|
@@ -3168,6 +3200,7 @@ function createV1Tables() {
|
|
|
3168
3200
|
parent_agent_session_id TEXT DEFAULT '',
|
|
3169
3201
|
agent_nickname TEXT DEFAULT '',
|
|
3170
3202
|
agent_role TEXT DEFAULT '',
|
|
3203
|
+
forked_from_desktop_uuid TEXT DEFAULT '',
|
|
3171
3204
|
created_at TEXT DEFAULT (datetime('now')),
|
|
3172
3205
|
updated_at TEXT DEFAULT (datetime('now')),
|
|
3173
3206
|
FOREIGN KEY (ctm_session_id) REFERENCES ctm_sessions(id) ON DELETE CASCADE
|
|
@@ -3189,6 +3222,7 @@ function createV1Tables() {
|
|
|
3189
3222
|
d.exec('CREATE INDEX IF NOT EXISTS idx_agent_slug ON agent_sessions(slug)');
|
|
3190
3223
|
d.exec('CREATE INDEX IF NOT EXISTS idx_agent_parent_session ON agent_sessions(parent_agent_session_id)');
|
|
3191
3224
|
d.exec('CREATE INDEX IF NOT EXISTS idx_agent_thread_source ON agent_sessions(thread_source)');
|
|
3225
|
+
d.exec("CREATE INDEX IF NOT EXISTS idx_agent_forked_desktop ON agent_sessions(forked_from_desktop_uuid) WHERE forked_from_desktop_uuid != ''");
|
|
3192
3226
|
}
|
|
3193
3227
|
|
|
3194
3228
|
// --- Settings CRUD ---
|
|
@@ -3596,6 +3630,32 @@ function listRuntimeEvents(ctmSessionId, { sinceId = 0, turnId = '', limit = 100
|
|
|
3596
3630
|
return rows.map(_parseRuntimeEvent).filter(Boolean);
|
|
3597
3631
|
}
|
|
3598
3632
|
|
|
3633
|
+
// Latest event whose event_type is one of `eventTypes` for a session — the highest id wins.
|
|
3634
|
+
// Backs latestContextTruth, which previously fetched + JSON-parsed the oldest 1000 events per
|
|
3635
|
+
// session per poll just to find the latest token_usage + latest compact (a 3.98s/4min on-main cost
|
|
3636
|
+
// in the live CPU profile, and wrong once a session exceeds 1000 events). One indexed DESC LIMIT 1
|
|
3637
|
+
// seek per type (idx_ctm_runtime_events_session_type) → O(1), parses at most one row per type.
|
|
3638
|
+
function latestRuntimeEventOfTypes(ctmSessionId, eventTypes, { turnId = '' } = {}) {
|
|
3639
|
+
const cleanSessionId = String(ctmSessionId || '').trim();
|
|
3640
|
+
if (!cleanSessionId || !Array.isArray(eventTypes) || eventTypes.length === 0) return null;
|
|
3641
|
+
const cleanTurnId = String(turnId || '').trim();
|
|
3642
|
+
const d = getDb();
|
|
3643
|
+
let best = null;
|
|
3644
|
+
for (const t of eventTypes) {
|
|
3645
|
+
const et = String(t || '').trim();
|
|
3646
|
+
if (!et) continue;
|
|
3647
|
+
const row = cleanTurnId
|
|
3648
|
+
? d.prepare(
|
|
3649
|
+
'SELECT * FROM ctm_runtime_events WHERE ctm_session_id = ? AND turn_id = ? AND event_type = ? ORDER BY id DESC LIMIT 1'
|
|
3650
|
+
).get(cleanSessionId, cleanTurnId, et)
|
|
3651
|
+
: d.prepare(
|
|
3652
|
+
'SELECT * FROM ctm_runtime_events WHERE ctm_session_id = ? AND event_type = ? ORDER BY id DESC LIMIT 1'
|
|
3653
|
+
).get(cleanSessionId, et);
|
|
3654
|
+
if (row && (!best || Number(row.id) > Number(best.id))) best = row;
|
|
3655
|
+
}
|
|
3656
|
+
return best ? _parseRuntimeEvent(best) : null;
|
|
3657
|
+
}
|
|
3658
|
+
|
|
3599
3659
|
function _parseRuntimeTurn(row) {
|
|
3600
3660
|
if (!row) return null;
|
|
3601
3661
|
return {
|
|
@@ -4983,6 +5043,10 @@ function importSessionConversation({
|
|
|
4983
5043
|
// render shape differs from `messages` (Wall-E persists review-shaped rows, not the raw transcript
|
|
4984
5044
|
// blob shape). The metadata upsert + blob still happen so the freshness gate + legacy reads work.
|
|
4985
5045
|
skipMessageRows,
|
|
5046
|
+
// When true, write the real blob even under blob retirement so the session renders from the blob
|
|
5047
|
+
// while content-rows-backfill fills rows across slices (chunked/cold-import route). Over-cap still
|
|
5048
|
+
// wins — a multi-GB stringify is never safe regardless of this flag.
|
|
5049
|
+
forceBlobWrite,
|
|
4986
5050
|
}) {
|
|
4987
5051
|
// Attribution: the whole import is one synchronous span — a JSON.stringify of
|
|
4988
5052
|
// the full message array (multi-MB for 2000+ prompt sessions) + an upsert + a
|
|
@@ -5010,7 +5074,10 @@ function importSessionConversation({
|
|
|
5010
5074
|
// the default), otherwise nulling the blob would blind every read; and the row
|
|
5011
5075
|
// write below restores the real blob if it fails, so a session is never empty.
|
|
5012
5076
|
const _retireBlob = _blobRetirementActive();
|
|
5013
|
-
|
|
5077
|
+
// forceBlobWrite (cold/large chunked-import route) writes the real blob even under retirement so
|
|
5078
|
+
// the session renders from blob while content-rows-backfill fills rows across slices. Over-cap
|
|
5079
|
+
// still wins (a multi-GB stringify is never safe).
|
|
5080
|
+
const _skipBlobWrite = _overParseCap || (_retireBlob && !forceBlobWrite);
|
|
5014
5081
|
if (_overParseCap && !_overCapImportLogged.has(session_id)) {
|
|
5015
5082
|
_overCapImportLogged.add(session_id);
|
|
5016
5083
|
console.warn(`[db] session_conversations import over parse cap (file_size=${file_size}) for ${String(session_id || '').slice(0, 8)} — storing empty blob, skipping full stringify/row-rewrite.`);
|
|
@@ -5032,7 +5099,8 @@ function importSessionConversation({
|
|
|
5032
5099
|
const _prevEst = _importTokenEstimateLen.get(session_id);
|
|
5033
5100
|
const _modelChanged = !_prevEst || _prevEst.model !== (model_id || '');
|
|
5034
5101
|
const _grewEnough = !_prevEst || _reestimateDelta === 0 || (_msgLen - _prevEst.len) >= _reestimateDelta;
|
|
5035
|
-
|
|
5102
|
+
const _chunkedRoute = !!forceBlobWrite && !!skipMessageRows && !_overParseCap;
|
|
5103
|
+
if (!_overParseCap && !_chunkedRoute && (_modelChanged || _grewEnough)) {
|
|
5036
5104
|
try {
|
|
5037
5105
|
const summary = require('./lib/session-token-usage').estimateFromMessages(messages || [], model_id || '');
|
|
5038
5106
|
if (summary && summary.total > 0) {
|
|
@@ -5087,6 +5155,14 @@ function importSessionConversation({
|
|
|
5087
5155
|
_tokTotal, _tokCtx, _tokWindow, _tokExact, _tokBreakdown
|
|
5088
5156
|
);
|
|
5089
5157
|
|
|
5158
|
+
// Chunked-import route: rows are written later by content-rows-backfill (in windows). Stamp the
|
|
5159
|
+
// render-gate HWM now so findUnbackfilledSessions (extracted_source_len>0 AND rows<len) adopts
|
|
5160
|
+
// this session; appendSessionMessageRowsChunk re-stamps the same value idempotently on completion.
|
|
5161
|
+
if (_chunkedRoute) {
|
|
5162
|
+
try { _setExtractedSourceLen(getDb(), session_id, _msgLen, _lastMessageAt); }
|
|
5163
|
+
catch (e) { console.error('[db] chunked-import source-len stamp failed:', e.message); }
|
|
5164
|
+
}
|
|
5165
|
+
|
|
5090
5166
|
// Keep message-level search/review surfaces in lockstep with the durable
|
|
5091
5167
|
// conversation cache. The older one-shot backfill path intentionally skips
|
|
5092
5168
|
// sessions that already have rows, which made long-running imported Codex
|
|
@@ -7886,6 +7962,9 @@ function replaceSessionMessages(sessionId, messages) {
|
|
|
7886
7962
|
// transcript timestamp is available, advance last_message_at without allowing
|
|
7887
7963
|
// partial rewrites or compacted sources to move activity backward.
|
|
7888
7964
|
function _setExtractedSourceLen(d, ctmSessionId, sourceLen, lastMessageAt = '') {
|
|
7965
|
+
// Any row write reaches here to advance the watermark — drop the cached rows-available verdict so
|
|
7966
|
+
// sessionContentRowsAvailable re-COUNTs once against the new state (covers same-watermark mutations).
|
|
7967
|
+
_invalidateRowsAvailCache(ctmSessionId);
|
|
7889
7968
|
try {
|
|
7890
7969
|
d.prepare('UPDATE session_conversations SET extracted_source_len = ? WHERE ctm_session_id = ?')
|
|
7891
7970
|
.run(Number(sourceLen) || 0, ctmSessionId);
|
|
@@ -8221,8 +8300,10 @@ function appendSessionConversation({
|
|
|
8221
8300
|
function getSessionMessagesArray(sessionId, { fallbackToBlob = true } = {}) {
|
|
8222
8301
|
const d = getDb();
|
|
8223
8302
|
if (_tableExists('session_message_rows')) {
|
|
8224
|
-
|
|
8225
|
-
|
|
8303
|
+
// Delegate the SELECT + row→object map to the shared lib so this row read is byte-identical
|
|
8304
|
+
// to the read-pool worker's off-thread getSessionMessagesArray op (parity by construction).
|
|
8305
|
+
const rows = require('./lib/session-messages-page').getMessagesArray(d, { sessionId });
|
|
8306
|
+
if (rows.length) return rows;
|
|
8226
8307
|
}
|
|
8227
8308
|
if (fallbackToBlob) {
|
|
8228
8309
|
try {
|
|
@@ -8236,16 +8317,40 @@ function getSessionMessagesArray(sessionId, { fallbackToBlob = true } = {}) {
|
|
|
8236
8317
|
// True only when the faithful rows can serve this session: present AND fully backfilled
|
|
8237
8318
|
// (row count covers the conversation's extracted length). A half-migrated session returns
|
|
8238
8319
|
// false → the caller uses the complete blob/JSONL path.
|
|
8320
|
+
// Watermark-keyed cache of the "rows fully cover the conversation" verdict. sessionContentRowsAvailable
|
|
8321
|
+
// is on MANY hot/periodic main-loop paths — most heavily the per-codex-session snapshot serialize
|
|
8322
|
+
// (_serializeSessionSnapshot → _codexRolloutHistoryActive → _codexRowLookupId), plus the
|
|
8323
|
+
// apiSessionMessages gate, attach, and exit — and each call ran an O(rows) COUNT(*) that cold-page
|
|
8324
|
+
// faults for seconds (the `(unknown)` cpu-low apiSessionMessages/snapshot freeze on the primary).
|
|
8325
|
+
// The gate is `cnt >= expected` where expected = extracted_source_len (the import high-water mark).
|
|
8326
|
+
// Rows only grow and `expected` advances only as import progresses, so a TRUE verdict stays true
|
|
8327
|
+
// while `expected` is unchanged. Cache ONLY true verdicts keyed on `expected`: a key match means
|
|
8328
|
+
// cnt was already ≥ this watermark and (rows-only-grow) still is — skip the COUNT(*). A false/
|
|
8329
|
+
// half-migrated verdict is NOT cached (it must be re-checked so a just-completed migration is
|
|
8330
|
+
// picked up). Row writes invalidate the entry (belt-and-suspenders for a same-watermark row
|
|
8331
|
+
// mutation). CTM_ROWS_AVAIL_CACHE=0 disables.
|
|
8332
|
+
const _rowsAvailCache = new Map(); // ctm_session_id -> expected (watermark at which it was TRUE)
|
|
8333
|
+
function _invalidateRowsAvailCache(sessionId) { _rowsAvailCache.delete(String(sessionId || '')); }
|
|
8239
8334
|
function sessionContentRowsAvailable(sessionId) {
|
|
8240
8335
|
try {
|
|
8241
8336
|
const d = getDb();
|
|
8242
8337
|
if (!_tableExists('session_message_rows')) return false;
|
|
8243
|
-
|
|
8244
|
-
|
|
8338
|
+
// Cheap watermark read first (single non-blob column); also lets us skip the COUNT entirely for
|
|
8339
|
+
// sessions with no HWM (always false under the old logic too).
|
|
8245
8340
|
const conv = d.prepare('SELECT extracted_source_len FROM session_conversations WHERE ctm_session_id = ?').get(sessionId);
|
|
8246
8341
|
const expected = conv ? Number(conv.extracted_source_len) : 0;
|
|
8247
|
-
if (!(expected > 0)
|
|
8248
|
-
|
|
8342
|
+
if (!(expected > 0)) { _invalidateRowsAvailCache(sessionId); return false; } // unknown HWM → blob path
|
|
8343
|
+
const cacheOn = process.env.CTM_ROWS_AVAIL_CACHE !== '0';
|
|
8344
|
+
if (cacheOn && _rowsAvailCache.get(sessionId) === expected) return true; // TRUE@expected still holds (rows only grow)
|
|
8345
|
+
const cnt = Number(d.prepare('SELECT COUNT(*) AS n FROM session_message_rows WHERE ctm_session_id = ?').get(sessionId).n);
|
|
8346
|
+
const available = cnt > 0 && cnt >= expected; // unknown HWM or half-migrated → blob path
|
|
8347
|
+
if (available && cacheOn) {
|
|
8348
|
+
_rowsAvailCache.set(sessionId, expected);
|
|
8349
|
+
if (_rowsAvailCache.size > 1024) { const k = _rowsAvailCache.keys().next().value; _rowsAvailCache.delete(k); }
|
|
8350
|
+
} else {
|
|
8351
|
+
_invalidateRowsAvailCache(sessionId);
|
|
8352
|
+
}
|
|
8353
|
+
return available;
|
|
8249
8354
|
} catch { return false; }
|
|
8250
8355
|
}
|
|
8251
8356
|
|
|
@@ -8403,9 +8508,28 @@ function _messageFreshnessStatsForId(id, { userOnly = false } = {}) {
|
|
|
8403
8508
|
const d = getDb();
|
|
8404
8509
|
const bindId = String(id);
|
|
8405
8510
|
const whereRole = userOnly ? " AND role = 'user'" : '';
|
|
8406
|
-
|
|
8511
|
+
// The all-message session_message_rows store is DENSE — every message is a row at its array index
|
|
8512
|
+
// (replaceSessionMessageRows / appendSessionMessageRowsChunk write message_index = i contiguously
|
|
8513
|
+
// from 0, an invariant the row write paths already rely on), so COUNT(*) === MAX(message_index)+1.
|
|
8514
|
+
// The per-poll COUNT(*) over a giant (14k-19k-row) session was a 5.25s/4min on-main cost in the
|
|
8515
|
+
// live CPU profile; the query already computes MAX (an O(1) index seek), so deriving rows from it
|
|
8516
|
+
// drops the only O(rows) term with NO schema or write-path change — strictly better than a
|
|
8517
|
+
// maintained counter (no cross-thread drift). Equivalent as a change-detector: maxIndex moves on
|
|
8518
|
+
// every append, and the store is append-only / full-replace (no mid-array deletes), so maxIndex+1
|
|
8519
|
+
// changes exactly when the message set does. User-only rows are SPARSE (no density) and the legacy
|
|
8520
|
+
// session_messages store isn't guaranteed dense, so both keep the exact COUNT(*).
|
|
8521
|
+
const countStats = (table) => d.prepare(
|
|
8407
8522
|
`SELECT COALESCE(MAX(message_index), -1) AS maxIndex, COUNT(*) AS rows FROM ${table} WHERE ctm_session_id = ?${whereRole}`
|
|
8408
8523
|
).get(bindId);
|
|
8524
|
+
const denseStats = (table) => {
|
|
8525
|
+
const r = d.prepare(
|
|
8526
|
+
`SELECT COALESCE(MAX(message_index), -1) AS maxIndex FROM ${table} WHERE ctm_session_id = ?`
|
|
8527
|
+
).get(bindId);
|
|
8528
|
+
const maxIndex = Number(r && r.maxIndex != null ? r.maxIndex : -1);
|
|
8529
|
+
return { maxIndex, rows: maxIndex + 1 };
|
|
8530
|
+
};
|
|
8531
|
+
const readStats = (table) =>
|
|
8532
|
+
(!userOnly && table === 'session_message_rows') ? denseStats(table) : countStats(table);
|
|
8409
8533
|
|
|
8410
8534
|
// The row store is the default read source, but migration and tests can leave
|
|
8411
8535
|
// some sessions present only in the legacy filtered index. Pick per session
|
|
@@ -9383,14 +9507,15 @@ function upsertAgentSessionIdentity(agentSessionId, data = {}) {
|
|
|
9383
9507
|
parent_agent_session_id: lineage.parent_agent_session_id,
|
|
9384
9508
|
agent_nickname: lineage.agent_nickname,
|
|
9385
9509
|
agent_role: lineage.agent_role,
|
|
9510
|
+
forked_from_desktop_uuid: data.forkedFromDesktopUuid || data.forked_from_desktop_uuid || '',
|
|
9386
9511
|
};
|
|
9387
9512
|
d.prepare(`
|
|
9388
9513
|
INSERT INTO agent_sessions (agent_session_id, ctm_session_id, provider, provider_resume_id, project_path, jsonl_path,
|
|
9389
9514
|
first_message, file_size, modified_at, hostname, model, git_branch, user_msg_count, slug,
|
|
9390
|
-
thread_source, parent_agent_session_id, agent_nickname, agent_role)
|
|
9515
|
+
thread_source, parent_agent_session_id, agent_nickname, agent_role, forked_from_desktop_uuid)
|
|
9391
9516
|
VALUES (@agent_session_id, @ctm_session_id, @provider, @provider_resume_id, @project_path, @jsonl_path,
|
|
9392
9517
|
@first_message, @file_size, @modified_at, @hostname, @model, @git_branch, @user_msg_count, @slug,
|
|
9393
|
-
@thread_source, @parent_agent_session_id, @agent_nickname, @agent_role)
|
|
9518
|
+
@thread_source, @parent_agent_session_id, @agent_nickname, @agent_role, @forked_from_desktop_uuid)
|
|
9394
9519
|
ON CONFLICT(agent_session_id) DO UPDATE SET
|
|
9395
9520
|
ctm_session_id = COALESCE(agent_sessions.ctm_session_id, excluded.ctm_session_id),
|
|
9396
9521
|
provider = COALESCE(NULLIF(excluded.provider, ''), agent_sessions.provider),
|
|
@@ -9409,10 +9534,40 @@ function upsertAgentSessionIdentity(agentSessionId, data = {}) {
|
|
|
9409
9534
|
parent_agent_session_id = COALESCE(NULLIF(excluded.parent_agent_session_id, ''), agent_sessions.parent_agent_session_id),
|
|
9410
9535
|
agent_nickname = COALESCE(NULLIF(excluded.agent_nickname, ''), agent_sessions.agent_nickname),
|
|
9411
9536
|
agent_role = COALESCE(NULLIF(excluded.agent_role, ''), agent_sessions.agent_role),
|
|
9537
|
+
forked_from_desktop_uuid = COALESCE(NULLIF(excluded.forked_from_desktop_uuid, ''), agent_sessions.forked_from_desktop_uuid),
|
|
9412
9538
|
updated_at = datetime('now')
|
|
9413
9539
|
`).run(params);
|
|
9414
9540
|
}
|
|
9415
9541
|
|
|
9542
|
+
/**
|
|
9543
|
+
* Look up the Claude Code fork that was created from a given Claude Desktop conversation.
|
|
9544
|
+
* Returns the fork's agent_sessions row ({ agent_session_id, jsonl_path, ctm_session_id })
|
|
9545
|
+
* or null when the conversation has never been converted. Callers verify the fork still
|
|
9546
|
+
* exists (row + jsonl file) before treating the conversation as already-converted; a deleted
|
|
9547
|
+
* fork should re-offer conversion. Anchored on the stable Desktop uuid so a re-scan of the
|
|
9548
|
+
* Desktop cache never mints a duplicate fork.
|
|
9549
|
+
*/
|
|
9550
|
+
function getForkForDesktopUuid(desktopUuid) {
|
|
9551
|
+
const id = String(desktopUuid || '').trim();
|
|
9552
|
+
if (!id) return null;
|
|
9553
|
+
return getDb().prepare(
|
|
9554
|
+
'SELECT agent_session_id, jsonl_path, ctm_session_id FROM agent_sessions WHERE forked_from_desktop_uuid = ? LIMIT 1'
|
|
9555
|
+
).get(id) || null;
|
|
9556
|
+
}
|
|
9557
|
+
|
|
9558
|
+
/**
|
|
9559
|
+
* List every Claude Code fork created from a Claude Desktop conversation, keyed by the
|
|
9560
|
+
* originating Desktop uuid. The client fetches this to decide, per Desktop conversation,
|
|
9561
|
+
* whether to offer "Convert" or "Resume fork". Callers verify the transcript still exists
|
|
9562
|
+
* before trusting the link (a deleted fork should re-offer conversion).
|
|
9563
|
+
*/
|
|
9564
|
+
function listDesktopForks() {
|
|
9565
|
+
return getDb().prepare(
|
|
9566
|
+
"SELECT forked_from_desktop_uuid AS desktopUuid, agent_session_id AS forkSessionId, jsonl_path AS jsonlPath, project_path AS projectPath " +
|
|
9567
|
+
"FROM agent_sessions WHERE forked_from_desktop_uuid != ''"
|
|
9568
|
+
).all();
|
|
9569
|
+
}
|
|
9570
|
+
|
|
9416
9571
|
function setSessionStar(id, starred) {
|
|
9417
9572
|
// Try ctm_sessions first
|
|
9418
9573
|
const result = getDb().prepare("UPDATE ctm_sessions SET starred = ?, updated_at = datetime('now') WHERE id = ?")
|
|
@@ -10249,7 +10404,7 @@ module.exports = {
|
|
|
10249
10404
|
getStorageRisk,
|
|
10250
10405
|
getSqliteDriverStatus,
|
|
10251
10406
|
ensureRuntimeKernelTables,
|
|
10252
|
-
appendRuntimeEvent, listRuntimeEvents,
|
|
10407
|
+
appendRuntimeEvent, listRuntimeEvents, latestRuntimeEventOfTypes,
|
|
10253
10408
|
upsertRuntimeTurn, getRuntimeTurn, listRuntimeTurns,
|
|
10254
10409
|
upsertRuntimeInputEnvelope, updateRuntimeInputEnvelope, getRuntimeInputEnvelope, listRuntimeInputEnvelopes,
|
|
10255
10410
|
upsertRuntimeRouteSnapshot, getLatestRuntimeRouteSnapshot,
|
|
@@ -10318,7 +10473,7 @@ module.exports = {
|
|
|
10318
10473
|
setSessionTitleNew, setSessionTitleStatusNew, getSessionTitleNew, getSessionTitlesByIds, getSessionDisplayTitleInfo, isUserSessionTitleCandidate, promoteStartupTaskTitleIfUserNamed, repairSessionTitlesFromStartupTasks, getAllSessionsData,
|
|
10319
10474
|
repairCodexSessionMetadataFromConversations, repairCodexRolloutAgentIdentities, detachPromotedProviderChildSessions,
|
|
10320
10475
|
listProviderChildAgentOwnerMappings,
|
|
10321
|
-
getAgentSessions, getAgentSession, deleteCtmSession,
|
|
10476
|
+
getAgentSessions, getAgentSession, getForkForDesktopUuid, listDesktopForks, deleteCtmSession,
|
|
10322
10477
|
// Schema version
|
|
10323
10478
|
getSchemaVersion,
|
|
10324
10479
|
};
|
|
@@ -8,8 +8,10 @@ CTM has one product-facing session name: `title`.
|
|
|
8
8
|
|
|
9
9
|
1. Explicit user title wins.
|
|
10
10
|
2. A user-looking active `startup_tasks.label` is promoted into `ctm_sessions.title` with `user_renamed=1`.
|
|
11
|
-
3.
|
|
12
|
-
4.
|
|
11
|
+
3. Provider-native session titles may fill `ctm_sessions.title` only when no user title exists. Examples: Claude Code `summary` records and Codex `ai-title` records.
|
|
12
|
+
4. CTM AI-generated titles may fill `ctm_sessions.title` only when no user/provider title exists.
|
|
13
|
+
5. Runtime generated titles such as `Codex: ~/repo`, `Claude Code: ~/repo`, `Shell: /path`, branch/worktree leaf names, and `Wall-E session` are not user titles.
|
|
14
|
+
6. First prompts are display fallbacks, not titles. They belong in `agent_sessions.first_message` / conversation cache fields and must not be written to `ctm_sessions.title` unless the user explicitly renamed the session to that text.
|
|
13
15
|
|
|
14
16
|
## Why
|
|
15
17
|
|
|
@@ -26,7 +28,10 @@ The fix is architectural: promote the user runtime title into the durable title
|
|
|
26
28
|
|
|
27
29
|
- Session create/rename should send `title`; `label` may be included only as a compatibility mirror.
|
|
28
30
|
- Rename writes must update both `ctm_sessions.title` and `startup_tasks.label`.
|
|
29
|
-
- Non-user title writers must call through `setSessionTitleNew`; it protects active user startup titles before accepting
|
|
31
|
+
- Non-user title writers must call through `setSessionTitleNew`; it protects active user startup titles before accepting a provider/AI title.
|
|
32
|
+
- Provider-native imports write `title_source='provider_title'`, `title_status='imported'`, and the raw provider title. Live UI chrome can add branding such as `Codex:`, but durable `ctm_sessions.title` stores only the product-facing session name.
|
|
33
|
+
- Codex `first_user_message` and rollout user events are never title authority. Only explicit title records (`ai-title`, `custom-title`) or trusted provider state titles tied to the same canonical rollout id can become titles.
|
|
34
|
+
- Claude Code `summary` records are provider-native titles. They can fill an unnamed session title, but they must not overwrite `user_renamed=1`.
|
|
30
35
|
- Browser active-session chrome should prefer `meta.title`/user-looking runtime title before recent-session AI fields.
|
|
31
36
|
- Branch-only runtime labels are not user titles unless `userRenamed=1` is explicit. A worktree branch named `evaluation` should render as `Codex session` with an `evaluation` branch badge, not as a user title.
|
|
32
37
|
- New uses of `startup_tasks.label` should be commented as legacy schema compatibility.
|
|
@@ -4,6 +4,8 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const zlib = require('zlib');
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
const resumeCwd = require('./resume-cwd');
|
|
7
9
|
|
|
8
10
|
const DESKTOP_PROJECT_ENTRY = 'claude-desktop';
|
|
9
11
|
const DESKTOP_PROJECT_PATH = 'Claude Desktop';
|
|
@@ -213,6 +215,66 @@ function toClaudeCodeEntries(sessionOrId, options = {}) {
|
|
|
213
215
|
}));
|
|
214
216
|
}
|
|
215
217
|
|
|
218
|
+
// Snapshot-and-fork: turn a read-only Claude Desktop conversation (cached text only) into a
|
|
219
|
+
// valid, resumable Claude Code `.jsonl` so it can be continued in a terminal. Only text
|
|
220
|
+
// carries over — Desktop never recorded tool calls, thinking, working dir, or usage. The
|
|
221
|
+
// file is written into claudeProjectDirForCwd(cwd) so `path.dirname` matches the launch cwd
|
|
222
|
+
// (Claude Code's resume contract; see lib/resume-preflight.js), with an unbroken parentUuid
|
|
223
|
+
// chain and a first-line sentinel that records the originating Desktop uuid for dedup/recovery.
|
|
224
|
+
function materializeForkTranscript(session, opts = {}) {
|
|
225
|
+
const forkSessionId = opts.forkSessionId;
|
|
226
|
+
if (!forkSessionId) throw new Error('claude-desktop: forkSessionId is required');
|
|
227
|
+
if (!opts.cwd) throw new Error('claude-desktop: cwd is required');
|
|
228
|
+
|
|
229
|
+
const usable = (Array.isArray(session && session.messages) ? session.messages : [])
|
|
230
|
+
.filter(m => (m.role === 'user' || m.role === 'assistant') && String(m.text || '').trim());
|
|
231
|
+
if (usable.length === 0) {
|
|
232
|
+
throw new Error('claude-desktop: no usable cached messages to materialize');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const projectOpts = { homeDir: opts.homeDir, claudeConfigDir: opts.claudeConfigDir };
|
|
236
|
+
const dir = resumeCwd.claudeProjectDirForCwd(opts.cwd, projectOpts);
|
|
237
|
+
const jsonlPath = path.join(dir, `${forkSessionId}.jsonl`);
|
|
238
|
+
if (fs.existsSync(jsonlPath)) {
|
|
239
|
+
throw new Error(`claude-desktop: transcript already exists at ${jsonlPath}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const cwd = resumeCwd.canonicalizeClaudeProjectPath(opts.cwd, projectOpts);
|
|
243
|
+
const version = opts.version || '';
|
|
244
|
+
const nextUuid = typeof opts.uuidFn === 'function' ? opts.uuidFn : () => crypto.randomUUID();
|
|
245
|
+
const fallbackTs = opts.now || new Date().toISOString();
|
|
246
|
+
|
|
247
|
+
let parentUuid = null;
|
|
248
|
+
const lines = usable.map((msg, index) => {
|
|
249
|
+
const uuid = nextUuid();
|
|
250
|
+
const role = msg.role === 'assistant' ? 'assistant' : 'user';
|
|
251
|
+
const text = String(msg.text || '');
|
|
252
|
+
const line = {
|
|
253
|
+
parentUuid,
|
|
254
|
+
isSidechain: false,
|
|
255
|
+
userType: 'external',
|
|
256
|
+
cwd,
|
|
257
|
+
sessionId: forkSessionId,
|
|
258
|
+
version,
|
|
259
|
+
gitBranch: '',
|
|
260
|
+
type: role,
|
|
261
|
+
message: {
|
|
262
|
+
role,
|
|
263
|
+
content: role === 'assistant' ? [{ type: 'text', text }] : text,
|
|
264
|
+
},
|
|
265
|
+
uuid,
|
|
266
|
+
timestamp: msg.timestamp || fallbackTs,
|
|
267
|
+
};
|
|
268
|
+
if (index === 0 && opts.desktopUuid) line._ctmForkedFromDesktop = opts.desktopUuid;
|
|
269
|
+
parentUuid = uuid;
|
|
270
|
+
return JSON.stringify(line);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
274
|
+
fs.writeFileSync(jsonlPath, lines.join('\n') + '\n');
|
|
275
|
+
return { jsonlPath, sessionId: forkSessionId, lineCount: lines.length };
|
|
276
|
+
}
|
|
277
|
+
|
|
216
278
|
function readReactQueryCacheTexts(appDir) {
|
|
217
279
|
const levelDir = path.join(appDir, 'Local Storage', 'leveldb');
|
|
218
280
|
let files;
|
|
@@ -690,6 +752,7 @@ module.exports = {
|
|
|
690
752
|
parseSessionFile,
|
|
691
753
|
getMessages,
|
|
692
754
|
toClaudeCodeEntries,
|
|
755
|
+
materializeForkTranscript,
|
|
693
756
|
virtualSessionPath,
|
|
694
757
|
parseVirtualSessionPath,
|
|
695
758
|
isVirtualSessionPath,
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Codex MCP transport guard.
|
|
4
|
+
//
|
|
5
|
+
// The Codex desktop app periodically regenerates ~/.codex/config.toml and sometimes writes
|
|
6
|
+
// app-only MCP servers (e.g. `codex_apps`, `xcodebuildmcp`) as TABLES WITH NO TRANSPORT — only
|
|
7
|
+
// `startup_timeout_sec`, no `command` (stdio) and no `url` (http). The desktop app wires their
|
|
8
|
+
// transport up internally, but the standalone `codex` CLI that CTM spawns to resume a session
|
|
9
|
+
// validates transports at config-load time and ABORTS on the first offender:
|
|
10
|
+
//
|
|
11
|
+
// Error: failed to load configuration
|
|
12
|
+
// Caused by: invalid transport in `mcp_servers.codex_apps`
|
|
13
|
+
//
|
|
14
|
+
// Every Codex session then fails to resume, surfacing that error in the tab. `enabled = false`
|
|
15
|
+
// does NOT help — the CLI validates the transport BEFORE it honors `enabled`. The only thing that
|
|
16
|
+
// loads is supplying a transport. So this guard hands each transport-less server an INERT, DISABLED
|
|
17
|
+
// stdio transport via `-c` overrides: `command="/usr/bin/true"` satisfies the validator and
|
|
18
|
+
// `enabled=false` guarantees the CLI never actually spawns it. The override leaves ~/.codex
|
|
19
|
+
// untouched (no fighting the desktop app over a shared, Dropbox-synced file) and is a no-op on a
|
|
20
|
+
// healthy config (no transport-less servers → no args).
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
|
|
25
|
+
const INERT_COMMAND = '/usr/bin/true';
|
|
26
|
+
|
|
27
|
+
// Split a TOML table header's inner text on top-level dots (dots inside double quotes are literal).
|
|
28
|
+
function _splitDotted(inside) {
|
|
29
|
+
const segs = [];
|
|
30
|
+
let cur = '';
|
|
31
|
+
let inQ = false;
|
|
32
|
+
for (let i = 0; i < inside.length; i++) {
|
|
33
|
+
const ch = inside[i];
|
|
34
|
+
if (ch === '"') { inQ = !inQ; cur += ch; continue; }
|
|
35
|
+
if (ch === '.' && !inQ) { segs.push(cur); cur = ''; continue; }
|
|
36
|
+
cur += ch;
|
|
37
|
+
}
|
|
38
|
+
segs.push(cur);
|
|
39
|
+
return segs.map(s => s.trim());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function _unquote(seg) {
|
|
43
|
+
const t = String(seg || '').trim();
|
|
44
|
+
if (t.length >= 2 && t[0] === '"' && t[t.length - 1] === '"') return t.slice(1, -1);
|
|
45
|
+
return t;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Parse a line as a TOML table header `[ ... ]`, returning its dotted segments, else null.
|
|
49
|
+
function _tableHeaderSegments(line) {
|
|
50
|
+
const m = String(line).match(/^\s*\[([^\]]+)\]\s*(#.*)?$/);
|
|
51
|
+
if (!m) return null;
|
|
52
|
+
return _splitDotted(m[1]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Find every `[mcp_servers.<name>]` table (exactly two segments — sub-tables like
|
|
56
|
+
// `[mcp_servers.<name>.env]` have three and are ignored) whose own body declares neither
|
|
57
|
+
// `command` nor `url`. Returns the unquoted server names, in file order, de-duplicated.
|
|
58
|
+
function findTransportlessMcpServers(configText) {
|
|
59
|
+
const lines = String(configText || '').split(/\r?\n/);
|
|
60
|
+
const out = [];
|
|
61
|
+
let cur = null; // { name, hasTransport } for the server table we're currently inside
|
|
62
|
+
|
|
63
|
+
const finalize = () => {
|
|
64
|
+
if (cur && !cur.hasTransport) out.push(cur.name);
|
|
65
|
+
cur = null;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
const segs = _tableHeaderSegments(line);
|
|
70
|
+
if (segs) {
|
|
71
|
+
finalize(); // a new header ends the previous server table's body
|
|
72
|
+
if (segs.length === 2 && _unquote(segs[0]) === 'mcp_servers') {
|
|
73
|
+
cur = { name: _unquote(segs[1]), hasTransport: false };
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (cur && !cur.hasTransport) {
|
|
78
|
+
const km = line.match(/^\s*("[^"]*"|[A-Za-z0-9_-]+)\s*=/);
|
|
79
|
+
if (km) {
|
|
80
|
+
const key = _unquote(km[1]);
|
|
81
|
+
if (key === 'command' || key === 'url') cur.hasTransport = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
finalize();
|
|
86
|
+
|
|
87
|
+
return [...new Set(out)];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A TOML `-c` key segment is left bare when it's a valid bare key; otherwise double-quoted so the
|
|
91
|
+
// dotted override path (mcp_servers.<seg>.command) keeps parsing as three segments.
|
|
92
|
+
function _keySegment(name) {
|
|
93
|
+
return /^[A-Za-z0-9_-]+$/.test(name) ? name : `"${name}"`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Build `codex -c ...` override args that neutralize each transport-less server.
|
|
97
|
+
function buildCodexTransportOverrideArgs(names, opts = {}) {
|
|
98
|
+
const inert = opts.command || INERT_COMMAND;
|
|
99
|
+
const out = [];
|
|
100
|
+
for (const name of names || []) {
|
|
101
|
+
const seg = _keySegment(name);
|
|
102
|
+
out.push('-c', `mcp_servers.${seg}.command="${inert}"`);
|
|
103
|
+
out.push('-c', `mcp_servers.${seg}.enabled=false`);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Read CODEX_HOME/config.toml and return the override args (best-effort: unreadable/missing → []).
|
|
109
|
+
function codexMcpTransportGuardArgs(codexHome, opts = {}) {
|
|
110
|
+
let text;
|
|
111
|
+
try {
|
|
112
|
+
text = fs.readFileSync(path.join(codexHome || '', 'config.toml'), 'utf8');
|
|
113
|
+
} catch {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
return buildCodexTransportOverrideArgs(findTransportlessMcpServers(text), opts);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = {
|
|
120
|
+
INERT_COMMAND,
|
|
121
|
+
findTransportlessMcpServers,
|
|
122
|
+
buildCodexTransportOverrideArgs,
|
|
123
|
+
codexMcpTransportGuardArgs,
|
|
124
|
+
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { looksLikeCodexTranscriptPagerSnapshot } = require('./codex-transcript-pager');
|
|
4
|
+
|
|
3
5
|
// Codex rollout-backed snapshot composition (the "seam").
|
|
4
6
|
//
|
|
5
7
|
// A Codex Term snapshot becomes: [synthesized settled history] + [live region]
|
|
@@ -161,6 +163,11 @@ function composeCodexHistorySnapshot(input = {}) {
|
|
|
161
163
|
const historyAnsi = String(input.historyAnsi || '');
|
|
162
164
|
const viewportAnsi = String(input.viewportAnsi || '');
|
|
163
165
|
const opts = input.opts || {};
|
|
166
|
+
// durable: this compose feeds a snapshot PERSISTED to file for cold resume, not a
|
|
167
|
+
// live send to a connected client. The interactive transcript pager is kept for
|
|
168
|
+
// live sends (so the user sees the screen they're driving) but must be dropped for
|
|
169
|
+
// durable persistence — else a cold resume restores into a dead pager forever.
|
|
170
|
+
const durable = !!input.durable;
|
|
164
171
|
|
|
165
172
|
if (!historyAnsi) {
|
|
166
173
|
return { data: viewportAnsi, trimmed: 0, composed: false };
|
|
@@ -173,9 +180,25 @@ function composeCodexHistorySnapshot(input = {}) {
|
|
|
173
180
|
// \x1b[?1049h would flip the browser xterm to the alt buffer and hide the clean
|
|
174
181
|
// history + composer. Drop it and serve history-only — the synthesized history
|
|
175
182
|
// already ends with the composer, so the input box stays visible.
|
|
183
|
+
//
|
|
184
|
+
// EXCEPTION: the Codex transcript / backtrack pager (opened by `/status` then
|
|
185
|
+
// Esc) is ALSO an alt-screen frame, but it is a legitimate, persistent overlay
|
|
186
|
+
// the user is actively reading and navigating. Dropping it makes every
|
|
187
|
+
// snapshot/reflow serve stale history-only while the live PTY keeps painting the
|
|
188
|
+
// pager → the browser never settles and the session wedges (observed live:
|
|
189
|
+
// droppedAltViewport for 2+ min after `/status`+Esc). On a LIVE send, keep it:
|
|
190
|
+
// it is the authoritative live screen. On a DURABLE persist, still drop it (see
|
|
191
|
+
// the `durable` note above) so a cold resume never restores into a dead pager.
|
|
176
192
|
if (viewportEntersAltScreen(viewportAnsi)) {
|
|
177
|
-
|
|
178
|
-
|
|
193
|
+
// Keep the recognized interactive pager on a LIVE send (it's the screen the user
|
|
194
|
+
// is driving); drop transient full-screen frames, and — for durable persistence —
|
|
195
|
+
// the pager too. The detector's normalize pass only runs on the rare alt-screen
|
|
196
|
+
// frame, never on the common main-buffer compose.
|
|
197
|
+
const keepInteractivePager = !durable && looksLikeCodexTranscriptPagerSnapshot('codex', viewportAnsi);
|
|
198
|
+
if (!keepInteractivePager) {
|
|
199
|
+
const histOnly = historyAnsi.endsWith(CRLF) || historyAnsi.endsWith('\n') ? historyAnsi : historyAnsi + CRLF;
|
|
200
|
+
return { data: histOnly, trimmed: 0, composed: true, droppedAltViewport: true };
|
|
201
|
+
}
|
|
179
202
|
}
|
|
180
203
|
|
|
181
204
|
const { viewport, trimmed } = trimViewportOverlap(historyAnsi, viewportAnsi, opts);
|
|
@@ -206,12 +229,29 @@ function codexComposerTail(viewportAnsi) {
|
|
|
206
229
|
return '\x1b[0m' + lines.slice(start, lastNB + 1).join(CRLF) + CRLF;
|
|
207
230
|
}
|
|
208
231
|
|
|
232
|
+
// Should a mid-stream restore PUSH be suppressed because the codex compose is mid-turn?
|
|
233
|
+
// A flap/divergence-driven reflow that RIS-restores a fresh [synthesized history + live
|
|
234
|
+
// viewport] snapshot while the turn is still streaming makes the client reset + replay
|
|
235
|
+
// over codex's live inline repaint — under load this scatters the "Working…" status line
|
|
236
|
+
// into the middle of the transcript. When mid-turn (no composer ⇒ settled === false),
|
|
237
|
+
// keep the client's live render; the settle-time reconcile applies the clean snapshot.
|
|
238
|
+
// deferrableSource : the push is a flap/divergence reflow (the caller opts in); a cold
|
|
239
|
+
// attach / activation / keyframe always renders, so passes false.
|
|
240
|
+
// settled : compose saw a composer tail (turn settled). Only `=== false`
|
|
241
|
+
// (a confirmed mid-turn frame) suppresses; undefined fails open.
|
|
242
|
+
// Pure + total: any non-deferrable source, or a settled/undefined frame, returns false.
|
|
243
|
+
function shouldSuppressMidTurnRestore(input) {
|
|
244
|
+
const view = input || {};
|
|
245
|
+
return view.deferrableSource === true && view.settled === false;
|
|
246
|
+
}
|
|
247
|
+
|
|
209
248
|
module.exports = {
|
|
210
249
|
composeCodexHistorySnapshot,
|
|
211
250
|
trimViewportOverlap,
|
|
212
251
|
codexComposerTail,
|
|
213
252
|
capHistoryAnsiLines,
|
|
214
253
|
viewportEntersAltScreen,
|
|
254
|
+
shouldSuppressMidTurnRestore,
|
|
215
255
|
// exported for tests
|
|
216
256
|
_internal: { stripAnsi, normalizeLine, historyTailAnchor, findOverlapEnd, splitLines },
|
|
217
257
|
};
|