create-walle 0.9.28 → 0.9.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/README.md +2 -2
  2. package/bin/create-walle.js +166 -6
  3. package/package.json +1 -1
  4. package/template/bin/ctm-launch.sh +70 -18
  5. package/template/bin/dev.sh +18 -0
  6. package/template/bin/ensure-stable-node.js +11 -0
  7. package/template/bin/node-bin.sh +9 -0
  8. package/template/claude-task-manager/api-prompts.js +214 -23
  9. package/template/claude-task-manager/db.js +884 -50
  10. package/template/claude-task-manager/docs/backfill-incremental-no-main-fallback.md +48 -0
  11. package/template/claude-task-manager/docs/conversation-import-freshness.md +21 -0
  12. package/template/claude-task-manager/docs/conversation-log-redesign.html +587 -0
  13. package/template/claude-task-manager/docs/session-title-authority.md +8 -3
  14. package/template/claude-task-manager/lib/auth-rules.js +13 -0
  15. package/template/claude-task-manager/lib/claude-desktop-sessions.js +63 -0
  16. package/template/claude-task-manager/lib/codex-config-guard.js +124 -0
  17. package/template/claude-task-manager/lib/codex-rollout-snapshot.js +93 -0
  18. package/template/claude-task-manager/lib/coding-agent-models.js +5 -4
  19. package/template/claude-task-manager/lib/db-owner-cooperative-scheduler.js +114 -0
  20. package/template/claude-task-manager/lib/db-owner-task-queue.js +67 -0
  21. package/template/claude-task-manager/lib/db-owner-worker-client.js +5 -1
  22. package/template/claude-task-manager/lib/desktop-fork.js +81 -0
  23. package/template/claude-task-manager/lib/headless-term-service.js +251 -4
  24. package/template/claude-task-manager/lib/message-identity.js +115 -0
  25. package/template/claude-task-manager/lib/mirror-feed-guards.js +25 -0
  26. package/template/claude-task-manager/lib/mirror-feed-sanitize.js +45 -0
  27. package/template/claude-task-manager/lib/path-suggest.js +77 -0
  28. package/template/claude-task-manager/lib/prompt-index-inputs.js +136 -0
  29. package/template/claude-task-manager/lib/real-node.js +36 -4
  30. package/template/claude-task-manager/lib/restore-auto-resume-policy.js +67 -0
  31. package/template/claude-task-manager/lib/restore-resume-batch.js +20 -0
  32. package/template/claude-task-manager/lib/restore-terminal-dims.js +109 -0
  33. package/template/claude-task-manager/lib/resume-cwd.js +124 -3
  34. package/template/claude-task-manager/lib/runtime-approval-recorder.js +152 -0
  35. package/template/claude-task-manager/lib/runtime-context-truth.js +236 -0
  36. package/template/claude-task-manager/lib/runtime-contract.js +195 -0
  37. package/template/claude-task-manager/lib/runtime-history-builder.js +205 -0
  38. package/template/claude-task-manager/lib/runtime-hook-bus.js +98 -0
  39. package/template/claude-task-manager/lib/runtime-input-queue.js +114 -0
  40. package/template/claude-task-manager/lib/runtime-input-recorder.js +156 -0
  41. package/template/claude-task-manager/lib/runtime-lineage.js +189 -0
  42. package/template/claude-task-manager/lib/runtime-registry.js +263 -0
  43. package/template/claude-task-manager/lib/runtime-session-history.js +41 -0
  44. package/template/claude-task-manager/lib/scrollback-snapshot-policy.js +37 -0
  45. package/template/claude-task-manager/lib/server-phase-conditions.js +103 -0
  46. package/template/claude-task-manager/lib/session-content-backfill.js +55 -8
  47. package/template/claude-task-manager/lib/session-db-read-contract.js +67 -0
  48. package/template/claude-task-manager/lib/session-history.js +93 -5
  49. package/template/claude-task-manager/lib/session-host-manager.js +154 -2
  50. package/template/claude-task-manager/lib/session-messages-defer.js +50 -0
  51. package/template/claude-task-manager/lib/session-messages-page.js +13 -0
  52. package/template/claude-task-manager/lib/session-messages-projection.js +48 -29
  53. package/template/claude-task-manager/lib/session-stream.js +80 -17
  54. package/template/claude-task-manager/lib/session-title-signals.js +54 -0
  55. package/template/claude-task-manager/lib/session-token-usage.js +13 -0
  56. package/template/claude-task-manager/lib/state-sync/cell-diff.js +41 -0
  57. package/template/claude-task-manager/lib/state-sync/frame-emitter.js +214 -0
  58. package/template/claude-task-manager/lib/state-sync/frame-rate.js +75 -0
  59. package/template/claude-task-manager/lib/state-sync/row-serializer.js +166 -0
  60. package/template/claude-task-manager/lib/terminal-fingerprint.js +19 -3
  61. package/template/claude-task-manager/lib/transcript-ingest-chunker.js +41 -0
  62. package/template/claude-task-manager/lib/transcript-store.js +99 -7
  63. package/template/claude-task-manager/lib/wal-checkpoint-policy.js +40 -0
  64. package/template/claude-task-manager/lib/walle-session-model-catalog.js +100 -9
  65. package/template/claude-task-manager/lib/worktree-output-binding.js +93 -0
  66. package/template/claude-task-manager/lib/write-coalescer.js +83 -0
  67. package/template/claude-task-manager/public/css/walle-session.css +4 -0
  68. package/template/claude-task-manager/public/css/walle.css +0 -66
  69. package/template/claude-task-manager/public/index.html +1707 -266
  70. package/template/claude-task-manager/public/js/feedback.js +8 -1
  71. package/template/claude-task-manager/public/js/message-renderer.js +72 -2
  72. package/template/claude-task-manager/public/js/session-phase.js +4 -0
  73. package/template/claude-task-manager/public/js/session-status-precedence.js +7 -173
  74. package/template/claude-task-manager/public/js/setup.js +46 -3
  75. package/template/claude-task-manager/public/js/state-sync-client.js +257 -0
  76. package/template/claude-task-manager/public/js/state-sync-predictor.js +41 -0
  77. package/template/claude-task-manager/public/js/stream-view.js +113 -9
  78. package/template/claude-task-manager/public/js/terminal-reconciler.js +24 -4
  79. package/template/claude-task-manager/public/js/walle-session.js +239 -19
  80. package/template/claude-task-manager/public/js/walle.js +32 -119
  81. package/template/claude-task-manager/queue-engine.js +140 -0
  82. package/template/claude-task-manager/server.js +2802 -416
  83. package/template/claude-task-manager/session-integrity.js +16 -1
  84. package/template/claude-task-manager/workers/db-owner-worker.js +23 -6
  85. package/template/claude-task-manager/workers/read-pool-worker.js +55 -1
  86. package/template/claude-task-manager/workers/session-host-pool-process.js +193 -0
  87. package/template/claude-task-manager/workers/session-host-process.js +47 -11
  88. package/template/claude-task-manager/workers/state-detectors/codex.js +33 -0
  89. package/template/package.json +1 -1
  90. package/template/wall-e/agent.js +191 -31
  91. package/template/wall-e/api-walle.js +97 -52
  92. package/template/wall-e/auth/flow-manager.js +78 -1
  93. package/template/wall-e/auth/provider-flows.js +56 -2
  94. package/template/wall-e/bin/walle-mcp-stdio.js +138 -5
  95. package/template/wall-e/brain.js +175 -13
  96. package/template/wall-e/chat.js +46 -1
  97. package/template/wall-e/embeddings.js +70 -0
  98. package/template/wall-e/events/event-bus.js +11 -1
  99. package/template/wall-e/http/auth.js +3 -1
  100. package/template/wall-e/http/model-admin.js +22 -0
  101. package/template/wall-e/lib/brain-owner-worker-client.js +36 -4
  102. package/template/wall-e/lib/diagnostics-flags.js +9 -0
  103. package/template/wall-e/lib/event-loop-monitor.js +84 -5
  104. package/template/wall-e/lib/mcp-scan-lifecycle.js +247 -0
  105. package/template/wall-e/lib/parent-brain-owner-client.js +109 -0
  106. package/template/wall-e/lib/runtime-process-inventory.js +114 -0
  107. package/template/wall-e/lib/runtime-worker-pool.js +214 -23
  108. package/template/wall-e/lib/scheduler-worker-jobs.js +49 -4
  109. package/template/wall-e/lib/scheduler.js +320 -35
  110. package/template/wall-e/lib/slack-identity.js +120 -0
  111. package/template/wall-e/lib/slack-permalink.js +107 -0
  112. package/template/wall-e/lib/slack-web.js +174 -0
  113. package/template/wall-e/lib/worker-thread-pool.js +55 -4
  114. package/template/wall-e/llm/claude-cli.js +21 -3
  115. package/template/wall-e/llm/cli-binary.js +90 -0
  116. package/template/wall-e/llm/codex-cli.js +113 -49
  117. package/template/wall-e/llm/default-fallback.js +10 -4
  118. package/template/wall-e/llm/mlx.js +46 -8
  119. package/template/wall-e/llm/model-catalog.js +129 -17
  120. package/template/wall-e/llm/provider-detector.js +112 -22
  121. package/template/wall-e/loops/backfill.js +32 -16
  122. package/template/wall-e/loops/ingest.js +50 -16
  123. package/template/wall-e/loops/tasks.js +521 -25
  124. package/template/wall-e/mcp-server.js +215 -6
  125. package/template/wall-e/memory/ctm-session-context.js +93 -0
  126. package/template/wall-e/skills/_bundled/google-calendar/run.js +15 -23
  127. package/template/wall-e/skills/_bundled/gws-workspace/gws-router +237 -0
  128. package/template/wall-e/skills/_bundled/gws-workspace/setup.js +112 -1
  129. package/template/wall-e/skills/_bundled/mcp-scan/run.js +265 -41
  130. package/template/wall-e/skills/_bundled/slack-mentions/run.js +434 -93
  131. package/template/wall-e/skills/internal-skill-registry.js +27 -5
  132. package/template/wall-e/skills/mcp-client.js +18 -3
  133. package/template/wall-e/skills/script-skill-runner.js +53 -5
  134. package/template/wall-e/skills/skill-planner.js +5 -26
  135. package/template/wall-e/training/real-trajectory-miner.js +24 -114
  136. package/template/wall-e/utils/dedup.js +165 -66
  137. package/template/wall-e/weather-runtime.js +12 -4
  138. package/template/wall-e/workers/brain-owner-worker.js +68 -0
  139. package/template/wall-e/workers/runtime-worker.js +4 -0
  140. package/template/website/index.html +3 -0
