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.
Files changed (71) hide show
  1. package/package.json +1 -1
  2. package/template/bin/ctm-launch.sh +66 -21
  3. package/template/bin/dev.sh +13 -0
  4. package/template/bin/ensure-stable-node.js +11 -0
  5. package/template/bin/node-bin.sh +9 -0
  6. package/template/claude-task-manager/api-prompts.js +182 -8
  7. package/template/claude-task-manager/db.js +168 -13
  8. package/template/claude-task-manager/docs/session-title-authority.md +8 -3
  9. package/template/claude-task-manager/lib/claude-desktop-sessions.js +63 -0
  10. package/template/claude-task-manager/lib/codex-config-guard.js +124 -0
  11. package/template/claude-task-manager/lib/codex-rollout-snapshot.js +42 -2
  12. package/template/claude-task-manager/lib/coding-agent-models.js +5 -4
  13. package/template/claude-task-manager/lib/db-owner-cooperative-scheduler.js +114 -0
  14. package/template/claude-task-manager/lib/db-owner-task-queue.js +67 -0
  15. package/template/claude-task-manager/lib/db-owner-worker-client.js +5 -1
  16. package/template/claude-task-manager/lib/desktop-fork.js +81 -0
  17. package/template/claude-task-manager/lib/headless-term-service.js +7 -2
  18. package/template/claude-task-manager/lib/mirror-feed-sanitize.js +45 -0
  19. package/template/claude-task-manager/lib/runtime-context-truth.js +16 -6
  20. package/template/claude-task-manager/lib/scrollback-snapshot-policy.js +37 -0
  21. package/template/claude-task-manager/lib/session-history.js +88 -4
  22. package/template/claude-task-manager/lib/session-messages-page.js +13 -0
  23. package/template/claude-task-manager/lib/session-messages-projection.js +11 -27
  24. package/template/claude-task-manager/lib/session-stream.js +61 -16
  25. package/template/claude-task-manager/lib/session-title-signals.js +54 -0
  26. package/template/claude-task-manager/lib/session-token-usage.js +13 -0
  27. package/template/claude-task-manager/lib/state-sync/frame-emitter.js +43 -2
  28. package/template/claude-task-manager/lib/transcript-ingest-chunker.js +41 -0
  29. package/template/claude-task-manager/lib/transcript-store.js +12 -1
  30. package/template/claude-task-manager/lib/walle-session-model-catalog.js +100 -9
  31. package/template/claude-task-manager/public/css/walle-session.css +4 -0
  32. package/template/claude-task-manager/public/css/walle.css +0 -66
  33. package/template/claude-task-manager/public/index.html +766 -89
  34. package/template/claude-task-manager/public/js/state-sync-client.js +40 -1
  35. package/template/claude-task-manager/public/js/walle-session.js +211 -19
  36. package/template/claude-task-manager/public/js/walle.js +6 -110
  37. package/template/claude-task-manager/server.js +564 -90
  38. package/template/claude-task-manager/workers/db-owner-worker.js +15 -6
  39. package/template/claude-task-manager/workers/read-pool-worker.js +37 -0
  40. package/template/claude-task-manager/workers/session-host-pool-process.js +6 -1
  41. package/template/claude-task-manager/workers/session-host-process.js +6 -1
  42. package/template/claude-task-manager/workers/state-detectors/codex.js +33 -0
  43. package/template/package.json +1 -1
  44. package/template/wall-e/agent.js +78 -16
  45. package/template/wall-e/api-walle.js +24 -43
  46. package/template/wall-e/bin/walle-mcp-stdio.js +138 -5
  47. package/template/wall-e/brain.js +122 -1
  48. package/template/wall-e/chat.js +46 -1
  49. package/template/wall-e/http/model-admin.js +22 -0
  50. package/template/wall-e/lib/brain-owner-worker-client.js +20 -0
  51. package/template/wall-e/lib/parent-brain-owner-client.js +109 -0
  52. package/template/wall-e/lib/runtime-worker-pool.js +15 -1
  53. package/template/wall-e/lib/scheduler-worker-jobs.js +30 -1
  54. package/template/wall-e/lib/scheduler.js +71 -2
  55. package/template/wall-e/lib/slack-identity.js +120 -0
  56. package/template/wall-e/lib/slack-permalink.js +107 -0
  57. package/template/wall-e/lib/slack-web.js +174 -0
  58. package/template/wall-e/lib/worker-thread-pool.js +49 -0
  59. package/template/wall-e/llm/cli-binary.js +17 -4
  60. package/template/wall-e/llm/codex-cli.js +105 -60
  61. package/template/wall-e/llm/model-catalog.js +129 -17
  62. package/template/wall-e/loops/backfill.js +32 -16
  63. package/template/wall-e/loops/ingest.js +50 -16
  64. package/template/wall-e/mcp-server.js +215 -6
  65. package/template/wall-e/skills/_bundled/gws-workspace/gws-router +61 -4
  66. package/template/wall-e/skills/_bundled/slack-mentions/run.js +167 -52
  67. package/template/wall-e/skills/skill-planner.js +5 -26
  68. package/template/wall-e/utils/dedup.js +165 -66
  69. package/template/wall-e/weather-runtime.js +12 -4
  70. package/template/wall-e/workers/brain-owner-worker.js +60 -0
  71. 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
- const _skipBlobWrite = _overParseCap || _retireBlob;
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
- if (!_overParseCap && (_modelChanged || _grewEnough)) {
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
- const rows = d.prepare('SELECT role, text, timestamp, meta FROM session_message_rows WHERE ctm_session_id = ? ORDER BY message_index ASC').all(sessionId);
8225
- if (rows.length) return rows.map(_messageRowToObject);
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
- const cnt = Number(d.prepare('SELECT COUNT(*) AS n FROM session_message_rows WHERE ctm_session_id = ?').get(sessionId).n);
8244
- if (cnt === 0) return false;
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) || cnt < expected) return false; // unknown HWM or half-migrated → blob path
8248
- return true;
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
- const readStats = (table) => d.prepare(
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. AI-generated titles may fill `ctm_sessions.title` only when no user title exists.
12
- 4. Runtime generated titles such as `Codex: ~/repo`, `Claude Code: ~/repo`, `Shell: /path`, branch/worktree leaf names, and `Wall-E session` are not user titles.
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 an AI title.
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
- const histOnly = historyAnsi.endsWith(CRLF) || historyAnsi.endsWith('\n') ? historyAnsi : historyAnsi + CRLF;
178
- return { data: histOnly, trimmed: 0, composed: true, droppedAltViewport: true };
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
  };