create-walle 0.9.29 → 0.9.31

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 (95) hide show
  1. package/package.json +1 -1
  2. package/template/bin/ctm-launch.sh +66 -21
  3. package/template/bin/dev.sh +13 -0
  4. package/template/bin/ensure-stable-node.js +11 -0
  5. package/template/bin/node-bin.sh +9 -0
  6. package/template/claude-task-manager/api-prompts.js +182 -8
  7. package/template/claude-task-manager/api-reviews.js +153 -3
  8. package/template/claude-task-manager/approval-agent.js +37 -5
  9. package/template/claude-task-manager/db.js +168 -13
  10. package/template/claude-task-manager/docs/prompt-manager-redesign-proposal.html +666 -0
  11. package/template/claude-task-manager/docs/resume-ux-redesign.html +493 -0
  12. package/template/claude-task-manager/docs/review-redesign-proposal.html +892 -0
  13. package/template/claude-task-manager/docs/session-title-authority.md +8 -3
  14. package/template/claude-task-manager/lib/claude-desktop-sessions.js +63 -0
  15. package/template/claude-task-manager/lib/codex-config-guard.js +124 -0
  16. package/template/claude-task-manager/lib/codex-rollout-snapshot.js +42 -2
  17. package/template/claude-task-manager/lib/coding-agent-models.js +5 -4
  18. package/template/claude-task-manager/lib/db-owner-cooperative-scheduler.js +123 -0
  19. package/template/claude-task-manager/lib/db-owner-task-queue.js +67 -0
  20. package/template/claude-task-manager/lib/db-owner-worker-client.js +5 -1
  21. package/template/claude-task-manager/lib/desktop-fork.js +81 -0
  22. package/template/claude-task-manager/lib/headless-term-service.js +19 -3
  23. package/template/claude-task-manager/lib/mirror-feed-sanitize.js +45 -0
  24. package/template/claude-task-manager/lib/native-agent-model-args.js +118 -0
  25. package/template/claude-task-manager/lib/runtime-context-truth.js +16 -6
  26. package/template/claude-task-manager/lib/scrollback-snapshot-policy.js +37 -0
  27. package/template/claude-task-manager/lib/session-history.js +92 -5
  28. package/template/claude-task-manager/lib/session-messages-page.js +13 -0
  29. package/template/claude-task-manager/lib/session-messages-projection.js +11 -27
  30. package/template/claude-task-manager/lib/session-stream.js +61 -16
  31. package/template/claude-task-manager/lib/session-title-signals.js +54 -0
  32. package/template/claude-task-manager/lib/session-token-usage.js +13 -0
  33. package/template/claude-task-manager/lib/state-sync/frame-emitter.js +43 -2
  34. package/template/claude-task-manager/lib/transcript-ingest-chunker.js +41 -0
  35. package/template/claude-task-manager/lib/transcript-store.js +12 -1
  36. package/template/claude-task-manager/lib/tui-input-modes.js +40 -0
  37. package/template/claude-task-manager/lib/walle-session-model-catalog.js +100 -9
  38. package/template/claude-task-manager/providers/claude-code.js +20 -1
  39. package/template/claude-task-manager/public/css/reviews.css +232 -0
  40. package/template/claude-task-manager/public/css/walle-session.css +4 -0
  41. package/template/claude-task-manager/public/css/walle.css +0 -66
  42. package/template/claude-task-manager/public/index.html +1499 -171
  43. package/template/claude-task-manager/public/js/document-review-links.js +40 -4
  44. package/template/claude-task-manager/public/js/file-context-menu.js +185 -0
  45. package/template/claude-task-manager/public/js/message-renderer.js +27 -1
  46. package/template/claude-task-manager/public/js/resume-state.js +186 -0
  47. package/template/claude-task-manager/public/js/reviews.js +377 -70
  48. package/template/claude-task-manager/public/js/screenshot-router.js +91 -0
  49. package/template/claude-task-manager/public/js/session-search-utils.js +26 -0
  50. package/template/claude-task-manager/public/js/state-sync-client.js +118 -1
  51. package/template/claude-task-manager/public/js/stream-view.js +87 -20
  52. package/template/claude-task-manager/public/js/walle-session.js +211 -19
  53. package/template/claude-task-manager/public/js/walle.js +6 -110
  54. package/template/claude-task-manager/server.js +711 -92
  55. package/template/claude-task-manager/workers/db-owner-worker.js +15 -6
  56. package/template/claude-task-manager/workers/read-pool-worker.js +37 -0
  57. package/template/claude-task-manager/workers/session-host-pool-process.js +6 -1
  58. package/template/claude-task-manager/workers/session-host-process.js +6 -1
  59. package/template/claude-task-manager/workers/state-detectors/codex.js +33 -0
  60. package/template/package.json +1 -1
  61. package/template/wall-e/agent.js +78 -16
  62. package/template/wall-e/api-walle.js +45 -46
  63. package/template/wall-e/bin/walle-mcp-stdio.js +138 -5
  64. package/template/wall-e/brain.js +122 -1
  65. package/template/wall-e/chat.js +147 -9
  66. package/template/wall-e/http/model-admin.js +22 -0
  67. package/template/wall-e/lib/brain-owner-worker-client.js +20 -0
  68. package/template/wall-e/lib/parent-brain-owner-client.js +109 -0
  69. package/template/wall-e/lib/runtime-worker-pool.js +15 -1
  70. package/template/wall-e/lib/scheduler-worker-jobs.js +30 -1
  71. package/template/wall-e/lib/scheduler.js +71 -2
  72. package/template/wall-e/lib/slack-identity.js +120 -0
  73. package/template/wall-e/lib/slack-permalink.js +107 -0
  74. package/template/wall-e/lib/slack-web.js +174 -0
  75. package/template/wall-e/lib/worker-thread-pool.js +49 -0
  76. package/template/wall-e/llm/cli-binary.js +17 -4
  77. package/template/wall-e/llm/client.js +64 -5
  78. package/template/wall-e/llm/codex-cli.js +176 -83
  79. package/template/wall-e/llm/codex-cli.plugin.json +1 -0
  80. package/template/wall-e/llm/default-fallback.js +12 -6
  81. package/template/wall-e/llm/model-catalog.js +129 -17
  82. package/template/wall-e/llm/provider-error.js +7 -1
  83. package/template/wall-e/llm/provider-health-state.js +557 -11
  84. package/template/wall-e/llm/registry.js +6 -0
  85. package/template/wall-e/llm/routing-policy.js +255 -25
  86. package/template/wall-e/loops/backfill.js +32 -16
  87. package/template/wall-e/loops/ingest.js +50 -16
  88. package/template/wall-e/mcp-server.js +215 -6
  89. package/template/wall-e/skills/_bundled/gws-workspace/gws-router +61 -4
  90. package/template/wall-e/skills/_bundled/slack-mentions/run.js +167 -52
  91. package/template/wall-e/skills/skill-planner.js +5 -26
  92. package/template/wall-e/utils/dedup.js +165 -66
  93. package/template/wall-e/weather-runtime.js +12 -4
  94. package/template/wall-e/workers/brain-owner-worker.js +60 -0
  95. package/template/wall-e/workers/runtime-worker.js +4 -0
