create-walle 0.9.25 → 0.9.26

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 (179) hide show
  1. package/README.md +8 -0
  2. package/bin/create-walle.js +815 -45
  3. package/package.json +2 -2
  4. package/template/bin/ctm-dev-cleanup.js +90 -4
  5. package/template/bin/ctm-launch.sh +49 -1
  6. package/template/bin/dev.sh +45 -1
  7. package/template/bin/ensure-stable-node.js +132 -0
  8. package/template/bin/install-service.sh +9 -0
  9. package/template/claude-task-manager/api-prompts.js +899 -119
  10. package/template/claude-task-manager/approval-agent.js +360 -40
  11. package/template/claude-task-manager/bin/ctm-disclaim.c +42 -0
  12. package/template/claude-task-manager/bin/ctm-hotkey.swift +67 -81
  13. package/template/claude-task-manager/bin/ctm-screen-auth.swift +37 -0
  14. package/template/claude-task-manager/bin/install-hotkey.sh +97 -49
  15. package/template/claude-task-manager/bin/restart-ctm.sh +14 -0
  16. package/template/claude-task-manager/db.js +399 -48
  17. package/template/claude-task-manager/docs/approval-hook-sandbox.md +84 -0
  18. package/template/claude-task-manager/docs/codex-app-server-approvals.md +72 -0
  19. package/template/claude-task-manager/docs/codex-native-sandbox.md +47 -0
  20. package/template/claude-task-manager/docs/prompt-editing-tree-design.md +18 -1
  21. package/template/claude-task-manager/lib/approval-hook.js +200 -0
  22. package/template/claude-task-manager/lib/approval-self-adapt.js +1 -0
  23. package/template/claude-task-manager/lib/auth-rules.js +11 -0
  24. package/template/claude-task-manager/lib/background-llm.js +32 -4
  25. package/template/claude-task-manager/lib/codesign-identity.js +140 -0
  26. package/template/claude-task-manager/lib/codex-app-server-client.js +119 -0
  27. package/template/claude-task-manager/lib/codex-approval-bridge.js +118 -0
  28. package/template/claude-task-manager/lib/codex-history-terminal-renderer.js +571 -0
  29. package/template/claude-task-manager/lib/codex-paths.js +73 -0
  30. package/template/claude-task-manager/lib/codex-rollout-snapshot.js +164 -0
  31. package/template/claude-task-manager/lib/codex-rollout-tail.js +72 -0
  32. package/template/claude-task-manager/lib/codex-sandbox-args.js +47 -0
  33. package/template/claude-task-manager/lib/coding-agent-models.js +118 -71
  34. package/template/claude-task-manager/lib/command-targets.js +163 -0
  35. package/template/claude-task-manager/lib/conversation-tail-merge.js +61 -19
  36. package/template/claude-task-manager/lib/db-owner-worker-client.js +29 -1
  37. package/template/claude-task-manager/lib/escalation-review.js +80 -3
  38. package/template/claude-task-manager/lib/flow-control.js +52 -0
  39. package/template/claude-task-manager/lib/fs-watcher.js +24 -15
  40. package/template/claude-task-manager/lib/ingest-cooldown.js +68 -0
  41. package/template/claude-task-manager/lib/jsonl-conversation-parser.js +8 -4
  42. package/template/claude-task-manager/lib/launchd-recovery.js +92 -0
  43. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +207 -52
  44. package/template/claude-task-manager/lib/mobile-push-store.js +7 -0
  45. package/template/claude-task-manager/lib/model-overview-brain-fallback.js +102 -1
  46. package/template/claude-task-manager/lib/model-overview-cache.js +1 -0
  47. package/template/claude-task-manager/lib/oauth-proxy-supervisor.js +2 -1
  48. package/template/claude-task-manager/lib/perf-tracker.js +29 -2
  49. package/template/claude-task-manager/lib/permission-match.js +146 -16
  50. package/template/claude-task-manager/lib/project-slug.js +33 -0
  51. package/template/claude-task-manager/lib/prompt-intent.js +51 -4
  52. package/template/claude-task-manager/lib/read-pool-client.js +48 -3
  53. package/template/claude-task-manager/lib/real-node.js +73 -0
  54. package/template/claude-task-manager/lib/runtime-work-registry.js +131 -14
  55. package/template/claude-task-manager/lib/session-content-backfill.js +24 -5
  56. package/template/claude-task-manager/lib/session-diagnostics-batch.js +87 -0
  57. package/template/claude-task-manager/lib/session-history.js +5 -7
  58. package/template/claude-task-manager/lib/session-host-manager.js +19 -0
  59. package/template/claude-task-manager/lib/session-jobs.js +6 -0
  60. package/template/claude-task-manager/lib/session-message-response-cache.js +89 -0
  61. package/template/claude-task-manager/lib/session-messages-page.js +211 -0
  62. package/template/claude-task-manager/lib/session-messages-projection.js +170 -0
  63. package/template/claude-task-manager/lib/session-standup.js +8 -0
  64. package/template/claude-task-manager/lib/session-timeline-summary.js +16 -2
  65. package/template/claude-task-manager/lib/session-token-usage.js +30 -8
  66. package/template/claude-task-manager/lib/session-workspace-binding.js +29 -15
  67. package/template/claude-task-manager/lib/storage-migration.js +2 -1
  68. package/template/claude-task-manager/lib/transcript-store.js +179 -12
  69. package/template/claude-task-manager/lib/walle-ctm-history.js +298 -11
  70. package/template/claude-task-manager/lib/walle-permission-reply.js +49 -0
  71. package/template/claude-task-manager/lib/walle-session-cache.js +22 -1
  72. package/template/claude-task-manager/lib/walle-supervisor.js +42 -3
  73. package/template/claude-task-manager/package.json +5 -2
  74. package/template/claude-task-manager/prompt-harvest.js +31 -11
  75. package/template/claude-task-manager/providers/claude-code.js +29 -1
  76. package/template/claude-task-manager/providers/codex.js +13 -1
  77. package/template/claude-task-manager/public/css/setup.css +11 -0
  78. package/template/claude-task-manager/public/css/walle-session.css +132 -4
  79. package/template/claude-task-manager/public/css/walle.css +89 -0
  80. package/template/claude-task-manager/public/icon-16.png +0 -0
  81. package/template/claude-task-manager/public/icon-32.png +0 -0
  82. package/template/claude-task-manager/public/icon-512.png +0 -0
  83. package/template/claude-task-manager/public/index.html +2483 -165
  84. package/template/claude-task-manager/public/js/activation-render-check.js +55 -0
  85. package/template/claude-task-manager/public/js/flow-control-policy.js +52 -0
  86. package/template/claude-task-manager/public/js/message-renderer.js +60 -1
  87. package/template/claude-task-manager/public/js/prompts.js +13 -1
  88. package/template/claude-task-manager/public/js/session-status-precedence.js +9 -3
  89. package/template/claude-task-manager/public/js/setup.js +54 -10
  90. package/template/claude-task-manager/public/js/stream-resize-policy.js +80 -0
  91. package/template/claude-task-manager/public/js/stream-view.js +78 -0
  92. package/template/claude-task-manager/public/js/terminal-reconciler.js +52 -2
  93. package/template/claude-task-manager/public/js/tool-state.js +155 -0
  94. package/template/claude-task-manager/public/js/walle-session.js +887 -326
  95. package/template/claude-task-manager/public/js/walle.js +306 -195
  96. package/template/claude-task-manager/public/m/app.css +1 -0
  97. package/template/claude-task-manager/public/m/app.js +33 -3
  98. package/template/claude-task-manager/queue-engine.js +45 -1
  99. package/template/claude-task-manager/server.js +3367 -540
  100. package/template/claude-task-manager/workers/approval-blocklist.js +130 -17
  101. package/template/claude-task-manager/workers/db-owner-worker.js +31 -1
  102. package/template/claude-task-manager/workers/read-pool-worker.js +92 -5
  103. package/template/claude-task-manager/workers/session-host-process.js +10 -0
  104. package/template/claude-task-manager/workers/state-detectors/codex.js +58 -7
  105. package/template/package.json +2 -3
  106. package/template/shared/icons/AppIcon-ctm.icns +0 -0
  107. package/template/shared/icons/AppIcon-walle.icns +0 -0
  108. package/template/wall-e/agent.js +139 -18
  109. package/template/wall-e/api-walle.js +201 -22
  110. package/template/wall-e/bin/train-gemma-e4b-tooluse.js +1981 -0
  111. package/template/wall-e/brain.js +1049 -39
  112. package/template/wall-e/chat.js +427 -86
  113. package/template/wall-e/coding/acceptance-contract.js +26 -1
  114. package/template/wall-e/coding/action-memory-policy.js +353 -0
  115. package/template/wall-e/coding/action-memory-store.js +814 -0
  116. package/template/wall-e/coding/initial-messages.js +197 -0
  117. package/template/wall-e/coding/no-progress-guard.js +327 -0
  118. package/template/wall-e/coding/permission-service.js +88 -22
  119. package/template/wall-e/coding/session-workspaces.js +81 -0
  120. package/template/wall-e/coding/shell-sandbox.js +124 -0
  121. package/template/wall-e/coding/stream-processor.js +63 -2
  122. package/template/wall-e/coding/tool-execution-controller.js +14 -1
  123. package/template/wall-e/coding/tool-registry.js +1 -1
  124. package/template/wall-e/coding/transcript-writer.js +3 -0
  125. package/template/wall-e/coding-orchestrator.js +636 -35
  126. package/template/wall-e/coding-prompts.js +51 -2
  127. package/template/wall-e/docs/model-routing-policy.md +59 -0
  128. package/template/wall-e/docs/walle-shell-sandbox.md +61 -0
  129. package/template/wall-e/extraction/knowledge-extractor.js +76 -23
  130. package/template/wall-e/http/chat-api.js +30 -12
  131. package/template/wall-e/http/model-admin.js +93 -1
  132. package/template/wall-e/lib/background-lanes.js +133 -0
  133. package/template/wall-e/lib/boot-profile.js +11 -0
  134. package/template/wall-e/lib/brain-owner-worker-client.js +324 -0
  135. package/template/wall-e/lib/brain-read-pool-client.js +311 -0
  136. package/template/wall-e/lib/diagnostics-flags.js +87 -0
  137. package/template/wall-e/lib/event-loop-monitor.js +74 -3
  138. package/template/wall-e/lib/mcp-integration.js +7 -1
  139. package/template/wall-e/lib/real-node.js +98 -0
  140. package/template/wall-e/lib/runtime-health.js +206 -0
  141. package/template/wall-e/lib/runtime-worker-pool.js +101 -0
  142. package/template/wall-e/lib/scheduler-worker-jobs.js +231 -0
  143. package/template/wall-e/lib/scheduler.js +446 -17
  144. package/template/wall-e/lib/service-health.js +61 -2
  145. package/template/wall-e/lib/service-readiness.js +258 -0
  146. package/template/wall-e/lib/usage.js +152 -0
  147. package/template/wall-e/lib/worker-thread-pool.js +389 -0
  148. package/template/wall-e/llm/client.js +81 -4
  149. package/template/wall-e/llm/default-fallback.js +54 -8
  150. package/template/wall-e/llm/mlx.js +536 -73
  151. package/template/wall-e/llm/mlx.plugin.json +1 -1
  152. package/template/wall-e/llm/ollama.js +342 -43
  153. package/template/wall-e/llm/provider-error.js +18 -1
  154. package/template/wall-e/llm/provider-health-state.js +176 -0
  155. package/template/wall-e/llm/routing-policy.js +796 -0
  156. package/template/wall-e/llm/supported-models.js +5 -0
  157. package/template/wall-e/loops/tasks.js +60 -14
  158. package/template/wall-e/loops/think.js +89 -24
  159. package/template/wall-e/mcp-server.js +192 -28
  160. package/template/wall-e/server.js +32 -7
  161. package/template/wall-e/skills/script-skill-runner.js +8 -1
  162. package/template/wall-e/skills/skill-planner.js +64 -1
  163. package/template/wall-e/tools/builtin-middleware.js +67 -2
  164. package/template/wall-e/tools/local-tools.js +116 -26
  165. package/template/wall-e/tools/permission-checker.js +52 -4
  166. package/template/wall-e/tools/permission-rules.js +36 -0
  167. package/template/wall-e/tools/shell-analyzer.js +46 -1
  168. package/template/wall-e/training/gemma-e4b-qlora.js +314 -0
  169. package/template/wall-e/training/real-trajectory-miner.js +2617 -0
  170. package/template/wall-e/training/replay-eval-analysis.js +151 -0
  171. package/template/wall-e/training/run-shell-command-selector.js +277 -0
  172. package/template/wall-e/training/tool-sft-dataset.js +312 -0
  173. package/template/wall-e/training/tool-sft-renderers.js +144 -0
  174. package/template/wall-e/training/tool-trace-harvester.js +1440 -0
  175. package/template/wall-e/training/trajectory-action-selector.js +364 -0
  176. package/template/wall-e/weather-runtime.js +232 -0
  177. package/template/wall-e/workers/brain-owner-worker.js +162 -0
  178. package/template/wall-e/workers/brain-read-worker.js +148 -0
  179. package/template/wall-e/workers/runtime-worker.js +145 -0