@@ -11,6 +11,13 @@ const { codexRolloutIdFromPath, readCodexRolloutMetadata } = require('./lib/sess
11
11
  const { ensureTranscriptTables, healSelfLinkedTranscriptKeys } = require('./lib/transcript-store');
12
12
  const { isProviderGeneratedUserContextText } = require('./lib/provider-user-context');
13
13
  const { buildSessionImageRefs } = require('./lib/session-image-refs');
14
+ const {
15
+ normalizeRuntimeEvent,
16
+ normalizeTurnRecord,
17
+ normalizeInputEnvelope,
18
+ normalizeRouteSnapshot,
19
+ normalizeApprovalRecord,
20
+ } = require('./lib/runtime-contract');
14
21
  const {
15
22
  classifySqliteError,
16
23
  ensureSqliteDriverReady,
@@ -785,6 +792,121 @@ function connectDb(dbPath) {
785
792
  }
786
793
  }
787
794
 
795
+ function runtimeKernelSchemaSql() {
796
+ return `
797
+ CREATE TABLE IF NOT EXISTS ctm_runtime_events (
798
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
799
+ event_id TEXT NOT NULL UNIQUE,
800
+ idempotency_key TEXT NOT NULL UNIQUE,
801
+ ctm_session_id TEXT NOT NULL,
802
+ agent_session_id TEXT DEFAULT '',
803
+ turn_id TEXT DEFAULT '',
804
+ adapter TEXT NOT NULL,
805
+ event_type TEXT NOT NULL,
806
+ payload_json TEXT NOT NULL DEFAULT '{}',
807
+ created_at_ms INTEGER NOT NULL,
808
+ created_at TEXT DEFAULT (datetime('now'))
809
+ );
810
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_events_session
811
+ ON ctm_runtime_events(ctm_session_id, created_at_ms, id);
812
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_events_turn
813
+ ON ctm_runtime_events(ctm_session_id, turn_id, created_at_ms, id);
814
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_events_type
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);
820
+
821
+ CREATE TABLE IF NOT EXISTS ctm_runtime_turns (
822
+ ctm_session_id TEXT NOT NULL,
823
+ turn_id TEXT NOT NULL,
824
+ agent_session_id TEXT DEFAULT '',
825
+ parent_turn_id TEXT DEFAULT '',
826
+ user_input_event_id TEXT DEFAULT '',
827
+ status TEXT NOT NULL DEFAULT 'open',
828
+ opened_at_ms INTEGER NOT NULL,
829
+ closed_at_ms INTEGER DEFAULT 0,
830
+ model_route_json TEXT DEFAULT '{}',
831
+ compact_boundary_json TEXT DEFAULT '{}',
832
+ metadata_json TEXT DEFAULT '{}',
833
+ updated_at_ms INTEGER NOT NULL,
834
+ PRIMARY KEY (ctm_session_id, turn_id)
835
+ );
836
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_turns_session_status
837
+ ON ctm_runtime_turns(ctm_session_id, status, updated_at_ms DESC);
838
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_turns_agent
839
+ ON ctm_runtime_turns(agent_session_id, updated_at_ms DESC);
840
+
841
+ CREATE TABLE IF NOT EXISTS ctm_runtime_input_envelopes (
842
+ input_id TEXT PRIMARY KEY,
843
+ ctm_session_id TEXT NOT NULL,
844
+ agent_session_id TEXT DEFAULT '',
845
+ mode TEXT NOT NULL DEFAULT 'mailbox',
846
+ source TEXT NOT NULL DEFAULT 'runtime',
847
+ text TEXT NOT NULL DEFAULT '',
848
+ attachments_json TEXT NOT NULL DEFAULT '[]',
849
+ created_at_ms INTEGER NOT NULL,
850
+ accepted_at_ms INTEGER DEFAULT 0,
851
+ delivered_at_ms INTEGER DEFAULT 0,
852
+ result TEXT NOT NULL DEFAULT 'pending',
853
+ result_reason TEXT DEFAULT '',
854
+ metadata_json TEXT DEFAULT '{}',
855
+ updated_at_ms INTEGER NOT NULL
856
+ );
857
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_input_session_result
858
+ ON ctm_runtime_input_envelopes(ctm_session_id, result, created_at_ms);
859
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_input_agent
860
+ ON ctm_runtime_input_envelopes(agent_session_id, created_at_ms);
861
+
862
+ CREATE TABLE IF NOT EXISTS ctm_runtime_route_snapshots (
863
+ route_id TEXT PRIMARY KEY,
864
+ ctm_session_id TEXT NOT NULL,
865
+ agent_session_id TEXT DEFAULT '',
866
+ turn_id TEXT DEFAULT '',
867
+ requested_model TEXT DEFAULT '',
868
+ resolved_model TEXT DEFAULT '',
869
+ provider TEXT DEFAULT '',
870
+ connection_layer TEXT DEFAULT 'unknown',
871
+ route_source TEXT DEFAULT 'unknown',
872
+ supports_images INTEGER DEFAULT 0,
873
+ supports_tools INTEGER DEFAULT 0,
874
+ supports_mcp INTEGER DEFAULT 0,
875
+ created_at_ms INTEGER NOT NULL,
876
+ metadata_json TEXT DEFAULT '{}'
877
+ );
878
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_route_session
879
+ ON ctm_runtime_route_snapshots(ctm_session_id, created_at_ms DESC);
880
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_route_turn
881
+ ON ctm_runtime_route_snapshots(ctm_session_id, turn_id, created_at_ms DESC);
882
+
883
+ CREATE TABLE IF NOT EXISTS ctm_runtime_approvals (
884
+ approval_id TEXT PRIMARY KEY,
885
+ ctm_session_id TEXT NOT NULL,
886
+ agent_session_id TEXT DEFAULT '',
887
+ turn_id TEXT DEFAULT '',
888
+ source_event_id TEXT DEFAULT '',
889
+ status TEXT NOT NULL DEFAULT 'pending',
890
+ prompt TEXT DEFAULT '',
891
+ tool_name TEXT DEFAULT '',
892
+ decision TEXT DEFAULT '',
893
+ decided_by TEXT DEFAULT '',
894
+ requested_at_ms INTEGER NOT NULL,
895
+ resolved_at_ms INTEGER DEFAULT 0,
896
+ metadata_json TEXT DEFAULT '{}',
897
+ updated_at_ms INTEGER NOT NULL
898
+ );
899
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_approvals_session_status
900
+ ON ctm_runtime_approvals(ctm_session_id, status, requested_at_ms DESC);
901
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_approvals_source_event
902
+ ON ctm_runtime_approvals(source_event_id);
903
+ `;
904
+ }
905
+
906
+ function ensureRuntimeKernelTables(handle = getDb()) {
907
+ handle.exec(runtimeKernelSchemaSql());
908
+ }
909
+
788
910
  function createTables() {
789
911
  db.exec(`
790
912
  -- Settings (key-value store)
@@ -1006,6 +1128,7 @@ function createTables() {
1006
1128
  CREATE INDEX IF NOT EXISTS idx_runtime_tasks_session_status ON runtime_tasks(ctm_session_id, status, updated_at DESC);
1007
1129
  CREATE INDEX IF NOT EXISTS idx_runtime_tasks_agent ON runtime_tasks(agent_session_id, updated_at DESC);
1008
1130
  `);
1131
+ ensureRuntimeKernelTables(db);
1009
1132
  }
1010
1133
 
1011
1134
  function runMigrations() {
@@ -1047,6 +1170,7 @@ function runMigrations() {
1047
1170
  CREATE INDEX IF NOT EXISTS idx_runtime_tasks_session_status ON runtime_tasks(ctm_session_id, status, updated_at DESC);
1048
1171
  CREATE INDEX IF NOT EXISTS idx_runtime_tasks_agent ON runtime_tasks(agent_session_id, updated_at DESC);
1049
1172
  `);
1173
+ ensureRuntimeKernelTables(getDb());
1050
1174
 
1051
1175
  // Add pinned column to prompts if not exists
1052
1176
  const cols = getDb().prepare("PRAGMA table_info(prompts)").all();
@@ -2385,6 +2509,34 @@ function migrateSchemaIfNeeded() {
2385
2509
  // Non-fatal: dropping a dormant cache table; the app runs fine if it lingers.
2386
2510
  }
2387
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)');
2388
2540
  }
2389
2541
 
2390
2542
  /**
@@ -3048,6 +3200,7 @@ function createV1Tables() {
3048
3200
  parent_agent_session_id TEXT DEFAULT '',
3049
3201
  agent_nickname TEXT DEFAULT '',
3050
3202
  agent_role TEXT DEFAULT '',
3203
+ forked_from_desktop_uuid TEXT DEFAULT '',
3051
3204
  created_at TEXT DEFAULT (datetime('now')),
3052
3205
  updated_at TEXT DEFAULT (datetime('now')),
3053
3206
  FOREIGN KEY (ctm_session_id) REFERENCES ctm_sessions(id) ON DELETE CASCADE
@@ -3069,6 +3222,7 @@ function createV1Tables() {
3069
3222
  d.exec('CREATE INDEX IF NOT EXISTS idx_agent_slug ON agent_sessions(slug)');
3070
3223
  d.exec('CREATE INDEX IF NOT EXISTS idx_agent_parent_session ON agent_sessions(parent_agent_session_id)');
3071
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 != ''");
3072
3226
  }
3073
3227
 
3074
3228
  // --- Settings CRUD ---
@@ -3384,6 +3538,501 @@ function deleteRuntimeTasksForSession(ctmSessionId, { completedOnly = false } =
3384
3538
  return result.changes || 0;
3385
3539
  }
3386
3540
 
3541
+ function _parseJsonObject(value) {
3542
+ try {
3543
+ const parsed = JSON.parse(value || '{}');
3544
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
3545
+ } catch {
3546
+ return {};
3547
+ }
3548
+ }
3549
+
3550
+ function _parseJsonArray(value) {
3551
+ try {
3552
+ const parsed = JSON.parse(value || '[]');
3553
+ return Array.isArray(parsed) ? parsed : [];
3554
+ } catch {
3555
+ return [];
3556
+ }
3557
+ }
3558
+
3559
+ function _parseRuntimeEvent(row) {
3560
+ if (!row) return null;
3561
+ return {
3562
+ id: row.id,
3563
+ eventId: row.event_id,
3564
+ event_id: row.event_id,
3565
+ idempotencyKey: row.idempotency_key,
3566
+ idempotency_key: row.idempotency_key,
3567
+ ctmSessionId: row.ctm_session_id,
3568
+ ctm_session_id: row.ctm_session_id,
3569
+ agentSessionId: row.agent_session_id || '',
3570
+ agent_session_id: row.agent_session_id || '',
3571
+ turnId: row.turn_id || '',
3572
+ turn_id: row.turn_id || '',
3573
+ adapter: row.adapter || 'unknown',
3574
+ type: row.event_type || '',
3575
+ event_type: row.event_type || '',
3576
+ payload: _parseJsonObject(row.payload_json),
3577
+ payload_json: row.payload_json || '{}',
3578
+ createdAtMs: Number(row.created_at_ms || 0),
3579
+ created_at_ms: Number(row.created_at_ms || 0),
3580
+ created_at: row.created_at || '',
3581
+ };
3582
+ }
3583
+
3584
+ function appendRuntimeEvent(event = {}) {
3585
+ const normalized = normalizeRuntimeEvent(event);
3586
+ if (!normalized.ctmSessionId || !normalized.type) return null;
3587
+ getDb().prepare(`
3588
+ INSERT OR IGNORE INTO ctm_runtime_events (
3589
+ event_id, idempotency_key, ctm_session_id, agent_session_id, turn_id,
3590
+ adapter, event_type, payload_json, created_at_ms
3591
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
3592
+ `).run(
3593
+ normalized.eventId,
3594
+ normalized.idempotencyKey,
3595
+ normalized.ctmSessionId,
3596
+ normalized.agentSessionId,
3597
+ normalized.turnId,
3598
+ normalized.adapter,
3599
+ normalized.type,
3600
+ _runtimeJsonObject(normalized.payload),
3601
+ normalized.createdAtMs
3602
+ );
3603
+ return _parseRuntimeEvent(getDb().prepare(`
3604
+ SELECT * FROM ctm_runtime_events
3605
+ WHERE idempotency_key = ?
3606
+ ORDER BY id ASC
3607
+ LIMIT 1
3608
+ `).get(normalized.idempotencyKey));
3609
+ }
3610
+
3611
+ function listRuntimeEvents(ctmSessionId, { sinceId = 0, turnId = '', limit = 1000 } = {}) {
3612
+ const cleanSessionId = String(ctmSessionId || '').trim();
3613
+ if (!cleanSessionId) return [];
3614
+ const cap = Math.max(1, Math.min(Number(limit) || 1000, 5000));
3615
+ const minId = Math.max(0, Number(sinceId || 0) || 0);
3616
+ const cleanTurnId = String(turnId || '').trim();
3617
+ const rows = cleanTurnId
3618
+ ? getDb().prepare(`
3619
+ SELECT * FROM ctm_runtime_events
3620
+ WHERE ctm_session_id = ? AND turn_id = ? AND id > ?
3621
+ ORDER BY id ASC
3622
+ LIMIT ?
3623
+ `).all(cleanSessionId, cleanTurnId, minId, cap)
3624
+ : getDb().prepare(`
3625
+ SELECT * FROM ctm_runtime_events
3626
+ WHERE ctm_session_id = ? AND id > ?
3627
+ ORDER BY id ASC
3628
+ LIMIT ?
3629
+ `).all(cleanSessionId, minId, cap);
3630
+ return rows.map(_parseRuntimeEvent).filter(Boolean);
3631
+ }
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
+
3659
+ function _parseRuntimeTurn(row) {
3660
+ if (!row) return null;
3661
+ return {
3662
+ ctmSessionId: row.ctm_session_id,
3663
+ ctm_session_id: row.ctm_session_id,
3664
+ turnId: row.turn_id,
3665
+ turn_id: row.turn_id,
3666
+ agentSessionId: row.agent_session_id || '',
3667
+ agent_session_id: row.agent_session_id || '',
3668
+ parentTurnId: row.parent_turn_id || '',
3669
+ parent_turn_id: row.parent_turn_id || '',
3670
+ userInputEventId: row.user_input_event_id || '',
3671
+ user_input_event_id: row.user_input_event_id || '',
3672
+ status: row.status || 'open',
3673
+ openedAtMs: Number(row.opened_at_ms || 0),
3674
+ opened_at_ms: Number(row.opened_at_ms || 0),
3675
+ closedAtMs: Number(row.closed_at_ms || 0),
3676
+ closed_at_ms: Number(row.closed_at_ms || 0),
3677
+ modelRoute: _parseJsonObject(row.model_route_json),
3678
+ model_route_json: row.model_route_json || '{}',
3679
+ compactBoundary: _parseJsonObject(row.compact_boundary_json),
3680
+ compact_boundary_json: row.compact_boundary_json || '{}',
3681
+ metadata: _parseJsonObject(row.metadata_json),
3682
+ metadata_json: row.metadata_json || '{}',
3683
+ updatedAtMs: Number(row.updated_at_ms || 0),
3684
+ updated_at_ms: Number(row.updated_at_ms || 0),
3685
+ };
3686
+ }
3687
+
3688
+ function upsertRuntimeTurn(turn = {}) {
3689
+ const normalized = normalizeTurnRecord(turn);
3690
+ if (!normalized.ctmSessionId || !normalized.turnId) return null;
3691
+ const now = Date.now();
3692
+ getDb().prepare(`
3693
+ INSERT INTO ctm_runtime_turns (
3694
+ ctm_session_id, turn_id, agent_session_id, parent_turn_id, user_input_event_id,
3695
+ status, opened_at_ms, closed_at_ms, model_route_json, compact_boundary_json,
3696
+ metadata_json, updated_at_ms
3697
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3698
+ ON CONFLICT(ctm_session_id, turn_id) DO UPDATE SET
3699
+ agent_session_id = excluded.agent_session_id,
3700
+ parent_turn_id = excluded.parent_turn_id,
3701
+ user_input_event_id = excluded.user_input_event_id,
3702
+ status = excluded.status,
3703
+ opened_at_ms = MIN(ctm_runtime_turns.opened_at_ms, excluded.opened_at_ms),
3704
+ closed_at_ms = excluded.closed_at_ms,
3705
+ model_route_json = CASE
3706
+ WHEN excluded.model_route_json = '{}' THEN ctm_runtime_turns.model_route_json
3707
+ ELSE excluded.model_route_json
3708
+ END,
3709
+ compact_boundary_json = CASE
3710
+ WHEN excluded.compact_boundary_json = '{}' THEN ctm_runtime_turns.compact_boundary_json
3711
+ ELSE excluded.compact_boundary_json
3712
+ END,
3713
+ metadata_json = CASE
3714
+ WHEN excluded.metadata_json = '{}' THEN ctm_runtime_turns.metadata_json
3715
+ ELSE excluded.metadata_json
3716
+ END,
3717
+ updated_at_ms = excluded.updated_at_ms
3718
+ `).run(
3719
+ normalized.ctmSessionId,
3720
+ normalized.turnId,
3721
+ normalized.agentSessionId,
3722
+ normalized.parentTurnId,
3723
+ normalized.userInputEventId,
3724
+ normalized.status,
3725
+ normalized.openedAtMs,
3726
+ normalized.closedAtMs,
3727
+ _runtimeJsonObject(normalized.modelRoute),
3728
+ _runtimeJsonObject(normalized.compactBoundary),
3729
+ _runtimeJsonObject(normalized.metadata),
3730
+ now
3731
+ );
3732
+ return getRuntimeTurn(normalized.ctmSessionId, normalized.turnId);
3733
+ }
3734
+
3735
+ function getRuntimeTurn(ctmSessionId, turnId) {
3736
+ const row = getDb().prepare('SELECT * FROM ctm_runtime_turns WHERE ctm_session_id = ? AND turn_id = ?')
3737
+ .get(String(ctmSessionId || '').trim(), String(turnId || '').trim());
3738
+ return _parseRuntimeTurn(row);
3739
+ }
3740
+
3741
+ function listRuntimeTurns(ctmSessionId, { status = '', limit = 200 } = {}) {
3742
+ const cleanSessionId = String(ctmSessionId || '').trim();
3743
+ if (!cleanSessionId) return [];
3744
+ const cap = Math.max(1, Math.min(Number(limit) || 200, 1000));
3745
+ const cleanStatus = String(status || '').trim();
3746
+ const rows = cleanStatus
3747
+ ? getDb().prepare('SELECT * FROM ctm_runtime_turns WHERE ctm_session_id = ? AND status = ? ORDER BY opened_at_ms ASC LIMIT ?').all(cleanSessionId, cleanStatus, cap)
3748
+ : getDb().prepare('SELECT * FROM ctm_runtime_turns WHERE ctm_session_id = ? ORDER BY opened_at_ms ASC LIMIT ?').all(cleanSessionId, cap);
3749
+ return rows.map(_parseRuntimeTurn).filter(Boolean);
3750
+ }
3751
+
3752
+ function _parseRuntimeInputEnvelope(row) {
3753
+ if (!row) return null;
3754
+ return {
3755
+ inputId: row.input_id,
3756
+ input_id: row.input_id,
3757
+ ctmSessionId: row.ctm_session_id,
3758
+ ctm_session_id: row.ctm_session_id,
3759
+ agentSessionId: row.agent_session_id || '',
3760
+ agent_session_id: row.agent_session_id || '',
3761
+ mode: row.mode || 'mailbox',
3762
+ source: row.source || 'runtime',
3763
+ text: row.text || '',
3764
+ attachments: _parseJsonArray(row.attachments_json),
3765
+ attachments_json: row.attachments_json || '[]',
3766
+ createdAtMs: Number(row.created_at_ms || 0),
3767
+ created_at_ms: Number(row.created_at_ms || 0),
3768
+ acceptedAtMs: Number(row.accepted_at_ms || 0),
3769
+ accepted_at_ms: Number(row.accepted_at_ms || 0),
3770
+ deliveredAtMs: Number(row.delivered_at_ms || 0),
3771
+ delivered_at_ms: Number(row.delivered_at_ms || 0),
3772
+ result: row.result || 'pending',
3773
+ resultReason: row.result_reason || '',
3774
+ result_reason: row.result_reason || '',
3775
+ metadata: _parseJsonObject(row.metadata_json),
3776
+ metadata_json: row.metadata_json || '{}',
3777
+ updatedAtMs: Number(row.updated_at_ms || 0),
3778
+ updated_at_ms: Number(row.updated_at_ms || 0),
3779
+ };
3780
+ }
3781
+
3782
+ function upsertRuntimeInputEnvelope(input = {}) {
3783
+ const normalized = normalizeInputEnvelope(input);
3784
+ if (!normalized.ctmSessionId || !normalized.inputId) return null;
3785
+ const now = Date.now();
3786
+ getDb().prepare(`
3787
+ INSERT INTO ctm_runtime_input_envelopes (
3788
+ input_id, ctm_session_id, agent_session_id, mode, source, text,
3789
+ attachments_json, created_at_ms, accepted_at_ms, delivered_at_ms,
3790
+ result, result_reason, metadata_json, updated_at_ms
3791
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3792
+ ON CONFLICT(input_id) DO UPDATE SET
3793
+ ctm_session_id = excluded.ctm_session_id,
3794
+ agent_session_id = excluded.agent_session_id,
3795
+ mode = excluded.mode,
3796
+ source = excluded.source,
3797
+ text = excluded.text,
3798
+ attachments_json = excluded.attachments_json,
3799
+ created_at_ms = MIN(ctm_runtime_input_envelopes.created_at_ms, excluded.created_at_ms),
3800
+ accepted_at_ms = excluded.accepted_at_ms,
3801
+ delivered_at_ms = excluded.delivered_at_ms,
3802
+ result = excluded.result,
3803
+ result_reason = excluded.result_reason,
3804
+ metadata_json = excluded.metadata_json,
3805
+ updated_at_ms = excluded.updated_at_ms
3806
+ `).run(
3807
+ normalized.inputId,
3808
+ normalized.ctmSessionId,
3809
+ normalized.agentSessionId,
3810
+ normalized.mode,
3811
+ normalized.source,
3812
+ normalized.text,
3813
+ _runtimeJsonArray(normalized.attachments),
3814
+ normalized.createdAtMs,
3815
+ normalized.acceptedAtMs,
3816
+ normalized.deliveredAtMs,
3817
+ normalized.result,
3818
+ normalized.resultReason,
3819
+ _runtimeJsonObject(normalized.metadata),
3820
+ now
3821
+ );
3822
+ return _parseRuntimeInputEnvelope(getDb().prepare('SELECT * FROM ctm_runtime_input_envelopes WHERE input_id = ?').get(normalized.inputId));
3823
+ }
3824
+
3825
+ function updateRuntimeInputEnvelope(inputId, patch = {}) {
3826
+ const existing = _parseRuntimeInputEnvelope(getDb().prepare('SELECT * FROM ctm_runtime_input_envelopes WHERE input_id = ?').get(String(inputId || '').trim()));
3827
+ if (!existing) return null;
3828
+ return upsertRuntimeInputEnvelope({
3829
+ ...existing,
3830
+ ...patch,
3831
+ inputId: existing.inputId,
3832
+ ctmSessionId: patch.ctmSessionId || patch.ctm_session_id || existing.ctmSessionId,
3833
+ });
3834
+ }
3835
+
3836
+ function getRuntimeInputEnvelope(inputId) {
3837
+ const cleanInputId = String(inputId || '').trim();
3838
+ if (!cleanInputId) return null;
3839
+ return _parseRuntimeInputEnvelope(getDb().prepare('SELECT * FROM ctm_runtime_input_envelopes WHERE input_id = ?').get(cleanInputId));
3840
+ }
3841
+
3842
+ function listRuntimeInputEnvelopes(ctmSessionId, { result = '', limit = 200 } = {}) {
3843
+ const cleanSessionId = String(ctmSessionId || '').trim();
3844
+ if (!cleanSessionId) return [];
3845
+ const cap = Math.max(1, Math.min(Number(limit) || 200, 1000));
3846
+ const cleanResult = String(result || '').trim();
3847
+ const rows = cleanResult
3848
+ ? getDb().prepare('SELECT * FROM ctm_runtime_input_envelopes WHERE ctm_session_id = ? AND result = ? ORDER BY created_at_ms ASC LIMIT ?').all(cleanSessionId, cleanResult, cap)
3849
+ : getDb().prepare('SELECT * FROM ctm_runtime_input_envelopes WHERE ctm_session_id = ? ORDER BY created_at_ms ASC LIMIT ?').all(cleanSessionId, cap);
3850
+ return rows.map(_parseRuntimeInputEnvelope).filter(Boolean);
3851
+ }
3852
+
3853
+ function _parseRuntimeRouteSnapshot(row) {
3854
+ if (!row) return null;
3855
+ return {
3856
+ routeId: row.route_id,
3857
+ route_id: row.route_id,
3858
+ ctmSessionId: row.ctm_session_id,
3859
+ ctm_session_id: row.ctm_session_id,
3860
+ agentSessionId: row.agent_session_id || '',
3861
+ agent_session_id: row.agent_session_id || '',
3862
+ turnId: row.turn_id || '',
3863
+ turn_id: row.turn_id || '',
3864
+ requestedModel: row.requested_model || '',
3865
+ requested_model: row.requested_model || '',
3866
+ resolvedModel: row.resolved_model || '',
3867
+ resolved_model: row.resolved_model || '',
3868
+ provider: row.provider || '',
3869
+ connectionLayer: row.connection_layer || 'unknown',
3870
+ connection_layer: row.connection_layer || 'unknown',
3871
+ routeSource: row.route_source || 'unknown',
3872
+ route_source: row.route_source || 'unknown',
3873
+ supportsImages: !!row.supports_images,
3874
+ supports_images: !!row.supports_images,
3875
+ supportsTools: !!row.supports_tools,
3876
+ supports_tools: !!row.supports_tools,
3877
+ supportsMcp: !!row.supports_mcp,
3878
+ supports_mcp: !!row.supports_mcp,
3879
+ createdAtMs: Number(row.created_at_ms || 0),
3880
+ created_at_ms: Number(row.created_at_ms || 0),
3881
+ metadata: _parseJsonObject(row.metadata_json),
3882
+ metadata_json: row.metadata_json || '{}',
3883
+ };
3884
+ }
3885
+
3886
+ function upsertRuntimeRouteSnapshot(snapshot = {}) {
3887
+ const normalized = normalizeRouteSnapshot(snapshot);
3888
+ if (!normalized.ctmSessionId || !normalized.routeId) return null;
3889
+ getDb().prepare(`
3890
+ INSERT INTO ctm_runtime_route_snapshots (
3891
+ route_id, ctm_session_id, agent_session_id, turn_id, requested_model,
3892
+ resolved_model, provider, connection_layer, route_source, supports_images,
3893
+ supports_tools, supports_mcp, created_at_ms, metadata_json
3894
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3895
+ ON CONFLICT(route_id) DO UPDATE SET
3896
+ ctm_session_id = excluded.ctm_session_id,
3897
+ agent_session_id = excluded.agent_session_id,
3898
+ turn_id = excluded.turn_id,
3899
+ requested_model = excluded.requested_model,
3900
+ resolved_model = excluded.resolved_model,
3901
+ provider = excluded.provider,
3902
+ connection_layer = excluded.connection_layer,
3903
+ route_source = excluded.route_source,
3904
+ supports_images = excluded.supports_images,
3905
+ supports_tools = excluded.supports_tools,
3906
+ supports_mcp = excluded.supports_mcp,
3907
+ created_at_ms = excluded.created_at_ms,
3908
+ metadata_json = excluded.metadata_json
3909
+ `).run(
3910
+ normalized.routeId,
3911
+ normalized.ctmSessionId,
3912
+ normalized.agentSessionId,
3913
+ normalized.turnId,
3914
+ normalized.requestedModel,
3915
+ normalized.resolvedModel,
3916
+ normalized.provider,
3917
+ normalized.connectionLayer,
3918
+ normalized.routeSource,
3919
+ normalized.supportsImages ? 1 : 0,
3920
+ normalized.supportsTools ? 1 : 0,
3921
+ normalized.supportsMcp ? 1 : 0,
3922
+ normalized.createdAtMs,
3923
+ _runtimeJsonObject(normalized.metadata)
3924
+ );
3925
+ return _parseRuntimeRouteSnapshot(getDb().prepare('SELECT * FROM ctm_runtime_route_snapshots WHERE route_id = ?').get(normalized.routeId));
3926
+ }
3927
+
3928
+ function getLatestRuntimeRouteSnapshot(ctmSessionId, { turnId = '' } = {}) {
3929
+ const cleanSessionId = String(ctmSessionId || '').trim();
3930
+ if (!cleanSessionId) return null;
3931
+ const cleanTurnId = String(turnId || '').trim();
3932
+ const row = cleanTurnId
3933
+ ? getDb().prepare('SELECT * FROM ctm_runtime_route_snapshots WHERE ctm_session_id = ? AND turn_id = ? ORDER BY created_at_ms DESC LIMIT 1').get(cleanSessionId, cleanTurnId)
3934
+ : getDb().prepare('SELECT * FROM ctm_runtime_route_snapshots WHERE ctm_session_id = ? ORDER BY created_at_ms DESC LIMIT 1').get(cleanSessionId);
3935
+ return _parseRuntimeRouteSnapshot(row);
3936
+ }
3937
+
3938
+ function _parseRuntimeApproval(row) {
3939
+ if (!row) return null;
3940
+ return {
3941
+ approvalId: row.approval_id,
3942
+ approval_id: row.approval_id,
3943
+ ctmSessionId: row.ctm_session_id,
3944
+ ctm_session_id: row.ctm_session_id,
3945
+ agentSessionId: row.agent_session_id || '',
3946
+ agent_session_id: row.agent_session_id || '',
3947
+ turnId: row.turn_id || '',
3948
+ turn_id: row.turn_id || '',
3949
+ sourceEventId: row.source_event_id || '',
3950
+ source_event_id: row.source_event_id || '',
3951
+ status: row.status || 'pending',
3952
+ prompt: row.prompt || '',
3953
+ toolName: row.tool_name || '',
3954
+ tool_name: row.tool_name || '',
3955
+ decision: row.decision || '',
3956
+ decidedBy: row.decided_by || '',
3957
+ decided_by: row.decided_by || '',
3958
+ requestedAtMs: Number(row.requested_at_ms || 0),
3959
+ requested_at_ms: Number(row.requested_at_ms || 0),
3960
+ resolvedAtMs: Number(row.resolved_at_ms || 0),
3961
+ resolved_at_ms: Number(row.resolved_at_ms || 0),
3962
+ metadata: _parseJsonObject(row.metadata_json),
3963
+ metadata_json: row.metadata_json || '{}',
3964
+ updatedAtMs: Number(row.updated_at_ms || 0),
3965
+ updated_at_ms: Number(row.updated_at_ms || 0),
3966
+ };
3967
+ }
3968
+
3969
+ function upsertRuntimeApproval(approval = {}) {
3970
+ const normalized = normalizeApprovalRecord(approval);
3971
+ if (!normalized.ctmSessionId || !normalized.approvalId) return null;
3972
+ const now = Date.now();
3973
+ getDb().prepare(`
3974
+ INSERT INTO ctm_runtime_approvals (
3975
+ approval_id, ctm_session_id, agent_session_id, turn_id, source_event_id,
3976
+ status, prompt, tool_name, decision, decided_by, requested_at_ms,
3977
+ resolved_at_ms, metadata_json, updated_at_ms
3978
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3979
+ ON CONFLICT(approval_id) DO UPDATE SET
3980
+ ctm_session_id = excluded.ctm_session_id,
3981
+ agent_session_id = excluded.agent_session_id,
3982
+ turn_id = excluded.turn_id,
3983
+ source_event_id = excluded.source_event_id,
3984
+ status = excluded.status,
3985
+ prompt = excluded.prompt,
3986
+ tool_name = excluded.tool_name,
3987
+ decision = excluded.decision,
3988
+ decided_by = excluded.decided_by,
3989
+ requested_at_ms = MIN(ctm_runtime_approvals.requested_at_ms, excluded.requested_at_ms),
3990
+ resolved_at_ms = excluded.resolved_at_ms,
3991
+ metadata_json = excluded.metadata_json,
3992
+ updated_at_ms = excluded.updated_at_ms
3993
+ `).run(
3994
+ normalized.approvalId,
3995
+ normalized.ctmSessionId,
3996
+ normalized.agentSessionId,
3997
+ normalized.turnId,
3998
+ normalized.sourceEventId,
3999
+ normalized.status,
4000
+ normalized.prompt,
4001
+ normalized.toolName,
4002
+ normalized.decision,
4003
+ normalized.decidedBy,
4004
+ normalized.requestedAtMs,
4005
+ normalized.resolvedAtMs,
4006
+ _runtimeJsonObject(normalized.metadata),
4007
+ now
4008
+ );
4009
+ return _parseRuntimeApproval(getDb().prepare('SELECT * FROM ctm_runtime_approvals WHERE approval_id = ?').get(normalized.approvalId));
4010
+ }
4011
+
4012
+ function resolveRuntimeApproval(approvalId, { decision = '', decidedBy = '', status = 'approved', metadata = {} } = {}) {
4013
+ const existing = _parseRuntimeApproval(getDb().prepare('SELECT * FROM ctm_runtime_approvals WHERE approval_id = ?').get(String(approvalId || '').trim()));
4014
+ if (!existing) return null;
4015
+ return upsertRuntimeApproval({
4016
+ ...existing,
4017
+ status,
4018
+ decision,
4019
+ decidedBy,
4020
+ resolvedAtMs: Date.now(),
4021
+ metadata: { ...(existing.metadata || {}), ...(metadata || {}) },
4022
+ });
4023
+ }
4024
+
4025
+ function listRuntimeApprovals(ctmSessionId, { status = '', limit = 100 } = {}) {
4026
+ const cleanSessionId = String(ctmSessionId || '').trim();
4027
+ if (!cleanSessionId) return [];
4028
+ const cap = Math.max(1, Math.min(Number(limit) || 100, 500));
4029
+ const cleanStatus = String(status || '').trim();
4030
+ const rows = cleanStatus
4031
+ ? getDb().prepare('SELECT * FROM ctm_runtime_approvals WHERE ctm_session_id = ? AND status = ? ORDER BY requested_at_ms DESC LIMIT ?').all(cleanSessionId, cleanStatus, cap)
4032
+ : getDb().prepare('SELECT * FROM ctm_runtime_approvals WHERE ctm_session_id = ? ORDER BY requested_at_ms DESC LIMIT ?').all(cleanSessionId, cap);
4033
+ return rows.map(_parseRuntimeApproval).filter(Boolean);
4034
+ }
4035
+
3387
4036
  // --- Domain auto-classification ---
3388
4037
  const DOMAIN_RULES = [
3389
4038
  { domain: 'coding', patterns: /\b(code|function|class|method|variable|bug|test|refactor|implement|api|endpoint|import|export|module|compile|syntax|type(script)?|lint|format|regex|array|object|string|int|float|boolean|null|undefined|return|async|await|promise|callback|error handling|unit test|integration test|debug|stack trace|exception|try.catch|react|component|hook|useState|useEffect|vue|angular|svelte|html|css|dom|frontend|backend|database|sql|orm|schema|migration|git|commit|branch|merge|pr|pull.request|npm|pip|cargo|maven|gradle|python|javascript|java|rust|go|ruby|php|swift|kotlin)\b/i },
@@ -4373,6 +5022,14 @@ const _overCapImportLogged = new Set(); // session_id → already warned about a
4373
5022
  // when the count grew past a delta (or the model changed); otherwise the upsert's COALESCE preserves
4374
5023
  // the prior stored value — exact recompute still happens on the live path for Claude/Codex.
4375
5024
  const _importTokenEstimateLen = new Map();
5025
+ // Blob retirement is the shipped default: stop writing the monolithic `messages` blob (store '[]')
5026
+ // because the faithful session_message_rows serve every read. Two safety conditions gate it:
5027
+ // - CTM_DUAL_WRITE_BLOB=1 → rollback hatch: keep dual-writing the real blob.
5028
+ // - CTM_SESSION_ROWS=0 → rows READ path is off, so nulling the blob would blind reads → keep it.
5029
+ // lib/session-content-backfill.js mirrors this predicate for the reclaim sweep (keep them in lockstep).
5030
+ function _blobRetirementActive() {
5031
+ return process.env.CTM_DUAL_WRITE_BLOB !== '1' && process.env.CTM_SESSION_ROWS !== '0';
5032
+ }
4376
5033
  function importSessionConversation({
4377
5034
  session_id, project_path, messages, user_msg_count, assistant_msg_count,
4378
5035
  search_messages,
@@ -4386,6 +5043,10 @@ function importSessionConversation({
4386
5043
  // render shape differs from `messages` (Wall-E persists review-shaped rows, not the raw transcript
4387
5044
  // blob shape). The metadata upsert + blob still happen so the freshness gate + legacy reads work.
4388
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,
4389
5050
  }) {
4390
5051
  // Attribution: the whole import is one synchronous span — a JSON.stringify of
4391
5052
  // the full message array (multi-MB for 2000+ prompt sessions) + an upsert + a
@@ -4404,16 +5065,19 @@ function importSessionConversation({
4404
5065
  let _isOverParseCap = null;
4405
5066
  try { _isOverParseCap = require('./lib/size-cap').isOverParseCap; } catch { /* optional */ }
4406
5067
  const _overParseCap = typeof _isOverParseCap === 'function' ? _isOverParseCap(file_size) : false;
4407
- // Phase 7 (blob retirement): once reads are served from session_message_rows
4408
- // (CTM_SESSION_ROWS=1, verified 100% via /api/ctm/session-rows-status), set
4409
- // CTM_DUAL_WRITE_BLOB=0 to STOP writing the monolithic messages blob (store '[]')
4410
- // this is what ends the O(N²) JSON.stringify-the-whole-array on every append.
4411
- // SAFETY: only honor the flag when the rows READ path is on (CTM_SESSION_ROWS=1),
4412
- // otherwise nulling the blob would blind every read. And the row write below
4413
- // restores the real blob if it fails, so a session is never left empty. Default
4414
- // unset = keep dual-writing the blob; existing users/legacy reads are unchanged.
4415
- const _retireBlob = process.env.CTM_DUAL_WRITE_BLOB === '0' && process.env.CTM_SESSION_ROWS !== '0';
4416
- const _skipBlobWrite = _overParseCap || _retireBlob;
5068
+ // Phase 7 (blob retirement): reads are served from session_message_rows (the
5069
+ // faithful per-message store), so the monolithic `messages` blob is dead weight
5070
+ // and its full-array JSON.stringify on every append is the O(N²) write cost we
5071
+ // want gone. DEFAULT (shipped to everyone, incl. npx): retire the blob (store
5072
+ // '[]'). Rollback hatch: CTM_DUAL_WRITE_BLOB=1 keeps dual-writing the real blob.
5073
+ // SAFETY: only retire when the rows READ path is on (CTM_SESSION_ROWS != '0',
5074
+ // the default), otherwise nulling the blob would blind every read; and the row
5075
+ // write below restores the real blob if it fails, so a session is never empty.
5076
+ const _retireBlob = _blobRetirementActive();
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);
4417
5081
  if (_overParseCap && !_overCapImportLogged.has(session_id)) {
4418
5082
  _overCapImportLogged.add(session_id);
4419
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.`);
@@ -4435,7 +5099,8 @@ function importSessionConversation({
4435
5099
  const _prevEst = _importTokenEstimateLen.get(session_id);
4436
5100
  const _modelChanged = !_prevEst || _prevEst.model !== (model_id || '');
4437
5101
  const _grewEnough = !_prevEst || _reestimateDelta === 0 || (_msgLen - _prevEst.len) >= _reestimateDelta;
4438
- if (!_overParseCap && (_modelChanged || _grewEnough)) {
5102
+ const _chunkedRoute = !!forceBlobWrite && !!skipMessageRows && !_overParseCap;
5103
+ if (!_overParseCap && !_chunkedRoute && (_modelChanged || _grewEnough)) {
4439
5104
  try {
4440
5105
  const summary = require('./lib/session-token-usage').estimateFromMessages(messages || [], model_id || '');
4441
5106
  if (summary && summary.total > 0) {
@@ -4490,6 +5155,14 @@ function importSessionConversation({
4490
5155
  _tokTotal, _tokCtx, _tokWindow, _tokExact, _tokBreakdown
4491
5156
  );
4492
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
+
4493
5166
  // Keep message-level search/review surfaces in lockstep with the durable
4494
5167
  // conversation cache. The older one-shot backfill path intentionally skips
4495
5168
  // sessions that already have rows, which made long-running imported Codex
@@ -5280,7 +5953,27 @@ const _AI_REFINEMENT_FAILED_MAX_ROWS = Number(process.env.CTM_AI_REFINEMENT_FAIL
5280
5953
  // (a few new failed rows/hour) never approaches the cap.
5281
5954
  const _AI_REFINEMENT_PRUNE_MAX_PER_RUN = Number(process.env.CTM_AI_REFINEMENT_PRUNE_MAX_PER_RUN || 5000);
5282
5955
 
5283
- function addApprovalObservation({
5956
+ const _APPROVAL_OBS_INSERT_SQL = `
5957
+ INSERT INTO approval_observations (
5958
+ session_id,
5959
+ provider_id,
5960
+ source,
5961
+ raw_detected,
5962
+ gated,
5963
+ gate_reason,
5964
+ parse_status,
5965
+ policy_decision,
5966
+ decided_by,
5967
+ keystroke_status,
5968
+ screen_fingerprint,
5969
+ redacted_screen_tail,
5970
+ debug_lines
5971
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
5972
+ `;
5973
+
5974
+ // Bind args for one approval_observations row. SHARED by the single-row insert and the batch
5975
+ // insert so per-row semantics (tail trimming, debug cap) can never diverge between the two paths.
5976
+ function _bindApprovalObservationRow({
5284
5977
  sessionId,
5285
5978
  providerId,
5286
5979
  source,
@@ -5304,23 +5997,7 @@ function addApprovalObservation({
5304
5997
  const _tailMax = _keepFullTail
5305
5998
  ? _APPROVAL_OBS_TAIL_MAX_GATED
5306
5999
  : _APPROVAL_OBS_TAIL_MAX_UNGATED;
5307
- const result = getDb().prepare(`
5308
- INSERT INTO approval_observations (
5309
- session_id,
5310
- provider_id,
5311
- source,
5312
- raw_detected,
5313
- gated,
5314
- gate_reason,
5315
- parse_status,
5316
- policy_decision,
5317
- decided_by,
5318
- keystroke_status,
5319
- screen_fingerprint,
5320
- redacted_screen_tail,
5321
- debug_lines
5322
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
5323
- `).run(
6000
+ return [
5324
6001
  sessionId || '',
5325
6002
  providerId || '',
5326
6003
  source || '',
@@ -5334,10 +6011,36 @@ function addApprovalObservation({
5334
6011
  screenFingerprint || '',
5335
6012
  String(redactedScreenTail || '').slice(-_tailMax),
5336
6013
  debug.slice(-2000),
5337
- );
6014
+ ];
6015
+ }
6016
+
6017
+ function addApprovalObservation(observation = {}) {
6018
+ const result = getDb().prepare(_APPROVAL_OBS_INSERT_SQL).run(..._bindApprovalObservationRow(observation));
5338
6019
  return result.lastInsertRowid;
5339
6020
  }
5340
6021
 
6022
+ // Coalesced sink for the approval-observation write path: insert N observations in ONE
6023
+ // transaction (one write-lock acquire / one commit) with byte-identical per-row semantics to
6024
+ // addApprovalObservation. The single-threaded db-owner worker was flooded by one INSERT op per
6025
+ // event (~20k/day); batching collapses that to a few ops/flush-window. Falsy entries are skipped;
6026
+ // returns the number of rows inserted.
6027
+ function addApprovalObservationsBatch(entries) {
6028
+ if (!Array.isArray(entries) || entries.length === 0) return 0;
6029
+ const valid = entries.filter((e) => e && typeof e === 'object');
6030
+ if (valid.length === 0) return 0;
6031
+ const d = getDb();
6032
+ const stmt = d.prepare(_APPROVAL_OBS_INSERT_SQL);
6033
+ const insertAll = d.transaction((rows) => {
6034
+ let inserted = 0;
6035
+ for (const obs of rows) {
6036
+ stmt.run(..._bindApprovalObservationRow(obs));
6037
+ inserted++;
6038
+ }
6039
+ return inserted;
6040
+ });
6041
+ return insertAll(valid);
6042
+ }
6043
+
5341
6044
  // Retention for approval_observations. The approver records ~20k observations/day, each
5342
6045
  // historically carrying a ~4KB screen tail, with no pruning — the table had grown to
5343
6046
  // 267MB. This caps it by age and by row count. Heavy DELETE → run on the db-owner worker
@@ -7259,6 +7962,9 @@ function replaceSessionMessages(sessionId, messages) {
7259
7962
  // transcript timestamp is available, advance last_message_at without allowing
7260
7963
  // partial rewrites or compacted sources to move activity backward.
7261
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);
7262
7968
  try {
7263
7969
  d.prepare('UPDATE session_conversations SET extracted_source_len = ? WHERE ctm_session_id = ?')
7264
7970
  .run(Number(sourceLen) || 0, ctmSessionId);
@@ -7432,6 +8138,42 @@ function replaceSessionMessageRows(sessionId, messages) {
7432
8138
  return normalized.length;
7433
8139
  }
7434
8140
 
8141
+ // Incremental, resumable backfill of ONE session's rows in a bounded chunk. The background
8142
+ // migration calls this once per session per scheduler tick so a single giant session (16k+
8143
+ // messages) is never rewritten in one write-lock-holding transaction — it fills in index order
8144
+ // across ticks. The store is dense and keyed by message_index, so INSERT OR IGNORE makes this
8145
+ // idempotent: re-running resumes from the existing row count (passed as startIndex) and never
8146
+ // double-writes. extracted_source_len (the render gate's target) is only stamped when the final
8147
+ // chunk lands, so a half-filled session stays on the blob path (sessionContentRowsAvailable=false)
8148
+ // until it is 100% backfilled. Returns {written, nextIndex, complete}.
8149
+ function appendSessionMessageRowsChunk(sessionId, messages, startIndex = 0, maxCount = 0) {
8150
+ if (!sessionId || !_tableExists('session_message_rows')) {
8151
+ return { written: 0, nextIndex: Number(startIndex) || 0, complete: false };
8152
+ }
8153
+ const d = getDb();
8154
+ const normalized = Array.isArray(messages) ? messages : [];
8155
+ const start = Math.max(0, Math.min(normalized.length, Number(startIndex) || 0));
8156
+ const cap = Number(maxCount) > 0 ? Number(maxCount) : (normalized.length - start);
8157
+ const end = Math.min(normalized.length, start + Math.max(1, cap));
8158
+ const insertRow = d.prepare(
8159
+ 'INSERT OR IGNORE INTO session_message_rows (ctm_session_id, message_index, role, text, timestamp, meta) VALUES (?, ?, ?, ?, ?, ?)'
8160
+ );
8161
+ const writeRow = (i) => {
8162
+ const m = normalized[i] || {};
8163
+ return insertRow.run(
8164
+ sessionId, i, String(m.role || ''),
8165
+ String(m.text != null ? m.text : (m.content != null ? m.content : '')),
8166
+ m.timestamp != null ? String(m.timestamp) : '',
8167
+ _messageRowMeta(m)
8168
+ );
8169
+ };
8170
+ let written = 0;
8171
+ d.transaction(() => { for (let i = start; i < end; i++) { if (writeRow(i).changes > 0) written++; } })();
8172
+ const complete = end >= normalized.length;
8173
+ if (complete) _setExtractedSourceLen(d, sessionId, normalized.length, _lastMessageAtFromMessages(normalized));
8174
+ return { written, nextIndex: end, complete };
8175
+ }
8176
+
7435
8177
  // O(Δ) append of NEW messages after a verified-dense, unchanged-prefix base. Unlike
7436
8178
  // replaceSessionMessageRows this NEVER loads or rewrites the base array, so it stays O(Δ) for an
7437
8179
  // active multi-thousand-message session (that full-array load + re-process was the giant-transcript
@@ -7558,8 +8300,10 @@ function appendSessionConversation({
7558
8300
  function getSessionMessagesArray(sessionId, { fallbackToBlob = true } = {}) {
7559
8301
  const d = getDb();
7560
8302
  if (_tableExists('session_message_rows')) {
7561
- const rows = d.prepare('SELECT role, text, timestamp, meta FROM session_message_rows WHERE ctm_session_id = ? ORDER BY message_index ASC').all(sessionId);
7562
- 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;
7563
8307
  }
7564
8308
  if (fallbackToBlob) {
7565
8309
  try {
@@ -7573,16 +8317,40 @@ function getSessionMessagesArray(sessionId, { fallbackToBlob = true } = {}) {
7573
8317
  // True only when the faithful rows can serve this session: present AND fully backfilled
7574
8318
  // (row count covers the conversation's extracted length). A half-migrated session returns
7575
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 || '')); }
7576
8334
  function sessionContentRowsAvailable(sessionId) {
7577
8335
  try {
7578
8336
  const d = getDb();
7579
8337
  if (!_tableExists('session_message_rows')) return false;
7580
- const cnt = Number(d.prepare('SELECT COUNT(*) AS n FROM session_message_rows WHERE ctm_session_id = ?').get(sessionId).n);
7581
- 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).
7582
8340
  const conv = d.prepare('SELECT extracted_source_len FROM session_conversations WHERE ctm_session_id = ?').get(sessionId);
7583
8341
  const expected = conv ? Number(conv.extracted_source_len) : 0;
7584
- if (!(expected > 0) || cnt < expected) return false; // unknown HWM or half-migrated → blob path
7585
- 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;
7586
8354
  } catch { return false; }
7587
8355
  }
7588
8356
 
@@ -7704,8 +8472,15 @@ function runContentRowsBackfillSweep(options = {}) {
7704
8472
  const limit = Math.max(1, Math.min(500, Number(o.limit) || 25));
7705
8473
  const budgetMs = Math.max(0, Number(o.budgetMs ?? process.env.CTM_ROWS_BACKFILL_BUDGET_MS ?? 250));
7706
8474
  const maxRows = Math.max(0, Number(o.maxRows ?? process.env.CTM_ROWS_BACKFILL_MAX_ROWS ?? 3000));
8475
+ // Per-session row cap: migrate a giant session in bounded slices across ticks (resumable) so one
8476
+ // session can't hold the write lock for its whole length. 0 = whole-session (legacy/tests).
8477
+ const maxRowsPerSession = Math.max(0, Number(o.maxRowsPerSession ?? process.env.CTM_ROWS_BACKFILL_MAX_ROWS_PER_SESSION ?? 2000));
7707
8478
  const bf = require('./lib/session-content-backfill');
7708
- return bf.runContentRowsBackfillSweep(getDb(), replaceSessionMessageRows, { limit, budgetMs, maxRows });
8479
+ return bf.runContentRowsBackfillSweep(getDb(), replaceSessionMessageRows, {
8480
+ limit, budgetMs, maxRows, maxRowsPerSession,
8481
+ appendChunk: appendSessionMessageRowsChunk,
8482
+ getRowCount: (id) => countSessionMessageRows(id),
8483
+ });
7709
8484
  }
7710
8485
 
7711
8486
  // Migration completeness for GET /api/ctm/session-rows-status (+ the cutover gate).
@@ -7733,9 +8508,28 @@ function _messageFreshnessStatsForId(id, { userOnly = false } = {}) {
7733
8508
  const d = getDb();
7734
8509
  const bindId = String(id);
7735
8510
  const whereRole = userOnly ? " AND role = 'user'" : '';
7736
- 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(
7737
8522
  `SELECT COALESCE(MAX(message_index), -1) AS maxIndex, COUNT(*) AS rows FROM ${table} WHERE ctm_session_id = ?${whereRole}`
7738
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);
7739
8533
 
7740
8534
  // The row store is the default read source, but migration and tests can leave
7741
8535
  // some sessions present only in the legacy filtered index. Pick per session
@@ -8180,10 +8974,10 @@ function getLatestAgentSessionForCtm(ctmSessionId, options = {}) {
8180
8974
  return null;
8181
8975
  }
8182
8976
 
8183
- function getSessionConversationSourceIds(id, options = {}) {
8977
+ function getSessionConversationSourceIds(id, options = {}, db) {
8184
8978
  const cleanId = String(id || '').trim();
8185
8979
  if (!cleanId) return [];
8186
- const identity = getSessionIdentity(cleanId);
8980
+ const identity = getSessionIdentity(cleanId, db);
8187
8981
  const ids = [];
8188
8982
  const add = (value) => {
8189
8983
  const s = String(value || '').trim();
@@ -8210,10 +9004,13 @@ function getSessionConversationSourceIds(id, options = {}) {
8210
9004
  * lifecycle/restore table; orphan recovery can use it explicitly, but normal
8211
9005
  * reads must not treat it as a second mapping source.
8212
9006
  */
8213
- function getSessionIdentity(id) {
9007
+ function getSessionIdentity(id, db) {
8214
9008
  const cleanId = String(id || '').trim();
8215
9009
  if (!cleanId) return null;
8216
- const d = getDb();
9010
+ // db-injectable: the read-pool worker passes its own read-only connection so the
9011
+ // prompt-index input resolution runs off the main loop (parity by construction —
9012
+ // same query bodies, different handle). Existing callers pass nothing → singleton.
9013
+ const d = db || getDb();
8217
9014
 
8218
9015
  let ctm = d.prepare('SELECT * FROM ctm_sessions WHERE id = ?').get(cleanId) || null;
8219
9016
  let ctmSessionId = ctm ? ctm.id : '';
@@ -8710,14 +9507,15 @@ function upsertAgentSessionIdentity(agentSessionId, data = {}) {
8710
9507
  parent_agent_session_id: lineage.parent_agent_session_id,
8711
9508
  agent_nickname: lineage.agent_nickname,
8712
9509
  agent_role: lineage.agent_role,
9510
+ forked_from_desktop_uuid: data.forkedFromDesktopUuid || data.forked_from_desktop_uuid || '',
8713
9511
  };
8714
9512
  d.prepare(`
8715
9513
  INSERT INTO agent_sessions (agent_session_id, ctm_session_id, provider, provider_resume_id, project_path, jsonl_path,
8716
9514
  first_message, file_size, modified_at, hostname, model, git_branch, user_msg_count, slug,
8717
- 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)
8718
9516
  VALUES (@agent_session_id, @ctm_session_id, @provider, @provider_resume_id, @project_path, @jsonl_path,
8719
9517
  @first_message, @file_size, @modified_at, @hostname, @model, @git_branch, @user_msg_count, @slug,
8720
- @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)
8721
9519
  ON CONFLICT(agent_session_id) DO UPDATE SET
8722
9520
  ctm_session_id = COALESCE(agent_sessions.ctm_session_id, excluded.ctm_session_id),
8723
9521
  provider = COALESCE(NULLIF(excluded.provider, ''), agent_sessions.provider),
@@ -8736,10 +9534,40 @@ function upsertAgentSessionIdentity(agentSessionId, data = {}) {
8736
9534
  parent_agent_session_id = COALESCE(NULLIF(excluded.parent_agent_session_id, ''), agent_sessions.parent_agent_session_id),
8737
9535
  agent_nickname = COALESCE(NULLIF(excluded.agent_nickname, ''), agent_sessions.agent_nickname),
8738
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),
8739
9538
  updated_at = datetime('now')
8740
9539
  `).run(params);
8741
9540
  }
8742
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
+
8743
9571
  function setSessionStar(id, starred) {
8744
9572
  // Try ctm_sessions first
8745
9573
  const result = getDb().prepare("UPDATE ctm_sessions SET starred = ?, updated_at = datetime('now') WHERE id = ?")
@@ -9558,8 +10386,8 @@ function deleteCtmSession(ctmSessionId) {
9558
10386
 
9559
10387
  // Legacy compatibility: upsertSessionIndex is now a no-op (session_index dropped)
9560
10388
  function upsertSessionIndex() {}
9561
- function resolveSessionId(id) {
9562
- const identity = getSessionIdentity(id);
10389
+ function resolveSessionId(id, db) {
10390
+ const identity = getSessionIdentity(id, db);
9563
10391
  if (!identity) return null;
9564
10392
  return {
9565
10393
  ctm_session_id: identity.ctm_session_id,
@@ -9575,6 +10403,12 @@ module.exports = {
9575
10403
  getWriteLockStats, resetWriteLockStats,
9576
10404
  getStorageRisk,
9577
10405
  getSqliteDriverStatus,
10406
+ ensureRuntimeKernelTables,
10407
+ appendRuntimeEvent, listRuntimeEvents, latestRuntimeEventOfTypes,
10408
+ upsertRuntimeTurn, getRuntimeTurn, listRuntimeTurns,
10409
+ upsertRuntimeInputEnvelope, updateRuntimeInputEnvelope, getRuntimeInputEnvelope, listRuntimeInputEnvelopes,
10410
+ upsertRuntimeRouteSnapshot, getLatestRuntimeRouteSnapshot,
10411
+ upsertRuntimeApproval, resolveRuntimeApproval, listRuntimeApprovals,
9578
10412
  getSetting, getSettingsByPrefix, setSetting,
9579
10413
  addSessionDiagnostic, flushSessionDiagnostics, listSessionDiagnostics,
9580
10414
  upsertRuntimeTask, updateRuntimeTask, getRuntimeTask, listRuntimeTasks, deleteRuntimeTasksForSession,
@@ -9587,8 +10421,8 @@ module.exports = {
9587
10421
  listPermissionLog, addPermissionLog,
9588
10422
  getAlwaysAskTools, setAlwaysAsk,
9589
10423
  importSessionConversation, appendSessionConversation, appendSessionMessageRows, listSessionConversations, getSessionConversation, getSessionConversationMeta, getSessionConversationMessages, updateSessionModel,
9590
- replaceSessionMessageRows, getSessionMessagesArray, sessionContentRowsAvailable, getSessionMessagesPage, getSessionMessagesTurnPage, countSessionMessageRows,
9591
- runContentRowsBackfillSweep, getSessionRowsStatus, retireLegacyStores, runVacuum,
10424
+ replaceSessionMessageRows, appendSessionMessageRowsChunk, getSessionMessagesArray, sessionContentRowsAvailable, getSessionMessagesPage, getSessionMessagesTurnPage, countSessionMessageRows,
10425
+ runContentRowsBackfillSweep, getSessionRowsStatus, retireLegacyStores, runVacuum, _blobRetirementActive,
9592
10426
  updateSessionTokens, getSessionTokens,
9593
10427
  pickDisplayTitle,
9594
10428
  getSessionTitle, setSessionTitle, isSessionUserRenamed, getAllSessionTitles,
@@ -9601,7 +10435,7 @@ module.exports = {
9601
10435
  listAutoApprovals, upsertAutoApproval, toggleAutoApproval, deleteAutoApproval, getEnabledAutoApprovals,
9602
10436
  listApprovalRules, upsertApprovalRule, findApprovalRuleBySignature, toggleApprovalRule, deleteApprovalRule, incrementApprovalRuleMatch,
9603
10437
  addApprovalDecision, listApprovalDecisions, resolveApprovalDecision, getPendingEscalations,
9604
- addApprovalObservation, listApprovalObservations, pruneApprovalObservations, pruneApprovalAiRefinementRules,
10438
+ addApprovalObservation, addApprovalObservationsBatch, listApprovalObservations, pruneApprovalObservations, pruneApprovalAiRefinementRules,
9605
10439
  getApprovalRescuePattern, saveApprovalRescuePattern, listApprovalRescuePatterns,
9606
10440
  getApprovalAiRefinementRule, saveApprovalAiRefinementRule, listApprovalAiRefinementRules,
9607
10441
  findActiveApprovalAiRefinementRules, saveApprovalAiRefinementWarning, listApprovalAiRefinementWarnings,
@@ -9639,7 +10473,7 @@ module.exports = {
9639
10473
  setSessionTitleNew, setSessionTitleStatusNew, getSessionTitleNew, getSessionTitlesByIds, getSessionDisplayTitleInfo, isUserSessionTitleCandidate, promoteStartupTaskTitleIfUserNamed, repairSessionTitlesFromStartupTasks, getAllSessionsData,
9640
10474
  repairCodexSessionMetadataFromConversations, repairCodexRolloutAgentIdentities, detachPromotedProviderChildSessions,
9641
10475
  listProviderChildAgentOwnerMappings,
9642
- getAgentSessions, getAgentSession, deleteCtmSession,
10476
+ getAgentSessions, getAgentSession, getForkForDesktopUuid, listDesktopForks, deleteCtmSession,
9643
10477
  // Schema version
9644
10478
  getSchemaVersion,
9645
10479
  };