@@ -23,6 +23,56 @@ function isValidProjectPath(p) {
23
23
  return true;
24
24
  } catch { return false; }
25
25
  }
26
+ // Resolve `filePath` within `project` and enforce containment AFTER following
27
+ // symlinks. A lexical `resolved.startsWith(root + sep)` check is not enough: a
28
+ // symlink that lives inside the repo can point at /etc/passwd (or ~/.ssh) and the
29
+ // lexical path still looks in-bounds. realpath canonicalizes the link target, so
30
+ // containment is checked against where the file ACTUALLY is.
31
+ // Returns the safe absolute path to read, or null if it escapes the project.
32
+ // A path that doesn't exist yet (ENOENT) can't be a symlink, so the lexical check
33
+ // already guarantees safety — return it so the caller can produce its own 404.
34
+ // Best-effort fallback when an html path doesn't resolve at the given root — e.g. a path
35
+ // relative to the repo root while the session cwd is a subdir. Searches tracked + untracked
36
+ // .html within the repo and returns the best suffix/basename match (stays inside the root).
37
+ function findHtmlInRepo(root, relPath) {
38
+ try {
39
+ const { execFileSync } = require('child_process');
40
+ const want = String(relPath || '').replace(/^\.?\//, '');
41
+ const base = path.basename(want);
42
+ const out = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '*.html', '*.htm'],
43
+ { cwd: root, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }).split('\0').filter(Boolean);
44
+ let hit = out.find(f => f === want || f.endsWith('/' + want));
45
+ if (!hit) hit = out.find(f => path.basename(f) === base);
46
+ return hit ? path.join(root, hit) : null;
47
+ } catch { return null; }
48
+ }
49
+
50
+ function htmlNotFoundPage(filePath) {
51
+ const safe = String(filePath || '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
52
+ return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>File not found</title>
53
+ <style>body{font-family:-apple-system,system-ui,sans-serif;background:#1a1b26;color:#c0caf5;display:grid;place-items:center;height:100vh;margin:0}
54
+ .box{text-align:center;max-width:34rem;padding:2rem}h1{font-size:1.1rem;color:#f7768e}code{background:rgba(122,162,247,.12);padding:.15rem .4rem;border-radius:4px;font-size:.85em;word-break:break-all}p{color:#8b94bd;line-height:1.6;font-size:.9rem}</style>
55
+ </head><body><div class="box"><h1>File not found</h1>
56
+ <p><code>${safe}</code> isn't in this checkout. It may live in a different worktree or branch than the one this session is running in.</p></div></body></html>`;
57
+ }
58
+
59
+ function resolveWithinProject(project, filePath) {
60
+ if (!project || typeof filePath !== 'string') return null;
61
+ const root = path.resolve(project);
62
+ const resolved = path.resolve(root, filePath);
63
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) return null; // lexical ../ escape
64
+ let realRoot;
65
+ try { realRoot = fs.realpathSync(root); }
66
+ catch { return null; }
67
+ let realFile;
68
+ try { realFile = fs.realpathSync(resolved); }
69
+ catch (e) {
70
+ if (e && e.code === 'ENOENT') return resolved; // lexically in-bounds, not yet on disk
71
+ return null;
72
+ }
73
+ if (realFile !== realRoot && !realFile.startsWith(realRoot + path.sep)) return null; // symlink escape
74
+ return realFile;
75
+ }
26
76
  function jsonResponse(res, code, data) {
27
77
  const body = JSON.stringify(data);
28
78
  res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
@@ -364,9 +414,9 @@ function handleReviewApi(req, res, url) {
364
414
  const ext = path.extname(filePath).toLowerCase();
365
415
  const imageExts = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.ico': 'image/x-icon', '.bmp': 'image/bmp' };
366
416
  if (!imageExts[ext]) return jsonResponse(res, 400, { error: 'Only image files are supported' });
367
- // Prevent path traversal
368
- const resolved = path.resolve(project, filePath);
369
- if (!resolved.startsWith(path.resolve(project) + path.sep)) return jsonResponse(res, 403, { error: 'Invalid file path' });
417
+ // Prevent path traversal (incl. symlinks escaping the project — see resolveWithinProject)
418
+ const resolved = resolveWithinProject(project, filePath);
419
+ if (!resolved) return jsonResponse(res, 403, { error: 'Invalid file path' });
370
420
  try {
371
421
  const data = fs.readFileSync(resolved);
372
422
  res.writeHead(200, { 'Content-Type': imageExts[ext], 'Content-Length': data.length, 'Cache-Control': 'no-cache' });
@@ -462,6 +512,105 @@ function handleReviewApi(req, res, url) {
462
512
  return jsonResponse(res, 200, { prompt });
463
513
  }
464
514
 
515
+ // GET /api/reviews/documents?project=... — list reviewable markdown/text files in a
516
+ // project (powers the Review Hub "Documents" discovery column). Tracked + untracked-
517
+ // not-ignored, bounded, sorted most-recently-modified first.
518
+ if (p === '/api/reviews/documents' && m === 'GET') {
519
+ const project = url.searchParams.get('project');
520
+ if (!project) return jsonResponse(res, 400, { error: 'project required' });
521
+ if (!isValidProjectPath(project)) return jsonResponse(res, 403, { error: 'Invalid project path' });
522
+ try {
523
+ const { execFileSync } = require('child_process');
524
+ const root = path.resolve(project);
525
+ const globs = ['*.md', '*.markdown', '*.mdown', '*.txt'];
526
+ // -z → NUL-separated so paths with spaces/specials survive (no core.quotePath issues).
527
+ const run = (args) => {
528
+ try {
529
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 })
530
+ .split('\0').filter(Boolean);
531
+ } catch { return []; }
532
+ };
533
+ const set = new Set([
534
+ ...run(['ls-files', '-z', ...globs]),
535
+ ...run(['ls-files', '-z', '--others', '--exclude-standard', ...globs]),
536
+ ]);
537
+ let items = [...set].slice(0, 2000).map(rel => {
538
+ let mtime = 0;
539
+ try { mtime = fs.statSync(path.join(root, rel)).mtimeMs; } catch {}
540
+ return { path: rel, name: path.basename(rel), dir: path.dirname(rel), mtime };
541
+ });
542
+ items.sort((a, b) => b.mtime - a.mtime);
543
+ return jsonResponse(res, 200, { documents: items.slice(0, 300), total: set.size });
544
+ } catch (e) {
545
+ return jsonResponse(res, 500, { error: e.message });
546
+ }
547
+ }
548
+
549
+ // GET /api/reviews/serve-html?project=...&path=... — serve a project .html file so the
550
+ // file-router can open it in a new browser tab. Path-validated; no traversal outside project.
551
+ if (p === '/api/reviews/serve-html' && m === 'GET') {
552
+ const project = url.searchParams.get('project');
553
+ const filePath = url.searchParams.get('path');
554
+ if (!project || !filePath) return jsonResponse(res, 400, { error: 'project and path required' });
555
+ if (!isValidProjectPath(project)) return jsonResponse(res, 403, { error: 'Invalid project path' });
556
+ const ext = path.extname(filePath).toLowerCase();
557
+ if (ext !== '.html' && ext !== '.htm') return jsonResponse(res, 400, { error: 'Only .html files are supported' });
558
+ let resolved = resolveWithinProject(project, filePath);
559
+ if (!resolved) return jsonResponse(res, 403, { error: 'Invalid file path' });
560
+ // Fallback: the path may be relative to the repo root while the session cwd is a
561
+ // subdir/worktree — search the repo for the file before giving up.
562
+ if (!fs.existsSync(resolved)) {
563
+ const found = findHtmlInRepo(path.resolve(project), filePath);
564
+ if (found) resolved = found;
565
+ }
566
+ // Sandbox the served file: arbitrary repo HTML rendered on the CTM origin (which holds
567
+ // the auth cookie). A bare `sandbox` blocks scripting AND assigns an opaque origin, so it
568
+ // can't run JS, read CTM storage, or make cookie-authed calls — inline CSS/images still render.
569
+ const htmlHeaders = (len) => ({
570
+ 'Content-Type': 'text/html; charset=utf-8',
571
+ 'Content-Length': len,
572
+ 'Cache-Control': 'no-cache',
573
+ 'X-Content-Type-Options': 'nosniff',
574
+ 'Content-Security-Policy': 'sandbox',
575
+ });
576
+ let data;
577
+ try { data = fs.readFileSync(resolved); }
578
+ catch {
579
+ // Friendly in-tab page instead of a scary "unreachable file" / bare text 404.
580
+ const body = Buffer.from(htmlNotFoundPage(filePath));
581
+ res.writeHead(404, htmlHeaders(body.length));
582
+ res.end(body);
583
+ return true;
584
+ }
585
+ res.writeHead(200, htmlHeaders(data.length));
586
+ res.end(data);
587
+ return true;
588
+ }
589
+
590
+ // POST /api/reviews/reveal {project, path} — reveal a project file in Finder (macOS
591
+ // `open -R`). Path-validated; best-effort (the file-router menu's "Reveal" action).
592
+ if (p === '/api/reviews/reveal' && m === 'POST') {
593
+ readBody(req).then(body => {
594
+ const project = String(body.project || '');
595
+ const filePath = String(body.path || '');
596
+ if (!project || !filePath) return jsonResponse(res, 400, { error: 'project and path required' });
597
+ if (!isValidProjectPath(project)) return jsonResponse(res, 403, { error: 'Invalid project path' });
598
+ const resolved = resolveWithinProject(project, filePath);
599
+ if (!resolved) return jsonResponse(res, 403, { error: 'Invalid file path' });
600
+ if (!fs.existsSync(resolved)) return jsonResponse(res, 404, { error: 'File not found' });
601
+ try {
602
+ const { execFile } = require('child_process');
603
+ const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
604
+ const args = process.platform === 'darwin' ? ['-R', resolved] : [path.dirname(resolved)];
605
+ execFile(cmd, args, () => {});
606
+ jsonResponse(res, 200, { ok: true });
607
+ } catch (e) {
608
+ jsonResponse(res, 500, { error: e.message });
609
+ }
610
+ }).catch(e => jsonResponse(res, 400, { error: e.message }));
611
+ return true;
612
+ }
613
+
465
614
  return false; // Not handled
466
615
  }
467
616
 
@@ -508,4 +657,5 @@ module.exports = {
508
657
  _sanitizeReviewCommentInput: sanitizeReviewCommentInput,
509
658
  _sanitizeReviewCommentUpdate: sanitizeReviewCommentUpdate,
510
659
  _documentReview: documentReview,
660
+ _resolveWithinProject: resolveWithinProject,
511
661
  };
@@ -250,6 +250,24 @@ function getApproveKeystroke(context, options = {}) {
250
250
  return context.hasAllowAll ? '2' : '1';
251
251
  }
252
252
 
253
+ // Fallback tool-name derivation when no tool header is recognized above the
254
+ // prompt: read it from the durable grant option ("Yes, and don't ask again for
255
+ // <X> commands in <dir>" / "Yes, allow reading from <dir>") so an unrecognized
256
+ // tool is still targetable by a heuristic/learned rule instead of collapsing to
257
+ // 'Unknown'. `lines` is the trimmed, non-empty line array; `proceedIdx` the
258
+ // "Do you want to…?" line. Pure.
259
+ function _toolNameFromDurableOption(lines, proceedIdx) {
260
+ for (let i = proceedIdx + 1; i < Math.min(proceedIdx + 8, lines.length); i++) {
261
+ const l = String(lines[i] || '');
262
+ const m = l.match(/don'?t ask again for\s+(.+?)\s+commands?\b/i);
263
+ if (m && m[1]) return m[1].trim();
264
+ if (/allow\s+reading\s+from\b/i.test(l)) return 'Read';
265
+ const a = l.match(/allow\s+all\s+(\w+)\b/i);
266
+ if (a && a[1]) return a[1].trim();
267
+ }
268
+ return '';
269
+ }
270
+
253
271
  function _parseGenericApprovalContext(cleanText, providerId) {
254
272
  const lines = String(cleanText || '').split('\n').map(l => l.trim()).filter(Boolean);
255
273
  if (!lines.length) return null;
@@ -284,12 +302,16 @@ function _parseGenericApprovalContext(cleanText, providerId) {
284
302
  const contextLines = [];
285
303
  for (let i = proceedIdx - 1; i >= Math.max(0, proceedIdx - 30); i--) {
286
304
  const line = lines[i];
287
- if (/^[⏺●]?\s*(Bash command|Bash|Edit|Write|Read|Glob|Grep|Fetch|WebFetch|NotebookEdit|TodoWrite|Agent|MCP)\b/i.test(line)) {
305
+ if (/^[⏺●]?\s*(Bash command|Bash|Edit|Write|Read|Glob|Grep|Fetch|WebFetch|Web ?Search|NotebookEdit|TodoWrite|Agent|MCP)\b/i.test(line)) {
288
306
  toolName = line.trim();
289
307
  break;
290
308
  }
291
309
  contextLines.unshift(line);
292
310
  }
311
+ if (toolName === 'Generic approval') {
312
+ const durable = _toolNameFromDurableOption(lines, proceedIdx);
313
+ if (durable) toolName = durable;
314
+ }
293
315
 
294
316
  const fullContext = lines.slice(Math.max(0, proceedIdx - 20), Math.min(lines.length, proceedIdx + 12)).join('\n');
295
317
  return {
@@ -373,7 +395,7 @@ function parseApprovalContext(cleanText, providerId) {
373
395
  for (let i = endIdx - 1; i >= Math.max(0, endIdx - 30); i--) {
374
396
  const line = lines[i];
375
397
  // Detect tool headers (Claude Code shows "Bash command", "⏺ Bash(...)", "Edit", etc.)
376
- if (/^[⏺●]?\s*(Bash command|Bash|Edit|Write|Read|Glob|Grep|Fetch|WebFetch|NotebookEdit|TodoWrite|Agent)\b/.test(line)) {
398
+ if (/^[⏺●]?\s*(Bash command|Bash|Edit|Write|Read|Glob|Grep|Fetch|WebFetch|Web ?Search|NotebookEdit|TodoWrite|Agent)\b/.test(line)) {
377
399
  toolName = line.trim();
378
400
  break;
379
401
  }
@@ -403,6 +425,10 @@ function parseApprovalContext(cleanText, providerId) {
403
425
  if (fileOp) { toolName = fileOp.toolName; fileOpCommand = fileOp.command; }
404
426
  }
405
427
 
428
+ // Still no header → derive a name from the durable grant option so the tool is
429
+ // targetable instead of 'Unknown' (mirror of the claude-code provider path).
430
+ if (!toolName) toolName = _toolNameFromDurableOption(lines, proceedIdx);
431
+
406
432
  // Bash approvals render the command followed by one dimmed prose description
407
433
  // line; drop it from the command so titles/signatures stay command-shaped
408
434
  // (mirror of the claude-code provider parse path).
@@ -1150,8 +1176,14 @@ function reviewWithHeuristics(context) {
1150
1176
  // "mcp__…" / "(mcp)" command text.
1151
1177
  const isMcp = /^(?:plugin:|mcp__|mcp\b)/i.test(tool) || /\(mcp\)/i.test(cmd);
1152
1178
  if (isMcp) {
1153
- const readOnlyMcp = /\b(navigate(?:_back)?|snapshot|take_screenshot|screenshot|read|list|get|query|search|console_messages|network_requests?|wait_for|hover|tabs|resolve[-_ ]library[-_ ]id|get[-_ ]library[-_ ]docs|browser_snapshot|browser_navigate)\b/i;
1154
- const mutatingMcp = /\b(click|type|fill|drag|drop|file_upload|upload|run_code|evaluate|press_key|select_option|handle_dialog|write|create|delete|remove|update|send|post|install|deploy|exec)\b/i;
1179
+ // Boundaries are `(?<![a-z0-9]) … (?![a-z0-9])` rather than `\b`: snake_case
1180
+ // tool ids (mcp__server__get_thread) glue the verb to the noun with `_`, which
1181
+ // IS a regex word char, so `\bget\b` never fired and every read-only snake_case
1182
+ // MCP op (Gmail/Calendar/Drive/wall-e) fell through to the medium verifier path.
1183
+ // The class treats `_`, `-`, space, `:`, and start/end as separators while still
1184
+ // rejecting alnum-glued false positives (`get` in "target", `draft` in "drafts").
1185
+ const readOnlyMcp = /(?<![a-z0-9])(navigate(?:_back)?|snapshot|take_screenshot|screenshot|read|list|get|query|search|fetch|download|find|view|suggest|stats|status|brief|ask|console_messages|network_requests?|wait_for|hover|tabs|resolve[-_ ]?library[-_ ]?id|get[-_ ]?library[-_ ]?docs|browser_snapshot|browser_navigate)(?![a-z0-9])/i;
1186
+ const mutatingMcp = /(?<![a-z0-9])(click|type|fill|drag|drop|file_upload|upload|run_code|evaluate|press_key|select_option|handle_dialog|write|create|delete|remove|update|send|post|install|deploy|exec|label|unlabel|respond|copy|move|add|set|insert|draft|authenticate|complete|reconnect|ingest|rebuild|export|remember|put|patch)(?![a-z0-9])/i;
1155
1187
  if (readOnlyMcp.test(tool) && !mutatingMcp.test(tool)) {
1156
1188
  return { decision: 'approve', reasoning: 'Read-only MCP operation (heuristic)', riskLevel: 'low',
1157
1189
  ruleLabel: context.toolName || 'MCP read-only operation',
@@ -1169,7 +1201,7 @@ function reviewWithHeuristics(context) {
1169
1201
  // because Edit/Write diffs may contain code with "drop table" or "rm -rf" as
1170
1202
  // string literals — those are code content, not dangerous operations).
1171
1203
  const lowRisk = [
1172
- /^(read|glob|grep|webfetch|notebookedit)/, // Read-only tools
1204
+ /^(read|glob|grep|webfetch|web ?search|notebookedit)/, // Read-only tools (incl. Web Search — read-only, no side effects, same tier as WebFetch)
1173
1205
  /^(edit|write)\b/, // Edit/Write to project files — normal dev workflow
1174
1206
  ];
1175
1207
  for (const re of lowRisk) {
@@ -813,6 +813,10 @@ function runtimeKernelSchemaSql() {
813
813
  ON ctm_runtime_events(ctm_session_id, turn_id, created_at_ms, id);
814
814
  CREATE INDEX IF NOT EXISTS idx_ctm_runtime_events_type
815
815
  ON ctm_runtime_events(event_type, created_at_ms);
816
+ -- Serves "latest event of a type for a session" (latestContextTruth's per-session token/compact
817
+ -- lookup) as an O(1) index seek instead of fetching+parsing the oldest 1000 events per poll.
818
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_events_session_type
819
+ ON ctm_runtime_events(ctm_session_id, event_type, id);
816
820
 
817
821
  CREATE TABLE IF NOT EXISTS ctm_runtime_turns (
818
822
  ctm_session_id TEXT NOT NULL,
@@ -2505,6 +2509,34 @@ function migrateSchemaIfNeeded() {
2505
2509
  // Non-fatal: dropping a dormant cache table; the app runs fine if it lingers.
2506
2510
  }
2507
2511
  }
2512
+ if (getSchemaVersion() < 11) {
2513
+ try {
2514
+ migrateToV11();
2515
+ } catch (e) {
2516
+ console.error('[db] Schema migration to v11 FAILED:', e.message);
2517
+ console.error('[db] Stack:', e.stack);
2518
+ // Non-fatal: the column only enables Claude Desktop → Code fork dedup; absent it,
2519
+ // getForkForDesktopUuid simply returns null and conversion stays available.
2520
+ }
2521
+ }
2522
+ }
2523
+
2524
+ /**
2525
+ * Schema v11: link a converted Claude Desktop conversation to its resumable Claude Code
2526
+ * fork. A read-only Desktop conversation can be snapshot-and-forked into a real Code session
2527
+ * (see lib/claude-desktop-sessions.js materializeForkTranscript); we record the originating
2528
+ * Desktop uuid on the fork's agent_sessions row so the same conversation is never offered for
2529
+ * conversion twice — the sidebar shows "Resume fork" instead. Idempotent via PRAGMA pre-check.
2530
+ */
2531
+ function migrateToV11() {
2532
+ const d = getDb();
2533
+ const cols = d.prepare('PRAGMA table_info(agent_sessions)').all();
2534
+ if (!cols.find((c) => c.name === 'forked_from_desktop_uuid')) {
2535
+ d.prepare("ALTER TABLE agent_sessions ADD COLUMN forked_from_desktop_uuid TEXT DEFAULT ''").run();
2536
+ }
2537
+ d.exec("CREATE INDEX IF NOT EXISTS idx_agent_forked_desktop ON agent_sessions(forked_from_desktop_uuid) WHERE forked_from_desktop_uuid != ''");
2538
+ setSchemaVersion(11);
2539
+ console.log('[db] Schema migrated to v11 (Claude Desktop fork linkage)');
2508
2540
  }
2509
2541
 
2510
2542
  /**
@@ -3168,6 +3200,7 @@ function createV1Tables() {
3168
3200
  parent_agent_session_id TEXT DEFAULT '',
3169
3201
  agent_nickname TEXT DEFAULT '',
3170
3202
  agent_role TEXT DEFAULT '',
3203
+ forked_from_desktop_uuid TEXT DEFAULT '',
3171
3204
  created_at TEXT DEFAULT (datetime('now')),
3172
3205
  updated_at TEXT DEFAULT (datetime('now')),
3173
3206
  FOREIGN KEY (ctm_session_id) REFERENCES ctm_sessions(id) ON DELETE CASCADE
@@ -3189,6 +3222,7 @@ function createV1Tables() {
3189
3222
  d.exec('CREATE INDEX IF NOT EXISTS idx_agent_slug ON agent_sessions(slug)');
3190
3223
  d.exec('CREATE INDEX IF NOT EXISTS idx_agent_parent_session ON agent_sessions(parent_agent_session_id)');
3191
3224
  d.exec('CREATE INDEX IF NOT EXISTS idx_agent_thread_source ON agent_sessions(thread_source)');
3225
+ d.exec("CREATE INDEX IF NOT EXISTS idx_agent_forked_desktop ON agent_sessions(forked_from_desktop_uuid) WHERE forked_from_desktop_uuid != ''");
3192
3226
  }
3193
3227
 
3194
3228
  // --- Settings CRUD ---
@@ -3596,6 +3630,32 @@ function listRuntimeEvents(ctmSessionId, { sinceId = 0, turnId = '', limit = 100
3596
3630
  return rows.map(_parseRuntimeEvent).filter(Boolean);
3597
3631
  }
3598
3632
 
3633
+ // Latest event whose event_type is one of `eventTypes` for a session — the highest id wins.
3634
+ // Backs latestContextTruth, which previously fetched + JSON-parsed the oldest 1000 events per
3635
+ // session per poll just to find the latest token_usage + latest compact (a 3.98s/4min on-main cost
3636
+ // in the live CPU profile, and wrong once a session exceeds 1000 events). One indexed DESC LIMIT 1
3637
+ // seek per type (idx_ctm_runtime_events_session_type) → O(1), parses at most one row per type.
3638
+ function latestRuntimeEventOfTypes(ctmSessionId, eventTypes, { turnId = '' } = {}) {
3639
+ const cleanSessionId = String(ctmSessionId || '').trim();
3640
+ if (!cleanSessionId || !Array.isArray(eventTypes) || eventTypes.length === 0) return null;
3641
+ const cleanTurnId = String(turnId || '').trim();
3642
+ const d = getDb();
3643
+ let best = null;
3644
+ for (const t of eventTypes) {
3645
+ const et = String(t || '').trim();
3646
+ if (!et) continue;
3647
+ const row = cleanTurnId
3648
+ ? d.prepare(
3649
+ 'SELECT * FROM ctm_runtime_events WHERE ctm_session_id = ? AND turn_id = ? AND event_type = ? ORDER BY id DESC LIMIT 1'
3650
+ ).get(cleanSessionId, cleanTurnId, et)
3651
+ : d.prepare(
3652
+ 'SELECT * FROM ctm_runtime_events WHERE ctm_session_id = ? AND event_type = ? ORDER BY id DESC LIMIT 1'
3653
+ ).get(cleanSessionId, et);
3654
+ if (row && (!best || Number(row.id) > Number(best.id))) best = row;
3655
+ }
3656
+ return best ? _parseRuntimeEvent(best) : null;
3657
+ }
3658
+
3599
3659
  function _parseRuntimeTurn(row) {
3600
3660
  if (!row) return null;
3601
3661
  return {
@@ -4983,6 +5043,10 @@ function importSessionConversation({
4983
5043
  // render shape differs from `messages` (Wall-E persists review-shaped rows, not the raw transcript
4984
5044
  // blob shape). The metadata upsert + blob still happen so the freshness gate + legacy reads work.
4985
5045
  skipMessageRows,
5046
+ // When true, write the real blob even under blob retirement so the session renders from the blob
5047
+ // while content-rows-backfill fills rows across slices (chunked/cold-import route). Over-cap still
5048
+ // wins — a multi-GB stringify is never safe regardless of this flag.
5049
+ forceBlobWrite,
4986
5050
  }) {
4987
5051
  // Attribution: the whole import is one synchronous span — a JSON.stringify of
4988
5052
  // the full message array (multi-MB for 2000+ prompt sessions) + an upsert + a
@@ -5010,7 +5074,10 @@ function importSessionConversation({
5010
5074
  // the default), otherwise nulling the blob would blind every read; and the row
5011
5075
  // write below restores the real blob if it fails, so a session is never empty.
5012
5076
  const _retireBlob = _blobRetirementActive();
5013
- const _skipBlobWrite = _overParseCap || _retireBlob;
5077
+ // forceBlobWrite (cold/large chunked-import route) writes the real blob even under retirement so
5078
+ // the session renders from blob while content-rows-backfill fills rows across slices. Over-cap
5079
+ // still wins (a multi-GB stringify is never safe).
5080
+ const _skipBlobWrite = _overParseCap || (_retireBlob && !forceBlobWrite);
5014
5081
  if (_overParseCap && !_overCapImportLogged.has(session_id)) {
5015
5082
  _overCapImportLogged.add(session_id);
5016
5083
  console.warn(`[db] session_conversations import over parse cap (file_size=${file_size}) for ${String(session_id || '').slice(0, 8)} — storing empty blob, skipping full stringify/row-rewrite.`);
@@ -5032,7 +5099,8 @@ function importSessionConversation({
5032
5099
  const _prevEst = _importTokenEstimateLen.get(session_id);
5033
5100
  const _modelChanged = !_prevEst || _prevEst.model !== (model_id || '');
5034
5101
  const _grewEnough = !_prevEst || _reestimateDelta === 0 || (_msgLen - _prevEst.len) >= _reestimateDelta;
5035
- if (!_overParseCap && (_modelChanged || _grewEnough)) {
5102
+ const _chunkedRoute = !!forceBlobWrite && !!skipMessageRows && !_overParseCap;
5103
+ if (!_overParseCap && !_chunkedRoute && (_modelChanged || _grewEnough)) {
5036
5104
  try {
5037
5105
  const summary = require('./lib/session-token-usage').estimateFromMessages(messages || [], model_id || '');
5038
5106
  if (summary && summary.total > 0) {
@@ -5087,6 +5155,14 @@ function importSessionConversation({
5087
5155
  _tokTotal, _tokCtx, _tokWindow, _tokExact, _tokBreakdown
5088
5156
  );
5089
5157
 
5158
+ // Chunked-import route: rows are written later by content-rows-backfill (in windows). Stamp the
5159
+ // render-gate HWM now so findUnbackfilledSessions (extracted_source_len>0 AND rows<len) adopts
5160
+ // this session; appendSessionMessageRowsChunk re-stamps the same value idempotently on completion.
5161
+ if (_chunkedRoute) {
5162
+ try { _setExtractedSourceLen(getDb(), session_id, _msgLen, _lastMessageAt); }
5163
+ catch (e) { console.error('[db] chunked-import source-len stamp failed:', e.message); }
5164
+ }
5165
+
5090
5166
  // Keep message-level search/review surfaces in lockstep with the durable
5091
5167
  // conversation cache. The older one-shot backfill path intentionally skips
5092
5168
  // sessions that already have rows, which made long-running imported Codex
@@ -7886,6 +7962,9 @@ function replaceSessionMessages(sessionId, messages) {
7886
7962
  // transcript timestamp is available, advance last_message_at without allowing
7887
7963
  // partial rewrites or compacted sources to move activity backward.
7888
7964
  function _setExtractedSourceLen(d, ctmSessionId, sourceLen, lastMessageAt = '') {
7965
+ // Any row write reaches here to advance the watermark — drop the cached rows-available verdict so
7966
+ // sessionContentRowsAvailable re-COUNTs once against the new state (covers same-watermark mutations).
7967
+ _invalidateRowsAvailCache(ctmSessionId);
7889
7968
  try {
7890
7969
  d.prepare('UPDATE session_conversations SET extracted_source_len = ? WHERE ctm_session_id = ?')
7891
7970
  .run(Number(sourceLen) || 0, ctmSessionId);
@@ -8221,8 +8300,10 @@ function appendSessionConversation({
8221
8300
  function getSessionMessagesArray(sessionId, { fallbackToBlob = true } = {}) {
8222
8301
  const d = getDb();
8223
8302
  if (_tableExists('session_message_rows')) {
8224
- const rows = d.prepare('SELECT role, text, timestamp, meta FROM session_message_rows WHERE ctm_session_id = ? ORDER BY message_index ASC').all(sessionId);
8225
- if (rows.length) return rows.map(_messageRowToObject);
8303
+ // Delegate the SELECT + row→object map to the shared lib so this row read is byte-identical
8304
+ // to the read-pool worker's off-thread getSessionMessagesArray op (parity by construction).
8305
+ const rows = require('./lib/session-messages-page').getMessagesArray(d, { sessionId });
8306
+ if (rows.length) return rows;
8226
8307
  }
8227
8308
  if (fallbackToBlob) {
8228
8309
  try {
@@ -8236,16 +8317,40 @@ function getSessionMessagesArray(sessionId, { fallbackToBlob = true } = {}) {
8236
8317
  // True only when the faithful rows can serve this session: present AND fully backfilled
8237
8318
  // (row count covers the conversation's extracted length). A half-migrated session returns
8238
8319
  // false → the caller uses the complete blob/JSONL path.
8320
+ // Watermark-keyed cache of the "rows fully cover the conversation" verdict. sessionContentRowsAvailable
8321
+ // is on MANY hot/periodic main-loop paths — most heavily the per-codex-session snapshot serialize
8322
+ // (_serializeSessionSnapshot → _codexRolloutHistoryActive → _codexRowLookupId), plus the
8323
+ // apiSessionMessages gate, attach, and exit — and each call ran an O(rows) COUNT(*) that cold-page
8324
+ // faults for seconds (the `(unknown)` cpu-low apiSessionMessages/snapshot freeze on the primary).
8325
+ // The gate is `cnt >= expected` where expected = extracted_source_len (the import high-water mark).
8326
+ // Rows only grow and `expected` advances only as import progresses, so a TRUE verdict stays true
8327
+ // while `expected` is unchanged. Cache ONLY true verdicts keyed on `expected`: a key match means
8328
+ // cnt was already ≥ this watermark and (rows-only-grow) still is — skip the COUNT(*). A false/
8329
+ // half-migrated verdict is NOT cached (it must be re-checked so a just-completed migration is
8330
+ // picked up). Row writes invalidate the entry (belt-and-suspenders for a same-watermark row
8331
+ // mutation). CTM_ROWS_AVAIL_CACHE=0 disables.
8332
+ const _rowsAvailCache = new Map(); // ctm_session_id -> expected (watermark at which it was TRUE)
8333
+ function _invalidateRowsAvailCache(sessionId) { _rowsAvailCache.delete(String(sessionId || '')); }
8239
8334
  function sessionContentRowsAvailable(sessionId) {
8240
8335
  try {
8241
8336
  const d = getDb();
8242
8337
  if (!_tableExists('session_message_rows')) return false;
8243
- const cnt = Number(d.prepare('SELECT COUNT(*) AS n FROM session_message_rows WHERE ctm_session_id = ?').get(sessionId).n);
8244
- if (cnt === 0) return false;
8338
+ // Cheap watermark read first (single non-blob column); also lets us skip the COUNT entirely for
8339
+ // sessions with no HWM (always false under the old logic too).
8245
8340
  const conv = d.prepare('SELECT extracted_source_len FROM session_conversations WHERE ctm_session_id = ?').get(sessionId);
8246
8341
  const expected = conv ? Number(conv.extracted_source_len) : 0;
8247
- if (!(expected > 0) || cnt < expected) return false; // unknown HWM or half-migrated → blob path
8248
- return true;
8342
+ if (!(expected > 0)) { _invalidateRowsAvailCache(sessionId); return false; } // unknown HWM → blob path
8343
+ const cacheOn = process.env.CTM_ROWS_AVAIL_CACHE !== '0';
8344
+ if (cacheOn && _rowsAvailCache.get(sessionId) === expected) return true; // TRUE@expected still holds (rows only grow)
8345
+ const cnt = Number(d.prepare('SELECT COUNT(*) AS n FROM session_message_rows WHERE ctm_session_id = ?').get(sessionId).n);
8346
+ const available = cnt > 0 && cnt >= expected; // unknown HWM or half-migrated → blob path
8347
+ if (available && cacheOn) {
8348
+ _rowsAvailCache.set(sessionId, expected);
8349
+ if (_rowsAvailCache.size > 1024) { const k = _rowsAvailCache.keys().next().value; _rowsAvailCache.delete(k); }
8350
+ } else {
8351
+ _invalidateRowsAvailCache(sessionId);
8352
+ }
8353
+ return available;
8249
8354
  } catch { return false; }
8250
8355
  }
8251
8356
 
@@ -8403,9 +8508,28 @@ function _messageFreshnessStatsForId(id, { userOnly = false } = {}) {
8403
8508
  const d = getDb();
8404
8509
  const bindId = String(id);
8405
8510
  const whereRole = userOnly ? " AND role = 'user'" : '';
8406
- const readStats = (table) => d.prepare(
8511
+ // The all-message session_message_rows store is DENSE — every message is a row at its array index
8512
+ // (replaceSessionMessageRows / appendSessionMessageRowsChunk write message_index = i contiguously
8513
+ // from 0, an invariant the row write paths already rely on), so COUNT(*) === MAX(message_index)+1.
8514
+ // The per-poll COUNT(*) over a giant (14k-19k-row) session was a 5.25s/4min on-main cost in the
8515
+ // live CPU profile; the query already computes MAX (an O(1) index seek), so deriving rows from it
8516
+ // drops the only O(rows) term with NO schema or write-path change — strictly better than a
8517
+ // maintained counter (no cross-thread drift). Equivalent as a change-detector: maxIndex moves on
8518
+ // every append, and the store is append-only / full-replace (no mid-array deletes), so maxIndex+1
8519
+ // changes exactly when the message set does. User-only rows are SPARSE (no density) and the legacy
8520
+ // session_messages store isn't guaranteed dense, so both keep the exact COUNT(*).
8521
+ const countStats = (table) => d.prepare(
8407
8522
  `SELECT COALESCE(MAX(message_index), -1) AS maxIndex, COUNT(*) AS rows FROM ${table} WHERE ctm_session_id = ?${whereRole}`
8408
8523
  ).get(bindId);
8524
+ const denseStats = (table) => {
8525
+ const r = d.prepare(
8526
+ `SELECT COALESCE(MAX(message_index), -1) AS maxIndex FROM ${table} WHERE ctm_session_id = ?`
8527
+ ).get(bindId);
8528
+ const maxIndex = Number(r && r.maxIndex != null ? r.maxIndex : -1);
8529
+ return { maxIndex, rows: maxIndex + 1 };
8530
+ };
8531
+ const readStats = (table) =>
8532
+ (!userOnly && table === 'session_message_rows') ? denseStats(table) : countStats(table);
8409
8533
 
8410
8534
  // The row store is the default read source, but migration and tests can leave
8411
8535
  // some sessions present only in the legacy filtered index. Pick per session
@@ -9383,14 +9507,15 @@ function upsertAgentSessionIdentity(agentSessionId, data = {}) {
9383
9507
  parent_agent_session_id: lineage.parent_agent_session_id,
9384
9508
  agent_nickname: lineage.agent_nickname,
9385
9509
  agent_role: lineage.agent_role,
9510
+ forked_from_desktop_uuid: data.forkedFromDesktopUuid || data.forked_from_desktop_uuid || '',
9386
9511
  };
9387
9512
  d.prepare(`
9388
9513
  INSERT INTO agent_sessions (agent_session_id, ctm_session_id, provider, provider_resume_id, project_path, jsonl_path,
9389
9514
  first_message, file_size, modified_at, hostname, model, git_branch, user_msg_count, slug,
9390
- thread_source, parent_agent_session_id, agent_nickname, agent_role)
9515
+ thread_source, parent_agent_session_id, agent_nickname, agent_role, forked_from_desktop_uuid)
9391
9516
  VALUES (@agent_session_id, @ctm_session_id, @provider, @provider_resume_id, @project_path, @jsonl_path,
9392
9517
  @first_message, @file_size, @modified_at, @hostname, @model, @git_branch, @user_msg_count, @slug,
9393
- @thread_source, @parent_agent_session_id, @agent_nickname, @agent_role)
9518
+ @thread_source, @parent_agent_session_id, @agent_nickname, @agent_role, @forked_from_desktop_uuid)
9394
9519
  ON CONFLICT(agent_session_id) DO UPDATE SET
9395
9520
  ctm_session_id = COALESCE(agent_sessions.ctm_session_id, excluded.ctm_session_id),
9396
9521
  provider = COALESCE(NULLIF(excluded.provider, ''), agent_sessions.provider),
@@ -9409,10 +9534,40 @@ function upsertAgentSessionIdentity(agentSessionId, data = {}) {
9409
9534
  parent_agent_session_id = COALESCE(NULLIF(excluded.parent_agent_session_id, ''), agent_sessions.parent_agent_session_id),
9410
9535
  agent_nickname = COALESCE(NULLIF(excluded.agent_nickname, ''), agent_sessions.agent_nickname),
9411
9536
  agent_role = COALESCE(NULLIF(excluded.agent_role, ''), agent_sessions.agent_role),
9537
+ forked_from_desktop_uuid = COALESCE(NULLIF(excluded.forked_from_desktop_uuid, ''), agent_sessions.forked_from_desktop_uuid),
9412
9538
  updated_at = datetime('now')
9413
9539
  `).run(params);
9414
9540
  }
9415
9541
 
9542
+ /**
9543
+ * Look up the Claude Code fork that was created from a given Claude Desktop conversation.
9544
+ * Returns the fork's agent_sessions row ({ agent_session_id, jsonl_path, ctm_session_id })
9545
+ * or null when the conversation has never been converted. Callers verify the fork still
9546
+ * exists (row + jsonl file) before treating the conversation as already-converted; a deleted
9547
+ * fork should re-offer conversion. Anchored on the stable Desktop uuid so a re-scan of the
9548
+ * Desktop cache never mints a duplicate fork.
9549
+ */
9550
+ function getForkForDesktopUuid(desktopUuid) {
9551
+ const id = String(desktopUuid || '').trim();
9552
+ if (!id) return null;
9553
+ return getDb().prepare(
9554
+ 'SELECT agent_session_id, jsonl_path, ctm_session_id FROM agent_sessions WHERE forked_from_desktop_uuid = ? LIMIT 1'
9555
+ ).get(id) || null;
9556
+ }
9557
+
9558
+ /**
9559
+ * List every Claude Code fork created from a Claude Desktop conversation, keyed by the
9560
+ * originating Desktop uuid. The client fetches this to decide, per Desktop conversation,
9561
+ * whether to offer "Convert" or "Resume fork". Callers verify the transcript still exists
9562
+ * before trusting the link (a deleted fork should re-offer conversion).
9563
+ */
9564
+ function listDesktopForks() {
9565
+ return getDb().prepare(
9566
+ "SELECT forked_from_desktop_uuid AS desktopUuid, agent_session_id AS forkSessionId, jsonl_path AS jsonlPath, project_path AS projectPath " +
9567
+ "FROM agent_sessions WHERE forked_from_desktop_uuid != ''"
9568
+ ).all();
9569
+ }
9570
+
9416
9571
  function setSessionStar(id, starred) {
9417
9572
  // Try ctm_sessions first
9418
9573
  const result = getDb().prepare("UPDATE ctm_sessions SET starred = ?, updated_at = datetime('now') WHERE id = ?")
@@ -10249,7 +10404,7 @@ module.exports = {
10249
10404
  getStorageRisk,
10250
10405
  getSqliteDriverStatus,
10251
10406
  ensureRuntimeKernelTables,
10252
- appendRuntimeEvent, listRuntimeEvents,
10407
+ appendRuntimeEvent, listRuntimeEvents, latestRuntimeEventOfTypes,
10253
10408
  upsertRuntimeTurn, getRuntimeTurn, listRuntimeTurns,
10254
10409
  upsertRuntimeInputEnvelope, updateRuntimeInputEnvelope, getRuntimeInputEnvelope, listRuntimeInputEnvelopes,
10255
10410
  upsertRuntimeRouteSnapshot, getLatestRuntimeRouteSnapshot,
@@ -10318,7 +10473,7 @@ module.exports = {
10318
10473
  setSessionTitleNew, setSessionTitleStatusNew, getSessionTitleNew, getSessionTitlesByIds, getSessionDisplayTitleInfo, isUserSessionTitleCandidate, promoteStartupTaskTitleIfUserNamed, repairSessionTitlesFromStartupTasks, getAllSessionsData,
10319
10474
  repairCodexSessionMetadataFromConversations, repairCodexRolloutAgentIdentities, detachPromotedProviderChildSessions,
10320
10475
  listProviderChildAgentOwnerMappings,
10321
- getAgentSessions, getAgentSession, deleteCtmSession,
10476
+ getAgentSessions, getAgentSession, getForkForDesktopUuid, listDesktopForks, deleteCtmSession,
10322
10477
  // Schema version
10323
10478
  getSchemaVersion,
10324
10479
  };