@@ -8,7 +8,7 @@ const { threadId, isMainThread } = require('node:worker_threads');
8
8
  const { execFileSync } = require('child_process');
9
9
  const { normalizeAgentType } = require('./lib/agent-capabilities');
10
10
  const { codexRolloutIdFromPath, readCodexRolloutMetadata } = require('./lib/session-history');
11
- const { ensureTranscriptTables } = require('./lib/transcript-store');
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
14
  const {
@@ -693,6 +693,26 @@ function _openDbConnection(dbPath) {
693
693
  d.pragma('journal_mode = WAL');
694
694
  d.pragma(`busy_timeout = ${busyTimeoutMs}`);
695
695
  d.pragma('foreign_keys = ON');
696
+ // synchronous=NORMAL (safe under WAL): the default FULL fsync's on EVERY commit, which is the
697
+ // bulk of how long a write txn holds the cross-process write lock — and that hold is what parks
698
+ // the main thread OFF-CPU (the "(unknown)" lock-wait freezes that stall Codex keystrokes). Under
699
+ // WAL, NORMAL only fsync's at checkpoint, not per commit; the documented trade-off is that a
700
+ // power-loss/OS-crash can lose the last few committed txns (NOT corrupt the db). For a local
701
+ // session cache that's continuously re-derivable from the JSONL transcripts, that's an easy win.
702
+ // Override with CTM_SQLITE_SYNCHRONOUS=FULL to restore the old behavior.
703
+ const _sync = String(process.env.CTM_SQLITE_SYNCHRONOUS || 'NORMAL').toUpperCase();
704
+ if (['OFF', 'NORMAL', 'FULL', 'EXTRA'].includes(_sync)) d.pragma(`synchronous = ${_sync}`);
705
+ // Disable SQLite's INLINE auto-checkpoint (default 1000 pages). Otherwise any commit that
706
+ // pushes the WAL past the threshold runs a PASSIVE checkpoint SYNCHRONOUSLY on that connection,
707
+ // holding CTM's cross-process write lock for the checkpoint I/O while every other writer stalls.
708
+ // Once the WAL bloats (reader-pool snapshots pin frames so PASSIVE can't truncate, observed 49MB)
709
+ // that inline checkpoint scans the whole WAL — measured 457ms on a trivial cursor upsert and up to
710
+ // 3s on the periodic pass — landing the cost on a random user-facing write (the off-CPU lock-wait
711
+ // freezes). We OWN checkpointing explicitly off the event loop: the db-owner worker runs a periodic
712
+ // TRUNCATE (bounds the file) plus flushWal PASSIVE nudges after critical writes. Set
713
+ // CTM_WAL_AUTOCHECKPOINT to a positive page count to restore SQLite's inline backstop.
714
+ const _autoCkpt = Number(process.env.CTM_WAL_AUTOCHECKPOINT);
715
+ d.pragma(`wal_autocheckpoint = ${Number.isFinite(_autoCkpt) && _autoCkpt >= 0 ? Math.floor(_autoCkpt) : 0}`);
696
716
  return d;
697
717
  }
698
718
 
@@ -1127,6 +1147,13 @@ function runMigrations() {
1127
1147
  getDb().exec('ALTER TABLE perm_rules ADD COLUMN always_ask INTEGER DEFAULT 0');
1128
1148
  }
1129
1149
 
1150
+ // Add exceptions column to perm_rules (migration) — allow-exceptions attached
1151
+ // to deny rules ("deny rm except under /tmp"); JSON array, NULL = none.
1152
+ const hasExceptions = getDb().prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='perm_rules'").get();
1153
+ if (hasExceptions && !hasExceptions.sql.includes('exceptions')) {
1154
+ getDb().exec('ALTER TABLE perm_rules ADD COLUMN exceptions TEXT DEFAULT NULL');
1155
+ }
1156
+
1130
1157
  // Collapse CTM permission rules into a single GLOBAL config (decoupled from
1131
1158
  // per-project Claude/Codex settings). CTM owns permissions in its own DB and
1132
1159
  // applies them to all sessions; the project column is legacy. Idempotent: runs
@@ -1305,6 +1332,17 @@ function runMigrations() {
1305
1332
  getDb().exec("ALTER TABLE approval_decisions ADD COLUMN command_signature TEXT DEFAULT ''");
1306
1333
  }
1307
1334
 
1335
+ // When a Wall-E coding turn parks on approval it registers the park here so it
1336
+ // surfaces in the shared "Pending" tab and survives reload. walle_request_id is
1337
+ // the Wall-E PermissionService request id (permId); resolving the row calls back
1338
+ // that id to unpark the live turn. walle_session_id scopes the cascade re-check.
1339
+ try {
1340
+ getDb().prepare("SELECT walle_request_id FROM approval_decisions LIMIT 1").get();
1341
+ } catch {
1342
+ getDb().exec("ALTER TABLE approval_decisions ADD COLUMN walle_request_id TEXT DEFAULT ''");
1343
+ getDb().exec("ALTER TABLE approval_decisions ADD COLUMN walle_session_id TEXT DEFAULT ''");
1344
+ }
1345
+
1308
1346
  // Approval observations are the pre-decision control-plane audit trail.
1309
1347
  // approval_decisions records what the agent did; this table records every
1310
1348
  // approval-shaped terminal screen, including prompts rejected by structural
@@ -1866,9 +1904,16 @@ function runMigrations() {
1866
1904
  // table-create block above only fires for fresh DBs; run this every init
1867
1905
  // (idempotent) so existing DBs get the index too. Turns a filtered partial
1868
1906
  // scan + sort into a pre-ordered index range scan.
1869
- getDb().exec(
1870
- "CREATE INDEX IF NOT EXISTS idx_session_messages_user_prompts ON session_messages(ctm_session_id, role, message_index);"
1871
- );
1907
+ // Guard on the column: a v0 DB still has session_messages.session_id at this
1908
+ // point the rename to ctm_session_id happens later in this same pass — so an
1909
+ // unguarded CREATE INDEX here throws "no such column: ctm_session_id" and aborts
1910
+ // the whole v0→current migration. migrateToV1() builds it itself right after
1911
+ // the rename so a migrating DB still gets the index in this same pass.
1912
+ if (_tableHasColumn('session_messages', 'ctm_session_id')) {
1913
+ getDb().exec(
1914
+ "CREATE INDEX IF NOT EXISTS idx_session_messages_user_prompts ON session_messages(ctm_session_id, role, message_index);"
1915
+ );
1916
+ }
1872
1917
 
1873
1918
  // --- FTS5 on session_messages ---
1874
1919
  const hasMsgFts = getDb().prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='session_messages_fts'").get();
@@ -1937,6 +1982,13 @@ function runMigrations() {
1937
1982
  // migration time instead of the first user search.
1938
1983
  try {
1939
1984
  ensureTranscriptTables(getDb());
1985
+ // Heal any transcript rows orphaned by the historical self-link (rollouts keyed by their
1986
+ // own agent_session_id instead of the owning ctm_session_id — splits a resumed session's
1987
+ // Conv tab). Idempotent + bounded; a no-op once clean. See healSelfLinkedTranscriptKeys.
1988
+ const healed = healSelfLinkedTranscriptKeys(getDb());
1989
+ if (healed && (healed.files || healed.events)) {
1990
+ console.log(`[db] healed self-linked transcript keys: sessions=${healed.sessions}, files=${healed.files}, events=${healed.events}`);
1991
+ }
1940
1992
  } catch (e) {
1941
1993
  console.error('[db] transcript row-store migration failed:', e.message);
1942
1994
  }
@@ -2911,6 +2963,10 @@ function migrateToV1() {
2911
2963
  const smCols = d.prepare("PRAGMA table_info(session_messages)").all();
2912
2964
  if (smCols.find(c => c.name === 'session_id')) {
2913
2965
  d.exec('ALTER TABLE session_messages RENAME COLUMN session_id TO ctm_session_id');
2966
+ // The "every init" index build earlier in runMigrations skips a v0 table
2967
+ // (column not renamed yet); build it here so a migrating DB gets the
2968
+ // covering index in this same pass instead of one boot later.
2969
+ d.exec('CREATE INDEX IF NOT EXISTS idx_session_messages_user_prompts ON session_messages(ctm_session_id, role, message_index)');
2914
2970
  }
2915
2971
 
2916
2972
  // session_analyses: session_id -> ctm_session_id (it is the PK)
@@ -3073,10 +3129,63 @@ function addSessionDiagnostic(sessionId, event, details = {}) {
3073
3129
  LIMIT 500
3074
3130
  )
3075
3131
  `).run(cleanSessionId, cleanSessionId);
3076
- flushWal();
3132
+ // No flushWal() here — the WAL is durable on commit, and the throttled
3133
+ // periodic checkpoint (flushWal trailing timer) owns flushing. Calling it on
3134
+ // every diagnostic write made the checkpoint contend with the write storm for
3135
+ // the single writer lock (see flushSessionDiagnostics for the batched path).
3077
3136
  return { id: result.lastInsertRowid, session_id: cleanSessionId, event: cleanEvent, ...payload };
3078
3137
  }
3079
3138
 
3139
+ // UTC timestamp string matching SQLite's datetime('now') format, so batched
3140
+ // inserts carry the same shape as the legacy per-row datetime('now') default.
3141
+ function _utcSqliteTimestamp(ts) {
3142
+ const d = ts != null ? new Date(ts) : new Date();
3143
+ const iso = Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString();
3144
+ return iso.replace('T', ' ').replace(/\.\d+Z$/, '');
3145
+ }
3146
+
3147
+ // Batched diagnostic writer: persist a coalesced array of diagnostics in ONE
3148
+ // transaction (one writer-lock acquire / one commit for the whole batch) and run
3149
+ // the per-session retention sweep at most once per affected session. Replaces the
3150
+ // per-event INSERT+DELETE+flushWal storm that saturated the single-writer DB under
3151
+ // heavy multi-session load. Sanitization + JSON happen OUTSIDE the txn to minimize
3152
+ // lock-hold time. `entries`: [{ sessionId, event, details, createdAt }].
3153
+ function flushSessionDiagnostics(entries) {
3154
+ const list = Array.isArray(entries) ? entries : [];
3155
+ const prepared = [];
3156
+ const sessions = new Set();
3157
+ for (const e of list) {
3158
+ if (!e) continue;
3159
+ const sid = String(e.sessionId || '').trim();
3160
+ if (!sid) continue;
3161
+ const event = String(e.event || '').trim() || 'event';
3162
+ const payload = _sanitizeDiagnosticDetails(e.details || {});
3163
+ prepared.push([sid, event, JSON.stringify(payload), e.createdAt || _utcSqliteTimestamp()]);
3164
+ sessions.add(sid);
3165
+ }
3166
+ if (!prepared.length) return { inserted: 0, sessionsSwept: 0 };
3167
+ const d = getDb();
3168
+ const insert = d.prepare(
3169
+ 'INSERT INTO session_diagnostics (session_id, event, details_json, created_at) VALUES (?, ?, ?, ?)'
3170
+ );
3171
+ const sweep = d.prepare(`
3172
+ DELETE FROM session_diagnostics
3173
+ WHERE session_id = ?
3174
+ AND id NOT IN (
3175
+ SELECT id FROM session_diagnostics
3176
+ WHERE session_id = ?
3177
+ ORDER BY id DESC
3178
+ LIMIT 500
3179
+ )
3180
+ `);
3181
+ const txn = d.transaction(() => {
3182
+ for (const row of prepared) insert.run(row[0], row[1], row[2], row[3]);
3183
+ for (const sid of sessions) sweep.run(sid, sid);
3184
+ });
3185
+ txn();
3186
+ return { inserted: prepared.length, sessionsSwept: sessions.size };
3187
+ }
3188
+
3080
3189
  function listSessionDiagnostics(sessionId, limit = 120) {
3081
3190
  const cleanSessionId = String(sessionId || '').trim();
3082
3191
  if (!cleanSessionId) return [];
@@ -4097,11 +4206,12 @@ function listPermRules({ project, listType } = {}) {
4097
4206
  return getDb().prepare(sql).all(...params);
4098
4207
  }
4099
4208
 
4100
- function addPermRule({ rule, listType, scope, project }) {
4209
+ function addPermRule({ rule, listType, scope, project, exceptions }) {
4101
4210
  getDb().prepare(
4102
- `INSERT OR IGNORE INTO perm_rules (rule, list_type, scope, project)
4103
- VALUES (?, ?, ?, ?)`
4104
- ).run(rule, listType || 'allow', scope || 'global', project || '__global__');
4211
+ `INSERT OR IGNORE INTO perm_rules (rule, list_type, scope, project, exceptions)
4212
+ VALUES (?, ?, ?, ?, ?)`
4213
+ ).run(rule, listType || 'allow', scope || 'global', project || '__global__',
4214
+ Array.isArray(exceptions) && exceptions.length ? JSON.stringify(exceptions) : null);
4105
4215
  }
4106
4216
 
4107
4217
  function removePermRule({ rule, listType, project }) {
@@ -4110,15 +4220,28 @@ function removePermRule({ rule, listType, project }) {
4110
4220
  ).run(rule, listType || 'allow', project || '__global__');
4111
4221
  }
4112
4222
 
4223
+ // Replace the exceptions array on one rule row. `exceptions` is a validated
4224
+ // array (the API layer validates shape/limits) or [] to clear. Returns the
4225
+ // number of rows updated (0 = rule not found).
4226
+ function setPermRuleExceptions({ rule, listType, project, exceptions }) {
4227
+ const json = Array.isArray(exceptions) && exceptions.length ? JSON.stringify(exceptions) : null;
4228
+ const res = getDb().prepare(
4229
+ 'UPDATE perm_rules SET exceptions = ? WHERE rule = ? AND list_type = ? AND project = ?'
4230
+ ).run(json, rule, listType || 'deny', project || '__global__');
4231
+ return res.changes;
4232
+ }
4233
+
4113
4234
  function bulkSetPermRules(rules) {
4114
- // rules: [{ rule, listType, scope, project }]
4235
+ // rules: [{ rule, listType, scope, project, exceptions? }]
4115
4236
  const txn = getDb().transaction(() => {
4116
4237
  getDb().exec('DELETE FROM perm_rules');
4117
4238
  const ins = getDb().prepare(
4118
- 'INSERT OR IGNORE INTO perm_rules (rule, list_type, scope, project) VALUES (?, ?, ?, ?)'
4239
+ 'INSERT OR IGNORE INTO perm_rules (rule, list_type, scope, project, exceptions) VALUES (?, ?, ?, ?, ?)'
4119
4240
  );
4120
4241
  for (const r of rules) {
4121
- ins.run(r.rule, r.listType || 'allow', r.scope || 'global', r.project || '__global__');
4242
+ const exc = Array.isArray(r.exceptions) && r.exceptions.length ? JSON.stringify(r.exceptions)
4243
+ : (typeof r.exceptions === 'string' && r.exceptions ? r.exceptions : null);
4244
+ ins.run(r.rule, r.listType || 'allow', r.scope || 'global', r.project || '__global__', exc);
4122
4245
  }
4123
4246
  });
4124
4247
  txn();
@@ -4244,6 +4367,12 @@ function _lastMessageAtFromMessages(messages) {
4244
4367
  }
4245
4368
 
4246
4369
  const _overCapImportLogged = new Set(); // session_id → already warned about an over-parse-cap import (log once)
4370
+ // session_id → message count at the last full token estimate. The estimate is an O(N) walk of the
4371
+ // WHOLE message array; recomputing it on every re-import of an actively-appending session (just to
4372
+ // nudge an APPROXIMATE (~) count by one message) is wasted work under the write lock. Only re-estimate
4373
+ // when the count grew past a delta (or the model changed); otherwise the upsert's COALESCE preserves
4374
+ // the prior stored value — exact recompute still happens on the live path for Claude/Codex.
4375
+ const _importTokenEstimateLen = new Map();
4247
4376
  function importSessionConversation({
4248
4377
  session_id, project_path, messages, user_msg_count, assistant_msg_count,
4249
4378
  search_messages,
@@ -4253,6 +4382,10 @@ function importSessionConversation({
4253
4382
  // v3 title-fallback fields; optional for backward compatibility with any
4254
4383
  // caller that doesn't yet populate them.
4255
4384
  last_user_content, first_assistant_text, rename_name,
4385
+ // When true, DON'T write session_message_rows here — the caller owns the row write because the
4386
+ // render shape differs from `messages` (Wall-E persists review-shaped rows, not the raw transcript
4387
+ // blob shape). The metadata upsert + blob still happen so the freshness gate + legacy reads work.
4388
+ skipMessageRows,
4256
4389
  }) {
4257
4390
  // Attribution: the whole import is one synchronous span — a JSON.stringify of
4258
4391
  // the full message array (multi-MB for 2000+ prompt sessions) + an upsert + a
@@ -4293,13 +4426,23 @@ function importSessionConversation({
4293
4426
  // null when there are no messages, and the COALESCE guards below then preserve
4294
4427
  // any existing value rather than wiping it.
4295
4428
  let _tokTotal = null, _tokCtx = null, _tokWindow = null, _tokExact = 0, _tokBreakdown = null;
4296
- if (!_overParseCap) {
4429
+ // Only pay the O(N) full-array estimate when it can actually move the (~) number: first time for
4430
+ // this session, a model change, or the count grew past a delta. Otherwise leave the fields null so
4431
+ // the upsert COALESCEs the prior stored value (no loss, no recompute). Delta default 32 messages;
4432
+ // CTM_IMPORT_TOKEN_REESTIMATE_DELTA tunes it, 0 = always re-estimate (old behavior).
4433
+ const _msgLen = Array.isArray(messages) ? messages.length : 0;
4434
+ const _reestimateDelta = Math.max(0, Number(process.env.CTM_IMPORT_TOKEN_REESTIMATE_DELTA ?? 32));
4435
+ const _prevEst = _importTokenEstimateLen.get(session_id);
4436
+ const _modelChanged = !_prevEst || _prevEst.model !== (model_id || '');
4437
+ const _grewEnough = !_prevEst || _reestimateDelta === 0 || (_msgLen - _prevEst.len) >= _reestimateDelta;
4438
+ if (!_overParseCap && (_modelChanged || _grewEnough)) {
4297
4439
  try {
4298
4440
  const summary = require('./lib/session-token-usage').estimateFromMessages(messages || [], model_id || '');
4299
4441
  if (summary && summary.total > 0) {
4300
4442
  _tokTotal = summary.total; _tokCtx = summary.ctx; _tokWindow = summary.ctxWindow;
4301
4443
  _tokExact = summary.exact ? 1 : 0;
4302
4444
  _tokBreakdown = summary.breakdown ? JSON.stringify(summary.breakdown) : null;
4445
+ _importTokenEstimateLen.set(session_id, { len: _msgLen, model: model_id || '' });
4303
4446
  }
4304
4447
  } catch { /* token estimate is best-effort */ }
4305
4448
  }
@@ -4370,19 +4513,22 @@ function importSessionConversation({
4370
4513
  // Faithful per-message render store (the blob replacement). Uses `messages` (the exact
4371
4514
  // blob source array, full text) so the rows are byte-faithful to the blob. O(Δ) append.
4372
4515
  // Stamps extracted_source_len (the render gate's HWM) so it's self-sufficient.
4373
- try {
4374
- replaceSessionMessageRows(session_id, messages || []);
4375
- } catch (e) {
4376
- console.error('[db] replaceSessionMessageRows error:', e.message);
4377
- // If we retired the blob ('[]') but the faithful rows failed to write, this session
4378
- // would read empty. Restore the real blob (the rare failure path pays the stringify)
4379
- // so a read is never blank correctness wins over the perf optimization here.
4380
- if (_retireBlob) {
4381
- try {
4382
- getDb().prepare('UPDATE session_conversations SET messages = ? WHERE ctm_session_id = ?')
4383
- .run(JSON.stringify(messages || []), session_id);
4384
- } catch (e2) {
4385
- console.error('[db] blob-restore after row-write failure also failed:', e2.message);
4516
+ // skipMessageRows: the caller writes rows itself in a different (render) shape — see Wall-E.
4517
+ if (!skipMessageRows) {
4518
+ try {
4519
+ replaceSessionMessageRows(session_id, messages || []);
4520
+ } catch (e) {
4521
+ console.error('[db] replaceSessionMessageRows error:', e.message);
4522
+ // If we retired the blob ('[]') but the faithful rows failed to write, this session
4523
+ // would read empty. Restore the real blob (the rare failure path pays the stringify)
4524
+ // so a read is never blank — correctness wins over the perf optimization here.
4525
+ if (_retireBlob) {
4526
+ try {
4527
+ getDb().prepare('UPDATE session_conversations SET messages = ? WHERE ctm_session_id = ?')
4528
+ .run(JSON.stringify(messages || []), session_id);
4529
+ } catch (e2) {
4530
+ console.error('[db] blob-restore after row-write failure also failed:', e2.message);
4531
+ }
4386
4532
  }
4387
4533
  }
4388
4534
  }
@@ -5085,11 +5231,11 @@ function incrementApprovalRuleMatch(id) {
5085
5231
  }
5086
5232
 
5087
5233
  // --- Approval Decisions (audit log) ---
5088
- function addApprovalDecision({ sessionId, toolName, commandSummary, fullContext, warning, decision, reasoning, decidedBy, ruleId, riskLevel, commandSignature }) {
5234
+ function addApprovalDecision({ sessionId, toolName, commandSummary, fullContext, warning, decision, reasoning, decidedBy, ruleId, riskLevel, commandSignature, walleRequestId, walleSessionId }) {
5089
5235
  const result = getDb().prepare(
5090
- `INSERT INTO approval_decisions (session_id, tool_name, command_summary, full_context, warning_text, decision, reasoning, decided_by, rule_id, risk_level, command_signature)
5091
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
5092
- ).run(sessionId || '', toolName || '', commandSummary || '', fullContext || '', warning || '', decision, reasoning || '', decidedBy || 'ai', ruleId || null, riskLevel || 'low', commandSignature || '');
5236
+ `INSERT INTO approval_decisions (session_id, tool_name, command_summary, full_context, warning_text, decision, reasoning, decided_by, rule_id, risk_level, command_signature, walle_request_id, walle_session_id)
5237
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
5238
+ ).run(sessionId || '', toolName || '', commandSummary || '', fullContext || '', warning || '', decision, reasoning || '', decidedBy || 'ai', ruleId || null, riskLevel || 'low', commandSignature || '', walleRequestId || '', walleSessionId || '');
5093
5239
  return result.lastInsertRowid;
5094
5240
  }
5095
5241
 
@@ -7237,9 +7383,45 @@ function replaceSessionMessageRows(sessionId, messages) {
7237
7383
  }
7238
7384
  }
7239
7385
 
7240
- // Full rewrite (first import / compaction / prefix change). Chunked so the write lock is
7241
- // never held for the whole conversation; AFTER DELETE/INSERT triggers re-sync the FTS.
7242
7386
  const CHUNK = Math.max(50, Math.min(5000, Number(process.env.CTM_MESSAGE_REWRITE_CHUNK) || 500));
7387
+
7388
+ // Incremental tail diff (the Codex freeze fix). The pure-extension fast path above misses whenever
7389
+ // the LAST stored message's text changed between imports — and Codex mutates its trailing assistant
7390
+ // message as tokens stream, so EVERY re-import of a long Codex session fell through to the full
7391
+ // rewrite below: DELETE + re-INSERT all N rows, re-tokenizing the whole conversation through the
7392
+ // FTS triggers while holding the cross-process write lock (~1s/16k rows measured). That lock-hold is
7393
+ // the off-CPU main-loop freeze that lagged Codex typing. Claude appends immutably so it always hit
7394
+ // the fast path — hence "especially in codex". Here, for a DENSE store, find the first index where
7395
+ // the stored rows and the new array diverge (forward scan; a lock-free read) and rewrite ONLY from
7396
+ // there. A last-message edit then rewrites ~1 row instead of N. If divergence is at the very start
7397
+ // (true compaction / prefix change) this naturally degrades to a full rewrite (same cost as before).
7398
+ // Gate: CTM_MESSAGE_ROWS_INCREMENTAL_DIFF=0 forces the old full-rewrite behavior.
7399
+ const _denseStore = !!(minRow && maxRow) && minRow.idx === 0 && count === maxRow.idx + 1 && count > 0;
7400
+ if (_denseStore && process.env.CTM_MESSAGE_ROWS_INCREMENTAL_DIFF !== '0') {
7401
+ const existing = d.prepare(
7402
+ 'SELECT message_index AS idx, role, text FROM session_message_rows WHERE ctm_session_id = ? ORDER BY message_index ASC'
7403
+ ).all(sessionId);
7404
+ const sameAt = (i) => {
7405
+ const s = existing[i]; const m = normalized[i] || {};
7406
+ return String(m.role || '') === s.role && String(m.text != null ? m.text : (m.content != null ? m.content : '')) === s.text;
7407
+ };
7408
+ let div = 0;
7409
+ const lim = Math.min(existing.length, normalized.length);
7410
+ while (div < lim && sameAt(div)) div++;
7411
+ // Delete the stale tail [div, count) and (re)insert the new tail [div, len). Both chunked so the
7412
+ // write lock is never held for the whole conversation; the FTS triggers re-sync per affected row.
7413
+ const tailIds = d.prepare('SELECT id FROM session_message_rows WHERE ctm_session_id = ? AND message_index >= ?').all(sessionId, div);
7414
+ const delById = d.prepare('DELETE FROM session_message_rows WHERE id = ?');
7415
+ const delChunk = d.transaction((slice) => { for (const r of slice) delById.run(r.id); });
7416
+ for (let i = 0; i < tailIds.length; i += CHUNK) delChunk(tailIds.slice(i, i + CHUNK));
7417
+ const insChunk = d.transaction((start, end) => { for (let i = start; i < end; i++) writeRow(i); });
7418
+ for (let i = div; i < normalized.length; i += CHUNK) insChunk(i, Math.min(i + CHUNK, normalized.length));
7419
+ _setExtractedSourceLen(d, sessionId, normalized.length, _lastMessageAtFromMessages(normalized));
7420
+ return normalized.length;
7421
+ }
7422
+
7423
+ // Full rewrite (first import / compaction / prefix change / non-dense store). Chunked so the write
7424
+ // lock is never held for the whole conversation; AFTER DELETE/INSERT triggers re-sync the FTS.
7243
7425
  const oldIds = d.prepare('SELECT id FROM session_message_rows WHERE ctm_session_id = ?').all(sessionId);
7244
7426
  const delById = d.prepare('DELETE FROM session_message_rows WHERE id = ?');
7245
7427
  const delChunk = d.transaction((slice) => { for (const r of slice) delById.run(r.id); });
@@ -7250,6 +7432,128 @@ function replaceSessionMessageRows(sessionId, messages) {
7250
7432
  return normalized.length;
7251
7433
  }
7252
7434
 
7435
+ // O(Δ) append of NEW messages after a verified-dense, unchanged-prefix base. Unlike
7436
+ // replaceSessionMessageRows this NEVER loads or rewrites the base array, so it stays O(Δ) for an
7437
+ // active multi-thousand-message session (that full-array load + re-process was the giant-transcript
7438
+ // freeze). Returns the new total length on success, or null when the base is not safe to append
7439
+ // onto — no rows, not a dense 0..n-1 block, or the row count disagrees with `expectedBaseCount`
7440
+ // (the conversation's known message HWM). On null the caller MUST fall back to the full path.
7441
+ function appendSessionMessageRows(sessionId, newMessages, expectedBaseCount) {
7442
+ if (!sessionId || !_tableExists('session_message_rows')) return null;
7443
+ const add = Array.isArray(newMessages) ? newMessages : [];
7444
+ if (add.length === 0) return null;
7445
+ const d = getDb();
7446
+ const minRow = d.prepare('SELECT MIN(message_index) AS idx FROM session_message_rows WHERE ctm_session_id = ?').get(sessionId);
7447
+ const maxRow = d.prepare('SELECT MAX(message_index) AS idx FROM session_message_rows WHERE ctm_session_id = ?').get(sessionId);
7448
+ const count = Number(d.prepare('SELECT COUNT(*) AS n FROM session_message_rows WHERE ctm_session_id = ?').get(sessionId).n);
7449
+ // Dense 0..count-1 block — no gaps, starts at 0 — else appending would misorder.
7450
+ if (count === 0 || minRow == null || minRow.idx !== 0 || count !== Number(maxRow.idx) + 1) return null;
7451
+ // The row count must equal the base length the delta was parsed against. When the caller can't
7452
+ // prove it (HWM unset / -1), refuse and let the full path re-establish the invariant.
7453
+ if (!Number.isFinite(expectedBaseCount) || expectedBaseCount < 0 || count !== expectedBaseCount) return null;
7454
+ const insertRow = d.prepare(
7455
+ 'INSERT OR IGNORE INTO session_message_rows (ctm_session_id, message_index, role, text, timestamp, meta) VALUES (?, ?, ?, ?, ?, ?)'
7456
+ );
7457
+ const writeRow = (i, m) => insertRow.run(
7458
+ sessionId, i, String(m.role || ''),
7459
+ String(m.text != null ? m.text : (m.content != null ? m.content : '')),
7460
+ m.timestamp != null ? String(m.timestamp) : '', _messageRowMeta(m)
7461
+ );
7462
+ const newLen = count + add.length;
7463
+ d.transaction(() => { for (let k = 0; k < add.length; k++) writeRow(count + k, add[k] || {}); })();
7464
+ _setExtractedSourceLen(d, sessionId, newLen, _lastMessageAtFromMessages(add));
7465
+ return newLen;
7466
+ }
7467
+
7468
+ // Incremental counterpart of importSessionConversation for an APPEND (file grew, prefix
7469
+ // unchanged). Appends only the Δ messages to the faithful row store and bumps the conversation
7470
+ // metadata (counts, last_message_at/last_user_content, file_size, token ESTIMATE) without loading
7471
+ // or re-serialising the full conversation. Returns { ok:true, length } on success, or
7472
+ // { ok:false, reason } when the base isn't safe to append onto — the caller then does a full
7473
+ // importSessionConversation. Only the (~) estimate token tier is touched; the live exact path wins.
7474
+ function appendSessionConversation({
7475
+ session_id, new_messages, model_id, file_size,
7476
+ last_user_content, model_provider, import_parser_version,
7477
+ // Cheap metadata that can be corrected on re-import (provider cwd, branch, title, host, rename).
7478
+ // first_message / first_assistant_text / session_created_at are cold-import-only and never change
7479
+ // on an append, so they are deliberately NOT touched here (that would need the full array).
7480
+ project_path, git_branch, title, hostname, rename_name,
7481
+ } = {}) {
7482
+ if (!session_id) return { ok: false, reason: 'no-session' };
7483
+ const add = Array.isArray(new_messages) ? new_messages : [];
7484
+ if (add.length === 0) return { ok: false, reason: 'empty-delta' };
7485
+ // The append path only maintains the row store. If the legacy session_messages index is being
7486
+ // dual-written (or rows are off), defer to the full path so that index stays in lockstep.
7487
+ if (process.env.CTM_SESSION_ROWS === '0' || process.env.CTM_DUAL_WRITE_SESSION_MESSAGES === '1') {
7488
+ return { ok: false, reason: 'legacy-index-dual-write' };
7489
+ }
7490
+ const d = getDb();
7491
+ const existing = d.prepare(
7492
+ 'SELECT extracted_source_len, tokens_total, tokens_exact FROM session_conversations WHERE ctm_session_id = ?'
7493
+ ).get(session_id);
7494
+ if (!existing) return { ok: false, reason: 'no-existing' };
7495
+ const baseCount = Number(existing.extracted_source_len);
7496
+ // Append the rows first — the strong dense+count guard lives there.
7497
+ const newLen = appendSessionMessageRows(session_id, add, baseCount);
7498
+ if (newLen == null) return { ok: false, reason: 'rows-not-appendable' };
7499
+
7500
+ // Δ-only counts (O(Δ), never re-walks the base).
7501
+ let dUsers = 0, dAssist = 0;
7502
+ for (const m of add) {
7503
+ const role = String(m && m.role || '');
7504
+ if (role === 'user') dUsers++;
7505
+ else if (role === 'assistant') dAssist++;
7506
+ }
7507
+ const lastAt = _lastMessageAtFromMessages(add) || '';
7508
+
7509
+ // Incremental token estimate: add ONLY the delta's tokens to the running total. Skip entirely
7510
+ // when an exact (live) total already owns the row — never downgrade exact → estimate.
7511
+ let dTokens = 0;
7512
+ if (!Number(existing.tokens_exact)) {
7513
+ try {
7514
+ const summ = require('./lib/session-token-usage').estimateFromMessages(add, model_id || '');
7515
+ dTokens = (summ && summ.total) || 0;
7516
+ } catch { /* estimate is best-effort */ }
7517
+ }
7518
+ const newTokens = dTokens > 0 ? (Number(existing.tokens_total) || 0) + dTokens : null;
7519
+
7520
+ d.prepare(
7521
+ `UPDATE session_conversations SET
7522
+ user_msg_count = user_msg_count + ?,
7523
+ assistant_msg_count = assistant_msg_count + ?,
7524
+ file_size = ?,
7525
+ last_message_at = CASE
7526
+ WHEN ? = '' THEN last_message_at
7527
+ WHEN last_message_at IS NULL OR last_message_at = '' THEN ?
7528
+ WHEN ? > last_message_at THEN ?
7529
+ ELSE last_message_at END,
7530
+ last_user_content = COALESCE(NULLIF(?, ''), last_user_content),
7531
+ project_path = COALESCE(NULLIF(?, ''), project_path),
7532
+ git_branch = COALESCE(NULLIF(?, ''), git_branch),
7533
+ title = COALESCE(NULLIF(?, ''), title),
7534
+ hostname = COALESCE(NULLIF(?, ''), hostname),
7535
+ rename_name = COALESCE(NULLIF(?, ''), rename_name),
7536
+ model_provider = COALESCE(NULLIF(?, ''), model_provider),
7537
+ model_id = COALESCE(NULLIF(?, ''), model_id),
7538
+ import_parser_version = ?,
7539
+ tokens_total = COALESCE(?, tokens_total),
7540
+ tokens_ctx = COALESCE(?, tokens_ctx),
7541
+ tokens_updated_at = CASE WHEN ? IS NULL THEN tokens_updated_at ELSE datetime('now') END,
7542
+ imported_at = datetime('now')
7543
+ WHERE ctm_session_id = ?`
7544
+ ).run(
7545
+ dUsers, dAssist, Number(file_size) || 0,
7546
+ lastAt, lastAt, lastAt, lastAt,
7547
+ last_user_content || '',
7548
+ project_path || '', git_branch || '', title || '', hostname || '', rename_name || '',
7549
+ model_provider || '', model_id || '',
7550
+ Number(import_parser_version || 0),
7551
+ newTokens, newTokens, newTokens,
7552
+ session_id
7553
+ );
7554
+ return { ok: true, length: newLen };
7555
+ }
7556
+
7253
7557
  // Whole conversation as the faithful message array (blob-identical), from rows; blob fallback.
7254
7558
  function getSessionMessagesArray(sessionId, { fallbackToBlob = true } = {}) {
7255
7559
  const d = getDb();
@@ -7283,17 +7587,19 @@ function sessionContentRowsAvailable(sessionId) {
7283
7587
  }
7284
7588
 
7285
7589
  // Newest-first offset page (matches lib/message-pagination.js semantics), O(limit) rows.
7590
+ // Delegates to the shared lib/session-messages-page module so the SAME logic runs on the main
7591
+ // thread (here) and on the read-pool worker (off-thread, read-only handle) — parity by construction.
7286
7592
  function getSessionMessagesPage(sessionId, { offset = 0, limit = 200 } = {}) {
7287
- const d = getDb();
7288
- const total = Number(d.prepare('SELECT COUNT(*) AS n FROM session_message_rows WHERE ctm_session_id = ?').get(sessionId).n);
7289
- const lim = Math.max(1, Math.min(1000, Number(limit) || 200));
7290
- const off = Math.max(0, Number(offset) || 0);
7291
- const start = Math.max(0, total - off - lim);
7292
- const take = Math.max(0, Math.min(lim, total - off - start));
7293
- const messages = take === 0 ? [] : d.prepare(
7294
- 'SELECT role, text, timestamp, meta FROM session_message_rows WHERE ctm_session_id = ? ORDER BY message_index ASC LIMIT ? OFFSET ?'
7295
- ).all(sessionId, take, start).map(_messageRowToObject);
7296
- return { messages, total, has_more: start > 0, next_offset: off + take };
7593
+ return require('./lib/session-messages-page').getMessagesPage(getDb(), { sessionId, offset, limit });
7594
+ }
7595
+
7596
+ // Cheap row count (no fetch) a content-version signal so callers can skip an
7597
+ // expensive page fetch + re-render when the message set is unchanged.
7598
+ function countSessionMessageRows(sessionId) {
7599
+ try {
7600
+ if (!_tableExists('session_message_rows')) return 0;
7601
+ return Number(getDb().prepare('SELECT COUNT(*) AS n FROM session_message_rows WHERE ctm_session_id = ?').get(sessionId).n) || 0;
7602
+ } catch { return 0; }
7297
7603
  }
7298
7604
 
7299
7605
  function _defaultSessionTurnStart(message) {
@@ -7392,9 +7698,14 @@ function getSessionMessagesTurnPage(sessionId, { offset = 0, limit = 50, isTurnS
7392
7698
  // backfill sweep using THIS module's writer + handle, so the worker/scheduler never thread
7393
7699
  // the handle through. Idempotent + resumable; returns the sweep summary {swept,migrated,rows,remaining}.
7394
7700
  function runContentRowsBackfillSweep(options = {}) {
7395
- const limit = Math.max(1, Math.min(500, Number(options && options.limit) || 50));
7701
+ const o = options && typeof options === 'object' ? options : {};
7702
+ // Smaller per-sweep batch by default (was 50) — this background migration shares the write lock
7703
+ // with interactive work; keep each sweep cheap and let it converge across ticks.
7704
+ const limit = Math.max(1, Math.min(500, Number(o.limit) || 25));
7705
+ const budgetMs = Math.max(0, Number(o.budgetMs ?? process.env.CTM_ROWS_BACKFILL_BUDGET_MS ?? 250));
7706
+ const maxRows = Math.max(0, Number(o.maxRows ?? process.env.CTM_ROWS_BACKFILL_MAX_ROWS ?? 3000));
7396
7707
  const bf = require('./lib/session-content-backfill');
7397
- return bf.runContentRowsBackfillSweep(getDb(), replaceSessionMessageRows, { limit });
7708
+ return bf.runContentRowsBackfillSweep(getDb(), replaceSessionMessageRows, { limit, budgetMs, maxRows });
7398
7709
  }
7399
7710
 
7400
7711
  // Migration completeness for GET /api/ctm/session-rows-status (+ the cutover gate).
@@ -8766,6 +9077,45 @@ function getSessionTitleNew(id) {
8766
9077
  return row && row.title ? { title: row.title, userRenamed: !!row.user_renamed } : null;
8767
9078
  }
8768
9079
 
9080
+ // Batch form of getSessionTitleNew: resolve titles for many ids in TWO queries instead of N point
9081
+ // reads, so a per-session gather loop (e.g. model-sync) doesn't block the main loop. Returns a
9082
+ // Map<id,{title,userRenamed}> keyed by the ORIGINAL input id; ids with no title are omitted (a miss
9083
+ // is `map.get(id) === undefined`, parity with getSessionTitleNew returning null). Precedence matches
9084
+ // getSessionTitleNew EXACTLY: a direct ctm_sessions row wins — and a titleless direct row resolves to
9085
+ // a miss WITHOUT falling through to the agent-session-id alias (getSessionTitleNew only tries the
9086
+ // alias when no ctm row exists for the id).
9087
+ function getSessionTitlesByIds(ids) {
9088
+ const out = new Map();
9089
+ const list = Array.from(new Set((Array.isArray(ids) ? ids : []).map((x) => String(x || '')).filter(Boolean)));
9090
+ if (list.length === 0) return out;
9091
+ const db = getDb();
9092
+ const ph = list.map(() => '?').join(',');
9093
+ const directRows = db.prepare(`SELECT id, title, user_renamed FROM ctm_sessions WHERE id IN (${ph})`).all(...list);
9094
+ const directById = new Map(directRows.map((r) => [String(r.id), r]));
9095
+ // Only ids with NO ctm row at all fall through to the agent_session_id alias.
9096
+ const unresolved = list.filter((id) => !directById.has(id));
9097
+ let aliasByAgentId = new Map();
9098
+ if (unresolved.length) {
9099
+ const aph = unresolved.map(() => '?').join(',');
9100
+ const aliasRows = db.prepare(
9101
+ `SELECT a.agent_session_id AS aid, c.title AS title, c.user_renamed AS user_renamed
9102
+ FROM agent_sessions a JOIN ctm_sessions c ON c.id = a.ctm_session_id
9103
+ WHERE a.agent_session_id IN (${aph})`
9104
+ ).all(...unresolved);
9105
+ aliasByAgentId = new Map(aliasRows.map((r) => [String(r.aid), r]));
9106
+ }
9107
+ for (const id of list) {
9108
+ if (directById.has(id)) {
9109
+ const direct = directById.get(id);
9110
+ if (direct && direct.title) out.set(id, { title: direct.title, userRenamed: !!direct.user_renamed });
9111
+ continue; // titleless direct row → miss, no alias (matches getSessionTitleNew)
9112
+ }
9113
+ const alias = aliasByAgentId.get(id);
9114
+ if (alias && alias.title) out.set(id, { title: alias.title, userRenamed: !!alias.user_renamed });
9115
+ }
9116
+ return out;
9117
+ }
9118
+
8769
9119
  function getSessionDisplayTitleInfo(id) {
8770
9120
  const cleanId = String(id || '').trim();
8771
9121
  if (!cleanId) return null;
@@ -9226,7 +9576,7 @@ module.exports = {
9226
9576
  getStorageRisk,
9227
9577
  getSqliteDriverStatus,
9228
9578
  getSetting, getSettingsByPrefix, setSetting,
9229
- addSessionDiagnostic, listSessionDiagnostics,
9579
+ addSessionDiagnostic, flushSessionDiagnostics, listSessionDiagnostics,
9230
9580
  upsertRuntimeTask, updateRuntimeTask, getRuntimeTask, listRuntimeTasks, deleteRuntimeTasksForSession,
9231
9581
  createPrompt, updatePrompt, getPrompt, listPrompts, listChildPrompts, setPromptParent, createGroupFromPrompts, deletePrompt, duplicatePrompt, reorderPrompts,
9232
9582
  getPromptVersions, restorePromptVersion,
@@ -9236,8 +9586,8 @@ module.exports = {
9236
9586
  listPermissionRules, upsertPermissionRule, deletePermissionRule,
9237
9587
  listPermissionLog, addPermissionLog,
9238
9588
  getAlwaysAskTools, setAlwaysAsk,
9239
- importSessionConversation, listSessionConversations, getSessionConversation, getSessionConversationMeta, getSessionConversationMessages, updateSessionModel,
9240
- replaceSessionMessageRows, getSessionMessagesArray, sessionContentRowsAvailable, getSessionMessagesPage, getSessionMessagesTurnPage,
9589
+ importSessionConversation, appendSessionConversation, appendSessionMessageRows, listSessionConversations, getSessionConversation, getSessionConversationMeta, getSessionConversationMessages, updateSessionModel,
9590
+ replaceSessionMessageRows, getSessionMessagesArray, sessionContentRowsAvailable, getSessionMessagesPage, getSessionMessagesTurnPage, countSessionMessageRows,
9241
9591
  runContentRowsBackfillSweep, getSessionRowsStatus, retireLegacyStores, runVacuum,
9242
9592
  updateSessionTokens, getSessionTokens,
9243
9593
  pickDisplayTitle,
@@ -9247,6 +9597,7 @@ module.exports = {
9247
9597
  checkpointWal, checkpointWalOrThrow, setWalCheckpointRunner, createBackup, copyLiveDatabaseTo, createBackupSync, listBackups, restoreBackup, deleteBackup, startDailyBackup,
9248
9598
  saveQueue, loadQueue, loadAllQueues, deleteQueueDb,
9249
9599
  listPermRules, addPermRule, removePermRule, bulkSetPermRules, getPermRulesByProject,
9600
+ setPermRuleExceptions,
9250
9601
  listAutoApprovals, upsertAutoApproval, toggleAutoApproval, deleteAutoApproval, getEnabledAutoApprovals,
9251
9602
  listApprovalRules, upsertApprovalRule, findApprovalRuleBySignature, toggleApprovalRule, deleteApprovalRule, incrementApprovalRuleMatch,
9252
9603
  addApprovalDecision, listApprovalDecisions, resolveApprovalDecision, getPendingEscalations,
@@ -9285,7 +9636,7 @@ module.exports = {
9285
9636
  upsertSessionIndex, resolveSessionId,
9286
9637
  // CTM Sessions + Agent Sessions (v1 schema)
9287
9638
  getSessionIdentity, getLatestAgentSessionForCtm, getSessionConversationSourceIds, getSessionMessageIndexFreshness, getSessionUserPromptFreshness, resolveSession, batchRestoreIdentities, upsertSession, upsertAgentSessionIdentity, setSessionStar, getStarredSessionIds,
9288
- setSessionTitleNew, setSessionTitleStatusNew, getSessionTitleNew, getSessionDisplayTitleInfo, isUserSessionTitleCandidate, promoteStartupTaskTitleIfUserNamed, repairSessionTitlesFromStartupTasks, getAllSessionsData,
9639
+ setSessionTitleNew, setSessionTitleStatusNew, getSessionTitleNew, getSessionTitlesByIds, getSessionDisplayTitleInfo, isUserSessionTitleCandidate, promoteStartupTaskTitleIfUserNamed, repairSessionTitlesFromStartupTasks, getAllSessionsData,
9289
9640
  repairCodexSessionMetadataFromConversations, repairCodexRolloutAgentIdentities, detachPromotedProviderChildSessions,
9290
9641
  listProviderChildAgentOwnerMappings,
9291
9642
  getAgentSessions, getAgentSession, deleteCtmSession,