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.
- package/package.json +1 -1
- package/template/bin/ctm-launch.sh +66 -21
- package/template/bin/dev.sh +13 -0
- package/template/bin/ensure-stable-node.js +11 -0
- package/template/bin/node-bin.sh +9 -0
- package/template/claude-task-manager/api-prompts.js +182 -8
- package/template/claude-task-manager/api-reviews.js +153 -3
- package/template/claude-task-manager/approval-agent.js +37 -5
- package/template/claude-task-manager/db.js +168 -13
- package/template/claude-task-manager/docs/prompt-manager-redesign-proposal.html +666 -0
- package/template/claude-task-manager/docs/resume-ux-redesign.html +493 -0
- package/template/claude-task-manager/docs/review-redesign-proposal.html +892 -0
- package/template/claude-task-manager/docs/session-title-authority.md +8 -3
- package/template/claude-task-manager/lib/claude-desktop-sessions.js +63 -0
- package/template/claude-task-manager/lib/codex-config-guard.js +124 -0
- package/template/claude-task-manager/lib/codex-rollout-snapshot.js +42 -2
- package/template/claude-task-manager/lib/coding-agent-models.js +5 -4
- package/template/claude-task-manager/lib/db-owner-cooperative-scheduler.js +123 -0
- package/template/claude-task-manager/lib/db-owner-task-queue.js +67 -0
- package/template/claude-task-manager/lib/db-owner-worker-client.js +5 -1
- package/template/claude-task-manager/lib/desktop-fork.js +81 -0
- package/template/claude-task-manager/lib/headless-term-service.js +19 -3
- package/template/claude-task-manager/lib/mirror-feed-sanitize.js +45 -0
- package/template/claude-task-manager/lib/native-agent-model-args.js +118 -0
- package/template/claude-task-manager/lib/runtime-context-truth.js +16 -6
- package/template/claude-task-manager/lib/scrollback-snapshot-policy.js +37 -0
- package/template/claude-task-manager/lib/session-history.js +92 -5
- package/template/claude-task-manager/lib/session-messages-page.js +13 -0
- package/template/claude-task-manager/lib/session-messages-projection.js +11 -27
- package/template/claude-task-manager/lib/session-stream.js +61 -16
- package/template/claude-task-manager/lib/session-title-signals.js +54 -0
- package/template/claude-task-manager/lib/session-token-usage.js +13 -0
- package/template/claude-task-manager/lib/state-sync/frame-emitter.js +43 -2
- package/template/claude-task-manager/lib/transcript-ingest-chunker.js +41 -0
- package/template/claude-task-manager/lib/transcript-store.js +12 -1
- package/template/claude-task-manager/lib/tui-input-modes.js +40 -0
- package/template/claude-task-manager/lib/walle-session-model-catalog.js +100 -9
- package/template/claude-task-manager/providers/claude-code.js +20 -1
- package/template/claude-task-manager/public/css/reviews.css +232 -0
- package/template/claude-task-manager/public/css/walle-session.css +4 -0
- package/template/claude-task-manager/public/css/walle.css +0 -66
- package/template/claude-task-manager/public/index.html +1499 -171
- package/template/claude-task-manager/public/js/document-review-links.js +40 -4
- package/template/claude-task-manager/public/js/file-context-menu.js +185 -0
- package/template/claude-task-manager/public/js/message-renderer.js +27 -1
- package/template/claude-task-manager/public/js/resume-state.js +186 -0
- package/template/claude-task-manager/public/js/reviews.js +377 -70
- package/template/claude-task-manager/public/js/screenshot-router.js +91 -0
- package/template/claude-task-manager/public/js/session-search-utils.js +26 -0
- package/template/claude-task-manager/public/js/state-sync-client.js +118 -1
- package/template/claude-task-manager/public/js/stream-view.js +87 -20
- package/template/claude-task-manager/public/js/walle-session.js +211 -19
- package/template/claude-task-manager/public/js/walle.js +6 -110
- package/template/claude-task-manager/server.js +711 -92
- package/template/claude-task-manager/workers/db-owner-worker.js +15 -6
- package/template/claude-task-manager/workers/read-pool-worker.js +37 -0
- package/template/claude-task-manager/workers/session-host-pool-process.js +6 -1
- package/template/claude-task-manager/workers/session-host-process.js +6 -1
- package/template/claude-task-manager/workers/state-detectors/codex.js +33 -0
- package/template/package.json +1 -1
- package/template/wall-e/agent.js +78 -16
- package/template/wall-e/api-walle.js +45 -46
- package/template/wall-e/bin/walle-mcp-stdio.js +138 -5
- package/template/wall-e/brain.js +122 -1
- package/template/wall-e/chat.js +147 -9
- package/template/wall-e/http/model-admin.js +22 -0
- package/template/wall-e/lib/brain-owner-worker-client.js +20 -0
- package/template/wall-e/lib/parent-brain-owner-client.js +109 -0
- package/template/wall-e/lib/runtime-worker-pool.js +15 -1
- package/template/wall-e/lib/scheduler-worker-jobs.js +30 -1
- package/template/wall-e/lib/scheduler.js +71 -2
- package/template/wall-e/lib/slack-identity.js +120 -0
- package/template/wall-e/lib/slack-permalink.js +107 -0
- package/template/wall-e/lib/slack-web.js +174 -0
- package/template/wall-e/lib/worker-thread-pool.js +49 -0
- package/template/wall-e/llm/cli-binary.js +17 -4
- package/template/wall-e/llm/client.js +64 -5
- package/template/wall-e/llm/codex-cli.js +176 -83
- package/template/wall-e/llm/codex-cli.plugin.json +1 -0
- package/template/wall-e/llm/default-fallback.js +12 -6
- package/template/wall-e/llm/model-catalog.js +129 -17
- package/template/wall-e/llm/provider-error.js +7 -1
- package/template/wall-e/llm/provider-health-state.js +557 -11
- package/template/wall-e/llm/registry.js +6 -0
- package/template/wall-e/llm/routing-policy.js +255 -25
- package/template/wall-e/loops/backfill.js +32 -16
- package/template/wall-e/loops/ingest.js +50 -16
- package/template/wall-e/mcp-server.js +215 -6
- package/template/wall-e/skills/_bundled/gws-workspace/gws-router +61 -4
- package/template/wall-e/skills/_bundled/slack-mentions/run.js +167 -52
- package/template/wall-e/skills/skill-planner.js +5 -26
- package/template/wall-e/utils/dedup.js +165 -66
- package/template/wall-e/weather-runtime.js +12 -4
- package/template/wall-e/workers/brain-owner-worker.js +60 -0
- package/template/wall-e/workers/runtime-worker.js +4 -0
|
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 =
|
|
369
|
-
if (!resolved
|
|
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
|
-
|
|
1154
|
-
|
|
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
|
-
|
|
5077
|
+
// forceBlobWrite (cold/large chunked-import route) writes the real blob even under retirement so
|
|
5078
|
+
// the session renders from blob while content-rows-backfill fills rows across slices. Over-cap
|
|
5079
|
+
// still wins (a multi-GB stringify is never safe).
|
|
5080
|
+
const _skipBlobWrite = _overParseCap || (_retireBlob && !forceBlobWrite);
|
|
5014
5081
|
if (_overParseCap && !_overCapImportLogged.has(session_id)) {
|
|
5015
5082
|
_overCapImportLogged.add(session_id);
|
|
5016
5083
|
console.warn(`[db] session_conversations import over parse cap (file_size=${file_size}) for ${String(session_id || '').slice(0, 8)} — storing empty blob, skipping full stringify/row-rewrite.`);
|
|
@@ -5032,7 +5099,8 @@ function importSessionConversation({
|
|
|
5032
5099
|
const _prevEst = _importTokenEstimateLen.get(session_id);
|
|
5033
5100
|
const _modelChanged = !_prevEst || _prevEst.model !== (model_id || '');
|
|
5034
5101
|
const _grewEnough = !_prevEst || _reestimateDelta === 0 || (_msgLen - _prevEst.len) >= _reestimateDelta;
|
|
5035
|
-
|
|
5102
|
+
const _chunkedRoute = !!forceBlobWrite && !!skipMessageRows && !_overParseCap;
|
|
5103
|
+
if (!_overParseCap && !_chunkedRoute && (_modelChanged || _grewEnough)) {
|
|
5036
5104
|
try {
|
|
5037
5105
|
const summary = require('./lib/session-token-usage').estimateFromMessages(messages || [], model_id || '');
|
|
5038
5106
|
if (summary && summary.total > 0) {
|
|
@@ -5087,6 +5155,14 @@ function importSessionConversation({
|
|
|
5087
5155
|
_tokTotal, _tokCtx, _tokWindow, _tokExact, _tokBreakdown
|
|
5088
5156
|
);
|
|
5089
5157
|
|
|
5158
|
+
// Chunked-import route: rows are written later by content-rows-backfill (in windows). Stamp the
|
|
5159
|
+
// render-gate HWM now so findUnbackfilledSessions (extracted_source_len>0 AND rows<len) adopts
|
|
5160
|
+
// this session; appendSessionMessageRowsChunk re-stamps the same value idempotently on completion.
|
|
5161
|
+
if (_chunkedRoute) {
|
|
5162
|
+
try { _setExtractedSourceLen(getDb(), session_id, _msgLen, _lastMessageAt); }
|
|
5163
|
+
catch (e) { console.error('[db] chunked-import source-len stamp failed:', e.message); }
|
|
5164
|
+
}
|
|
5165
|
+
|
|
5090
5166
|
// Keep message-level search/review surfaces in lockstep with the durable
|
|
5091
5167
|
// conversation cache. The older one-shot backfill path intentionally skips
|
|
5092
5168
|
// sessions that already have rows, which made long-running imported Codex
|
|
@@ -7886,6 +7962,9 @@ function replaceSessionMessages(sessionId, messages) {
|
|
|
7886
7962
|
// transcript timestamp is available, advance last_message_at without allowing
|
|
7887
7963
|
// partial rewrites or compacted sources to move activity backward.
|
|
7888
7964
|
function _setExtractedSourceLen(d, ctmSessionId, sourceLen, lastMessageAt = '') {
|
|
7965
|
+
// Any row write reaches here to advance the watermark — drop the cached rows-available verdict so
|
|
7966
|
+
// sessionContentRowsAvailable re-COUNTs once against the new state (covers same-watermark mutations).
|
|
7967
|
+
_invalidateRowsAvailCache(ctmSessionId);
|
|
7889
7968
|
try {
|
|
7890
7969
|
d.prepare('UPDATE session_conversations SET extracted_source_len = ? WHERE ctm_session_id = ?')
|
|
7891
7970
|
.run(Number(sourceLen) || 0, ctmSessionId);
|
|
@@ -8221,8 +8300,10 @@ function appendSessionConversation({
|
|
|
8221
8300
|
function getSessionMessagesArray(sessionId, { fallbackToBlob = true } = {}) {
|
|
8222
8301
|
const d = getDb();
|
|
8223
8302
|
if (_tableExists('session_message_rows')) {
|
|
8224
|
-
|
|
8225
|
-
|
|
8303
|
+
// Delegate the SELECT + row→object map to the shared lib so this row read is byte-identical
|
|
8304
|
+
// to the read-pool worker's off-thread getSessionMessagesArray op (parity by construction).
|
|
8305
|
+
const rows = require('./lib/session-messages-page').getMessagesArray(d, { sessionId });
|
|
8306
|
+
if (rows.length) return rows;
|
|
8226
8307
|
}
|
|
8227
8308
|
if (fallbackToBlob) {
|
|
8228
8309
|
try {
|
|
@@ -8236,16 +8317,40 @@ function getSessionMessagesArray(sessionId, { fallbackToBlob = true } = {}) {
|
|
|
8236
8317
|
// True only when the faithful rows can serve this session: present AND fully backfilled
|
|
8237
8318
|
// (row count covers the conversation's extracted length). A half-migrated session returns
|
|
8238
8319
|
// false → the caller uses the complete blob/JSONL path.
|
|
8320
|
+
// Watermark-keyed cache of the "rows fully cover the conversation" verdict. sessionContentRowsAvailable
|
|
8321
|
+
// is on MANY hot/periodic main-loop paths — most heavily the per-codex-session snapshot serialize
|
|
8322
|
+
// (_serializeSessionSnapshot → _codexRolloutHistoryActive → _codexRowLookupId), plus the
|
|
8323
|
+
// apiSessionMessages gate, attach, and exit — and each call ran an O(rows) COUNT(*) that cold-page
|
|
8324
|
+
// faults for seconds (the `(unknown)` cpu-low apiSessionMessages/snapshot freeze on the primary).
|
|
8325
|
+
// The gate is `cnt >= expected` where expected = extracted_source_len (the import high-water mark).
|
|
8326
|
+
// Rows only grow and `expected` advances only as import progresses, so a TRUE verdict stays true
|
|
8327
|
+
// while `expected` is unchanged. Cache ONLY true verdicts keyed on `expected`: a key match means
|
|
8328
|
+
// cnt was already ≥ this watermark and (rows-only-grow) still is — skip the COUNT(*). A false/
|
|
8329
|
+
// half-migrated verdict is NOT cached (it must be re-checked so a just-completed migration is
|
|
8330
|
+
// picked up). Row writes invalidate the entry (belt-and-suspenders for a same-watermark row
|
|
8331
|
+
// mutation). CTM_ROWS_AVAIL_CACHE=0 disables.
|
|
8332
|
+
const _rowsAvailCache = new Map(); // ctm_session_id -> expected (watermark at which it was TRUE)
|
|
8333
|
+
function _invalidateRowsAvailCache(sessionId) { _rowsAvailCache.delete(String(sessionId || '')); }
|
|
8239
8334
|
function sessionContentRowsAvailable(sessionId) {
|
|
8240
8335
|
try {
|
|
8241
8336
|
const d = getDb();
|
|
8242
8337
|
if (!_tableExists('session_message_rows')) return false;
|
|
8243
|
-
|
|
8244
|
-
|
|
8338
|
+
// Cheap watermark read first (single non-blob column); also lets us skip the COUNT entirely for
|
|
8339
|
+
// sessions with no HWM (always false under the old logic too).
|
|
8245
8340
|
const conv = d.prepare('SELECT extracted_source_len FROM session_conversations WHERE ctm_session_id = ?').get(sessionId);
|
|
8246
8341
|
const expected = conv ? Number(conv.extracted_source_len) : 0;
|
|
8247
|
-
if (!(expected > 0)
|
|
8248
|
-
|
|
8342
|
+
if (!(expected > 0)) { _invalidateRowsAvailCache(sessionId); return false; } // unknown HWM → blob path
|
|
8343
|
+
const cacheOn = process.env.CTM_ROWS_AVAIL_CACHE !== '0';
|
|
8344
|
+
if (cacheOn && _rowsAvailCache.get(sessionId) === expected) return true; // TRUE@expected still holds (rows only grow)
|
|
8345
|
+
const cnt = Number(d.prepare('SELECT COUNT(*) AS n FROM session_message_rows WHERE ctm_session_id = ?').get(sessionId).n);
|
|
8346
|
+
const available = cnt > 0 && cnt >= expected; // unknown HWM or half-migrated → blob path
|
|
8347
|
+
if (available && cacheOn) {
|
|
8348
|
+
_rowsAvailCache.set(sessionId, expected);
|
|
8349
|
+
if (_rowsAvailCache.size > 1024) { const k = _rowsAvailCache.keys().next().value; _rowsAvailCache.delete(k); }
|
|
8350
|
+
} else {
|
|
8351
|
+
_invalidateRowsAvailCache(sessionId);
|
|
8352
|
+
}
|
|
8353
|
+
return available;
|
|
8249
8354
|
} catch { return false; }
|
|
8250
8355
|
}
|
|
8251
8356
|
|
|
@@ -8403,9 +8508,28 @@ function _messageFreshnessStatsForId(id, { userOnly = false } = {}) {
|
|
|
8403
8508
|
const d = getDb();
|
|
8404
8509
|
const bindId = String(id);
|
|
8405
8510
|
const whereRole = userOnly ? " AND role = 'user'" : '';
|
|
8406
|
-
|
|
8511
|
+
// The all-message session_message_rows store is DENSE — every message is a row at its array index
|
|
8512
|
+
// (replaceSessionMessageRows / appendSessionMessageRowsChunk write message_index = i contiguously
|
|
8513
|
+
// from 0, an invariant the row write paths already rely on), so COUNT(*) === MAX(message_index)+1.
|
|
8514
|
+
// The per-poll COUNT(*) over a giant (14k-19k-row) session was a 5.25s/4min on-main cost in the
|
|
8515
|
+
// live CPU profile; the query already computes MAX (an O(1) index seek), so deriving rows from it
|
|
8516
|
+
// drops the only O(rows) term with NO schema or write-path change — strictly better than a
|
|
8517
|
+
// maintained counter (no cross-thread drift). Equivalent as a change-detector: maxIndex moves on
|
|
8518
|
+
// every append, and the store is append-only / full-replace (no mid-array deletes), so maxIndex+1
|
|
8519
|
+
// changes exactly when the message set does. User-only rows are SPARSE (no density) and the legacy
|
|
8520
|
+
// session_messages store isn't guaranteed dense, so both keep the exact COUNT(*).
|
|
8521
|
+
const countStats = (table) => d.prepare(
|
|
8407
8522
|
`SELECT COALESCE(MAX(message_index), -1) AS maxIndex, COUNT(*) AS rows FROM ${table} WHERE ctm_session_id = ?${whereRole}`
|
|
8408
8523
|
).get(bindId);
|
|
8524
|
+
const denseStats = (table) => {
|
|
8525
|
+
const r = d.prepare(
|
|
8526
|
+
`SELECT COALESCE(MAX(message_index), -1) AS maxIndex FROM ${table} WHERE ctm_session_id = ?`
|
|
8527
|
+
).get(bindId);
|
|
8528
|
+
const maxIndex = Number(r && r.maxIndex != null ? r.maxIndex : -1);
|
|
8529
|
+
return { maxIndex, rows: maxIndex + 1 };
|
|
8530
|
+
};
|
|
8531
|
+
const readStats = (table) =>
|
|
8532
|
+
(!userOnly && table === 'session_message_rows') ? denseStats(table) : countStats(table);
|
|
8409
8533
|
|
|
8410
8534
|
// The row store is the default read source, but migration and tests can leave
|
|
8411
8535
|
// some sessions present only in the legacy filtered index. Pick per session
|
|
@@ -9383,14 +9507,15 @@ function upsertAgentSessionIdentity(agentSessionId, data = {}) {
|
|
|
9383
9507
|
parent_agent_session_id: lineage.parent_agent_session_id,
|
|
9384
9508
|
agent_nickname: lineage.agent_nickname,
|
|
9385
9509
|
agent_role: lineage.agent_role,
|
|
9510
|
+
forked_from_desktop_uuid: data.forkedFromDesktopUuid || data.forked_from_desktop_uuid || '',
|
|
9386
9511
|
};
|
|
9387
9512
|
d.prepare(`
|
|
9388
9513
|
INSERT INTO agent_sessions (agent_session_id, ctm_session_id, provider, provider_resume_id, project_path, jsonl_path,
|
|
9389
9514
|
first_message, file_size, modified_at, hostname, model, git_branch, user_msg_count, slug,
|
|
9390
|
-
thread_source, parent_agent_session_id, agent_nickname, agent_role)
|
|
9515
|
+
thread_source, parent_agent_session_id, agent_nickname, agent_role, forked_from_desktop_uuid)
|
|
9391
9516
|
VALUES (@agent_session_id, @ctm_session_id, @provider, @provider_resume_id, @project_path, @jsonl_path,
|
|
9392
9517
|
@first_message, @file_size, @modified_at, @hostname, @model, @git_branch, @user_msg_count, @slug,
|
|
9393
|
-
@thread_source, @parent_agent_session_id, @agent_nickname, @agent_role)
|
|
9518
|
+
@thread_source, @parent_agent_session_id, @agent_nickname, @agent_role, @forked_from_desktop_uuid)
|
|
9394
9519
|
ON CONFLICT(agent_session_id) DO UPDATE SET
|
|
9395
9520
|
ctm_session_id = COALESCE(agent_sessions.ctm_session_id, excluded.ctm_session_id),
|
|
9396
9521
|
provider = COALESCE(NULLIF(excluded.provider, ''), agent_sessions.provider),
|
|
@@ -9409,10 +9534,40 @@ function upsertAgentSessionIdentity(agentSessionId, data = {}) {
|
|
|
9409
9534
|
parent_agent_session_id = COALESCE(NULLIF(excluded.parent_agent_session_id, ''), agent_sessions.parent_agent_session_id),
|
|
9410
9535
|
agent_nickname = COALESCE(NULLIF(excluded.agent_nickname, ''), agent_sessions.agent_nickname),
|
|
9411
9536
|
agent_role = COALESCE(NULLIF(excluded.agent_role, ''), agent_sessions.agent_role),
|
|
9537
|
+
forked_from_desktop_uuid = COALESCE(NULLIF(excluded.forked_from_desktop_uuid, ''), agent_sessions.forked_from_desktop_uuid),
|
|
9412
9538
|
updated_at = datetime('now')
|
|
9413
9539
|
`).run(params);
|
|
9414
9540
|
}
|
|
9415
9541
|
|
|
9542
|
+
/**
|
|
9543
|
+
* Look up the Claude Code fork that was created from a given Claude Desktop conversation.
|
|
9544
|
+
* Returns the fork's agent_sessions row ({ agent_session_id, jsonl_path, ctm_session_id })
|
|
9545
|
+
* or null when the conversation has never been converted. Callers verify the fork still
|
|
9546
|
+
* exists (row + jsonl file) before treating the conversation as already-converted; a deleted
|
|
9547
|
+
* fork should re-offer conversion. Anchored on the stable Desktop uuid so a re-scan of the
|
|
9548
|
+
* Desktop cache never mints a duplicate fork.
|
|
9549
|
+
*/
|
|
9550
|
+
function getForkForDesktopUuid(desktopUuid) {
|
|
9551
|
+
const id = String(desktopUuid || '').trim();
|
|
9552
|
+
if (!id) return null;
|
|
9553
|
+
return getDb().prepare(
|
|
9554
|
+
'SELECT agent_session_id, jsonl_path, ctm_session_id FROM agent_sessions WHERE forked_from_desktop_uuid = ? LIMIT 1'
|
|
9555
|
+
).get(id) || null;
|
|
9556
|
+
}
|
|
9557
|
+
|
|
9558
|
+
/**
|
|
9559
|
+
* List every Claude Code fork created from a Claude Desktop conversation, keyed by the
|
|
9560
|
+
* originating Desktop uuid. The client fetches this to decide, per Desktop conversation,
|
|
9561
|
+
* whether to offer "Convert" or "Resume fork". Callers verify the transcript still exists
|
|
9562
|
+
* before trusting the link (a deleted fork should re-offer conversion).
|
|
9563
|
+
*/
|
|
9564
|
+
function listDesktopForks() {
|
|
9565
|
+
return getDb().prepare(
|
|
9566
|
+
"SELECT forked_from_desktop_uuid AS desktopUuid, agent_session_id AS forkSessionId, jsonl_path AS jsonlPath, project_path AS projectPath " +
|
|
9567
|
+
"FROM agent_sessions WHERE forked_from_desktop_uuid != ''"
|
|
9568
|
+
).all();
|
|
9569
|
+
}
|
|
9570
|
+
|
|
9416
9571
|
function setSessionStar(id, starred) {
|
|
9417
9572
|
// Try ctm_sessions first
|
|
9418
9573
|
const result = getDb().prepare("UPDATE ctm_sessions SET starred = ?, updated_at = datetime('now') WHERE id = ?")
|
|
@@ -10249,7 +10404,7 @@ module.exports = {
|
|
|
10249
10404
|
getStorageRisk,
|
|
10250
10405
|
getSqliteDriverStatus,
|
|
10251
10406
|
ensureRuntimeKernelTables,
|
|
10252
|
-
appendRuntimeEvent, listRuntimeEvents,
|
|
10407
|
+
appendRuntimeEvent, listRuntimeEvents, latestRuntimeEventOfTypes,
|
|
10253
10408
|
upsertRuntimeTurn, getRuntimeTurn, listRuntimeTurns,
|
|
10254
10409
|
upsertRuntimeInputEnvelope, updateRuntimeInputEnvelope, getRuntimeInputEnvelope, listRuntimeInputEnvelopes,
|
|
10255
10410
|
upsertRuntimeRouteSnapshot, getLatestRuntimeRouteSnapshot,
|
|
@@ -10318,7 +10473,7 @@ module.exports = {
|
|
|
10318
10473
|
setSessionTitleNew, setSessionTitleStatusNew, getSessionTitleNew, getSessionTitlesByIds, getSessionDisplayTitleInfo, isUserSessionTitleCandidate, promoteStartupTaskTitleIfUserNamed, repairSessionTitlesFromStartupTasks, getAllSessionsData,
|
|
10319
10474
|
repairCodexSessionMetadataFromConversations, repairCodexRolloutAgentIdentities, detachPromotedProviderChildSessions,
|
|
10320
10475
|
listProviderChildAgentOwnerMappings,
|
|
10321
|
-
getAgentSessions, getAgentSession, deleteCtmSession,
|
|
10476
|
+
getAgentSessions, getAgentSession, getForkForDesktopUuid, listDesktopForks, deleteCtmSession,
|
|
10322
10477
|
// Schema version
|
|
10323
10478
|
getSchemaVersion,
|
|
10324
10479
|
};
|