create-walle 0.9.30 → 0.9.32

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 (112) hide show
  1. package/package.json +1 -1
  2. package/template/claude-task-manager/api-prompts.js +410 -5
  3. package/template/claude-task-manager/api-reviews.js +251 -3
  4. package/template/claude-task-manager/approval-agent.js +322 -24
  5. package/template/claude-task-manager/db.js +77 -13
  6. package/template/claude-task-manager/docs/codex-app-server-approvals.md +35 -1
  7. package/template/claude-task-manager/docs/postgres-concurrency-dgx-spark.html +407 -0
  8. package/template/claude-task-manager/docs/prompt-manager-redesign-proposal.html +667 -0
  9. package/template/claude-task-manager/docs/resume-ux-redesign.html +493 -0
  10. package/template/claude-task-manager/docs/review-redesign-proposal.html +892 -0
  11. package/template/claude-task-manager/docs/unified-auto-approver-proposal.html +520 -0
  12. package/template/claude-task-manager/git-utils.js +164 -26
  13. package/template/claude-task-manager/lib/agent-capabilities.js +26 -5
  14. package/template/claude-task-manager/lib/agent-cli-cache.js +1 -1
  15. package/template/claude-task-manager/lib/agent-presets.js +12 -0
  16. package/template/claude-task-manager/lib/agent-version-service.js +650 -0
  17. package/template/claude-task-manager/lib/approval-drift.js +57 -0
  18. package/template/claude-task-manager/lib/approval-hook.js +13 -0
  19. package/template/claude-task-manager/lib/auth-rules.js +6 -0
  20. package/template/claude-task-manager/lib/broadcast-payload-memo.js +109 -0
  21. package/template/claude-task-manager/lib/coding-agent-models.js +89 -8
  22. package/template/claude-task-manager/lib/db-owner-cooperative-scheduler.js +10 -1
  23. package/template/claude-task-manager/lib/document-review.js +75 -1
  24. package/template/claude-task-manager/lib/escalation-review.js +33 -3
  25. package/template/claude-task-manager/lib/headless-term-service.js +54 -9
  26. package/template/claude-task-manager/lib/mcp-risk.js +128 -0
  27. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +51 -2
  28. package/template/claude-task-manager/lib/native-agent-model-args.js +149 -0
  29. package/template/claude-task-manager/lib/opencode-conversation-store.js +269 -0
  30. package/template/claude-task-manager/lib/permission-match.js +77 -5
  31. package/template/claude-task-manager/lib/read-pool-client.js +15 -6
  32. package/template/claude-task-manager/lib/restore-backoff.js +82 -0
  33. package/template/claude-task-manager/lib/runtime-registry.js +1 -0
  34. package/template/claude-task-manager/lib/scheduled-wake.js +42 -0
  35. package/template/claude-task-manager/lib/session-capture.js +33 -6
  36. package/template/claude-task-manager/lib/session-history.js +60 -3
  37. package/template/claude-task-manager/lib/session-jobs.js +19 -0
  38. package/template/claude-task-manager/lib/session-restore.js +5 -4
  39. package/template/claude-task-manager/lib/session-standup.js +2 -0
  40. package/template/claude-task-manager/lib/session-stream.js +9 -0
  41. package/template/claude-task-manager/lib/state-sync/cell-diff.js +31 -4
  42. package/template/claude-task-manager/lib/state-sync/restore-frame-hold.js +39 -0
  43. package/template/claude-task-manager/lib/tui-input-modes.js +40 -0
  44. package/template/claude-task-manager/lib/walle-ctm-history.js +103 -36
  45. package/template/claude-task-manager/lib/walle-session-model-catalog.js +7 -1
  46. package/template/claude-task-manager/lib/walle-token-chip.js +65 -0
  47. package/template/claude-task-manager/package.json +1 -1
  48. package/template/claude-task-manager/providers/claude-code.js +20 -1
  49. package/template/claude-task-manager/providers/codex-mcp-extract.js +33 -0
  50. package/template/claude-task-manager/providers/codex-mcp.js +23 -20
  51. package/template/claude-task-manager/providers/codex.js +65 -1
  52. package/template/claude-task-manager/public/css/prompts.css +345 -0
  53. package/template/claude-task-manager/public/css/reviews.css +248 -40
  54. package/template/claude-task-manager/public/css/walle-session.css +90 -26
  55. package/template/claude-task-manager/public/index.html +2031 -428
  56. package/template/claude-task-manager/public/js/document-review-links.js +255 -13
  57. package/template/claude-task-manager/public/js/file-context-menu.js +185 -0
  58. package/template/claude-task-manager/public/js/message-renderer.js +46 -1
  59. package/template/claude-task-manager/public/js/mobile-review-core.js +54 -0
  60. package/template/claude-task-manager/public/js/prompt-diff.js +55 -0
  61. package/template/claude-task-manager/public/js/prompt-editor-chrome.js +108 -0
  62. package/template/claude-task-manager/public/js/prompt-editor-modes.js +87 -0
  63. package/template/claude-task-manager/public/js/prompt-organize.js +179 -0
  64. package/template/claude-task-manager/public/js/prompt-reuse.js +77 -0
  65. package/template/claude-task-manager/public/js/prompts.js +683 -215
  66. package/template/claude-task-manager/public/js/resume-state.js +186 -0
  67. package/template/claude-task-manager/public/js/reviews.js +402 -121
  68. package/template/claude-task-manager/public/js/screenshot-router.js +91 -0
  69. package/template/claude-task-manager/public/js/session-search-utils.js +81 -0
  70. package/template/claude-task-manager/public/js/state-sync-client.js +134 -17
  71. package/template/claude-task-manager/public/js/stream-view.js +93 -20
  72. package/template/claude-task-manager/public/js/walle-session.js +183 -32
  73. package/template/claude-task-manager/public/js/walle.js +21 -9
  74. package/template/claude-task-manager/public/m/app.css +117 -0
  75. package/template/claude-task-manager/public/m/app.js +309 -3
  76. package/template/claude-task-manager/public/m/index.html +56 -1
  77. package/template/claude-task-manager/server.js +724 -305
  78. package/template/claude-task-manager/workers/db-owner-worker.js +8 -0
  79. package/template/claude-task-manager/workers/read-pool-worker.js +4 -0
  80. package/template/claude-task-manager/workers/state-detectors/claude-code.js +10 -1
  81. package/template/docs/proposals/2026-06-23-local-model-right-path.html +340 -0
  82. package/template/docs/proposals/2026-06-24-qlora-done-right.md +255 -0
  83. package/template/package.json +1 -1
  84. package/template/wall-e/api-walle.js +21 -3
  85. package/template/wall-e/bin/train-gemma-e4b-tooluse.js +27 -4
  86. package/template/wall-e/brain.js +50 -5
  87. package/template/wall-e/chat/force-compact.js +73 -0
  88. package/template/wall-e/chat.js +137 -9
  89. package/template/wall-e/coding/action-memory-policy.js +120 -1
  90. package/template/wall-e/coding/compaction-service.js +10 -3
  91. package/template/wall-e/coding/model-router.js +116 -0
  92. package/template/wall-e/coding-orchestrator.js +303 -11
  93. package/template/wall-e/coding-prompts.js +1 -1
  94. package/template/wall-e/docs/skill-self-heal-design.html +429 -0
  95. package/template/wall-e/llm/client.js +115 -6
  96. package/template/wall-e/llm/codex-cli.js +71 -23
  97. package/template/wall-e/llm/codex-cli.plugin.json +1 -0
  98. package/template/wall-e/llm/default-fallback.js +71 -11
  99. package/template/wall-e/llm/mlx-worker.js +0 -0
  100. package/template/wall-e/llm/mlx.js +46 -12
  101. package/template/wall-e/llm/provider-error.js +9 -3
  102. package/template/wall-e/llm/provider-health-state.js +561 -11
  103. package/template/wall-e/llm/registry.js +6 -0
  104. package/template/wall-e/llm/routing-policy.js +255 -25
  105. package/template/wall-e/llm/tool-call-validator.js +156 -0
  106. package/template/wall-e/skills/skill-dispatch-decision.js +82 -2
  107. package/template/wall-e/skills/skill-planner.js +72 -5
  108. package/template/wall-e/tools/command-registry.js +34 -0
  109. package/template/wall-e/training/gemma-e4b-qlora.js +197 -1
  110. package/template/wall-e/training/mlx_lora_launch.py +196 -0
  111. package/template/wall-e/training/training-coexistence.sh +87 -0
  112. package/template/wall-e/training/training-mem-guard.sh +76 -0
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
  const path = require('path');
3
3
  const fs = require('fs');
4
+ const os = require('os');
4
5
  const db = require('./db');
5
6
  const gitUtils = require('./git-utils');
6
7
  const documentReview = require('./lib/document-review');
@@ -23,6 +24,132 @@ function isValidProjectPath(p) {
23
24
  return true;
24
25
  } catch { return false; }
25
26
  }
27
+ // Resolve `filePath` within `project` and enforce containment AFTER following
28
+ // symlinks. A lexical `resolved.startsWith(root + sep)` check is not enough: a
29
+ // symlink that lives inside the repo can point at /etc/passwd (or ~/.ssh) and the
30
+ // lexical path still looks in-bounds. realpath canonicalizes the link target, so
31
+ // containment is checked against where the file ACTUALLY is.
32
+ // Returns the safe absolute path to read, or null if it escapes the project.
33
+ // A path that doesn't exist yet (ENOENT) can't be a symlink, so the lexical check
34
+ // already guarantees safety — return it so the caller can produce its own 404.
35
+ // The repo's worktree roots, main-style checkouts first (work usually lands there), capped.
36
+ function htmlWorktreeRoots(projectRoot, cap = 20) {
37
+ try {
38
+ const { execFileSync } = require('child_process');
39
+ const out = execFileSync('git', ['worktree', 'list', '--porcelain'],
40
+ { cwd: projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
41
+ const roots = [];
42
+ for (const line of out.split('\n')) {
43
+ if (line.startsWith('worktree ')) roots.push(line.slice('worktree '.length).trim());
44
+ }
45
+ const isWorktreeSubdir = (pth) => /\/\.(?:claude|walle)\/worktrees\/|\/\.worktrees\//.test(pth);
46
+ roots.sort((a, b) => (isWorktreeSubdir(a) ? 1 : 0) - (isWorktreeSubdir(b) ? 1 : 0));
47
+ return roots.slice(0, Math.max(1, cap));
48
+ } catch { return []; }
49
+ }
50
+
51
+ // Find an .html file (tracked + untracked) within a single worktree `root` by exact relative
52
+ // path, suffix, or basename.
53
+ function findHtmlInRoot(root, want, base) {
54
+ try {
55
+ const { execFileSync } = require('child_process');
56
+ const out = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '*.html', '*.htm'],
57
+ { cwd: root, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }).split('\0').filter(Boolean);
58
+ let hit = out.find(f => f === want || f.endsWith('/' + want));
59
+ if (!hit) hit = out.find(f => path.basename(f) === base);
60
+ return hit ? path.join(root, hit) : null;
61
+ } catch { return null; }
62
+ }
63
+
64
+ // Best-effort fallback when an html path doesn't resolve at the given root — e.g. a path
65
+ // relative to the repo root while the session cwd is a subdir, OR a file that lives in a
66
+ // SIBLING worktree of the same repo (work landed on main while the session runs in a feature
67
+ // worktree). Searches the own worktree first, then the repo's others; returns a path under
68
+ // the user's HOME or null.
69
+ function findHtmlInRepo(root, relPath) {
70
+ const want = String(relPath || '').replace(/^\.?\//, '');
71
+ if (!want) return null;
72
+ const base = path.basename(want);
73
+ const home = process.env.HOME || '';
74
+ const underHome = (pth) => {
75
+ if (!home) return false;
76
+ const r = path.resolve(pth);
77
+ return r === home || r.startsWith(home + path.sep);
78
+ };
79
+ const own = findHtmlInRoot(root, want, base);
80
+ if (own && underHome(own)) return own;
81
+ let realRoot = '';
82
+ try { realRoot = fs.realpathSync(root); } catch { realRoot = path.resolve(root); }
83
+ for (const wt of htmlWorktreeRoots(root)) {
84
+ let realWt = '';
85
+ try { realWt = fs.realpathSync(wt); } catch { realWt = path.resolve(wt); }
86
+ if (realWt === realRoot) continue; // own worktree already searched
87
+ const hit = findHtmlInRoot(wt, want, base);
88
+ if (hit && underHome(hit)) return hit;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ function htmlNotFoundPage(filePath) {
94
+ const safe = String(filePath || '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
95
+ return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>File not found</title>
96
+ <style>body{font-family:-apple-system,system-ui,sans-serif;background:#1a1b26;color:#c0caf5;display:grid;place-items:center;height:100vh;margin:0}
97
+ .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>
98
+ </head><body><div class="box"><h1>File not found</h1>
99
+ <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>`;
100
+ }
101
+
102
+ function resolveWithinProject(project, filePath) {
103
+ if (!project || typeof filePath !== 'string') return null;
104
+ const root = path.resolve(project);
105
+ const resolved = path.resolve(root, filePath);
106
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) return null; // lexical ../ escape
107
+ let realRoot;
108
+ try { realRoot = fs.realpathSync(root); }
109
+ catch { return null; }
110
+ let realFile;
111
+ try { realFile = fs.realpathSync(resolved); }
112
+ catch (e) {
113
+ if (e && e.code === 'ENOENT') return resolved; // lexically in-bounds, not yet on disk
114
+ return null;
115
+ }
116
+ if (realFile !== realRoot && !realFile.startsWith(realRoot + path.sep)) return null; // symlink escape
117
+ return realFile;
118
+ }
119
+
120
+ // The per-session scratchpad lives OUTSIDE the project root AND outside HOME:
121
+ // <tmp>/claude-<uid>/<project-slug>/<session>/scratchpad/<file>.html
122
+ // (e.g. <tmpdir>/claude-<uid>/.../scratchpad/plan.html). Agents author HTML there and
123
+ // print the absolute path into session output; clicking it built a serve-html URL that
124
+ // 403'd because resolveWithinProject (project- and HOME-scoped) rejected it. Serve these
125
+ // too — same trust level as project HTML (same uid, mode-0700 temp dir), behind the same
126
+ // .html + sandbox-CSP guards, with realpath containment to block a symlink escaping the
127
+ // scratchpad. Returns the safe absolute path to read, or null.
128
+ function scratchpadRoots() {
129
+ let uid = '';
130
+ try { if (typeof process.getuid === 'function') uid = String(process.getuid()); } catch {}
131
+ if (!uid) return [];
132
+ const roots = new Set();
133
+ // os.tmpdir() (launchd gives a per-user /var/folders/... TMPDIR) plus the /tmp aliases
134
+ // Claude Code actually uses; realpath collapses /tmp → /private/tmp on macOS and dedupes.
135
+ for (const base of [os.tmpdir(), '/tmp', '/private/tmp']) {
136
+ try { roots.add(fs.realpathSync(path.join(base, 'claude-' + uid))); } catch {}
137
+ }
138
+ return [...roots];
139
+ }
140
+
141
+ function resolveWithinScratchpad(filePath) {
142
+ if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) return null;
143
+ const roots = scratchpadRoots();
144
+ if (!roots.length) return null;
145
+ let realFile;
146
+ try { realFile = fs.realpathSync(filePath); } // must exist; no pre-create 404 for scratchpad
147
+ catch { return null; }
148
+ for (const root of roots) {
149
+ if (realFile === root || realFile.startsWith(root + path.sep)) return realFile;
150
+ }
151
+ return null;
152
+ }
26
153
  function jsonResponse(res, code, data) {
27
154
  const body = JSON.stringify(data);
28
155
  res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
@@ -234,6 +361,22 @@ function handleReviewApi(req, res, url) {
234
361
  return true;
235
362
  }
236
363
 
364
+ // POST /api/reviews/verify-commits — given a repo and a list of candidate SHAs
365
+ // (mined from session output), return only the ones that are real commits, with
366
+ // their short hash + subject. Lets the client linkify commit references safely.
367
+ if (p === '/api/reviews/verify-commits' && m === 'POST') {
368
+ readBody(req).then(body => {
369
+ const project = body && body.project;
370
+ const shas = (body && Array.isArray(body.shas)) ? body.shas.slice(0, 50) : [];
371
+ if (!project) return jsonResponse(res, 400, { error: 'project required' });
372
+ if (!isValidProjectPath(project)) return jsonResponse(res, 403, { error: 'Invalid project path' });
373
+ gitUtils.verifyCommits(project, shas)
374
+ .then(commits => jsonResponse(res, 200, { commits }))
375
+ .catch(e => jsonResponse(res, 500, { error: e.message }));
376
+ }).catch(e => jsonResponse(res, 400, { error: e.message }));
377
+ return true;
378
+ }
379
+
237
380
  // GET /api/reviews/diff-stat?project=...&base=...
238
381
  if (p === '/api/reviews/diff-stat' && m === 'GET') {
239
382
  const project = url.searchParams.get('project');
@@ -364,9 +507,9 @@ function handleReviewApi(req, res, url) {
364
507
  const ext = path.extname(filePath).toLowerCase();
365
508
  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
509
  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' });
510
+ // Prevent path traversal (incl. symlinks escaping the project — see resolveWithinProject)
511
+ const resolved = resolveWithinProject(project, filePath);
512
+ if (!resolved) return jsonResponse(res, 403, { error: 'Invalid file path' });
370
513
  try {
371
514
  const data = fs.readFileSync(resolved);
372
515
  res.writeHead(200, { 'Content-Type': imageExts[ext], 'Content-Length': data.length, 'Cache-Control': 'no-cache' });
@@ -462,6 +605,108 @@ function handleReviewApi(req, res, url) {
462
605
  return jsonResponse(res, 200, { prompt });
463
606
  }
464
607
 
608
+ // GET /api/reviews/documents?project=... — list reviewable markdown/text files in a
609
+ // project (powers the Review Hub "Documents" discovery column). Tracked + untracked-
610
+ // not-ignored, bounded, sorted most-recently-modified first.
611
+ if (p === '/api/reviews/documents' && m === 'GET') {
612
+ const project = url.searchParams.get('project');
613
+ if (!project) return jsonResponse(res, 400, { error: 'project required' });
614
+ if (!isValidProjectPath(project)) return jsonResponse(res, 403, { error: 'Invalid project path' });
615
+ try {
616
+ const { execFileSync } = require('child_process');
617
+ const root = path.resolve(project);
618
+ const globs = ['*.md', '*.markdown', '*.mdown', '*.txt'];
619
+ // -z → NUL-separated so paths with spaces/specials survive (no core.quotePath issues).
620
+ const run = (args) => {
621
+ try {
622
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 })
623
+ .split('\0').filter(Boolean);
624
+ } catch { return []; }
625
+ };
626
+ const set = new Set([
627
+ ...run(['ls-files', '-z', ...globs]),
628
+ ...run(['ls-files', '-z', '--others', '--exclude-standard', ...globs]),
629
+ ]);
630
+ let items = [...set].slice(0, 2000).map(rel => {
631
+ let mtime = 0;
632
+ try { mtime = fs.statSync(path.join(root, rel)).mtimeMs; } catch {}
633
+ return { path: rel, name: path.basename(rel), dir: path.dirname(rel), mtime };
634
+ });
635
+ items.sort((a, b) => b.mtime - a.mtime);
636
+ return jsonResponse(res, 200, { documents: items.slice(0, 300), total: set.size });
637
+ } catch (e) {
638
+ return jsonResponse(res, 500, { error: e.message });
639
+ }
640
+ }
641
+
642
+ // GET /api/reviews/serve-html?project=...&path=... — serve a project .html file so the
643
+ // file-router can open it in a new browser tab. Path-validated; no traversal outside project.
644
+ if (p === '/api/reviews/serve-html' && m === 'GET') {
645
+ const project = url.searchParams.get('project');
646
+ const filePath = url.searchParams.get('path');
647
+ if (!project || !filePath) return jsonResponse(res, 400, { error: 'project and path required' });
648
+ if (!isValidProjectPath(project)) return jsonResponse(res, 403, { error: 'Invalid project path' });
649
+ const ext = path.extname(filePath).toLowerCase();
650
+ if (ext !== '.html' && ext !== '.htm') return jsonResponse(res, 400, { error: 'Only .html files are supported' });
651
+ let resolved = resolveWithinProject(project, filePath);
652
+ // The file may be agent-authored HTML in the session scratchpad (absolute /tmp path,
653
+ // outside the project and HOME). realpath-validated to the user's claude-<uid> temp dir.
654
+ if (!resolved) resolved = resolveWithinScratchpad(filePath);
655
+ if (!resolved) return jsonResponse(res, 403, { error: 'Invalid file path' });
656
+ // Fallback: the path may be relative to the repo root while the session cwd is a
657
+ // subdir/worktree — search the repo for the file before giving up.
658
+ if (!fs.existsSync(resolved)) {
659
+ const found = findHtmlInRepo(path.resolve(project), filePath);
660
+ if (found) resolved = found;
661
+ }
662
+ // Sandbox the served file: arbitrary repo HTML rendered on the CTM origin (which holds
663
+ // the auth cookie). A bare `sandbox` blocks scripting AND assigns an opaque origin, so it
664
+ // can't run JS, read CTM storage, or make cookie-authed calls — inline CSS/images still render.
665
+ const htmlHeaders = (len) => ({
666
+ 'Content-Type': 'text/html; charset=utf-8',
667
+ 'Content-Length': len,
668
+ 'Cache-Control': 'no-cache',
669
+ 'X-Content-Type-Options': 'nosniff',
670
+ 'Content-Security-Policy': 'sandbox',
671
+ });
672
+ let data;
673
+ try { data = fs.readFileSync(resolved); }
674
+ catch {
675
+ // Friendly in-tab page instead of a scary "unreachable file" / bare text 404.
676
+ const body = Buffer.from(htmlNotFoundPage(filePath));
677
+ res.writeHead(404, htmlHeaders(body.length));
678
+ res.end(body);
679
+ return true;
680
+ }
681
+ res.writeHead(200, htmlHeaders(data.length));
682
+ res.end(data);
683
+ return true;
684
+ }
685
+
686
+ // POST /api/reviews/reveal {project, path} — reveal a project file in Finder (macOS
687
+ // `open -R`). Path-validated; best-effort (the file-router menu's "Reveal" action).
688
+ if (p === '/api/reviews/reveal' && m === 'POST') {
689
+ readBody(req).then(body => {
690
+ const project = String(body.project || '');
691
+ const filePath = String(body.path || '');
692
+ if (!project || !filePath) return jsonResponse(res, 400, { error: 'project and path required' });
693
+ if (!isValidProjectPath(project)) return jsonResponse(res, 403, { error: 'Invalid project path' });
694
+ const resolved = resolveWithinProject(project, filePath);
695
+ if (!resolved) return jsonResponse(res, 403, { error: 'Invalid file path' });
696
+ if (!fs.existsSync(resolved)) return jsonResponse(res, 404, { error: 'File not found' });
697
+ try {
698
+ const { execFile } = require('child_process');
699
+ const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
700
+ const args = process.platform === 'darwin' ? ['-R', resolved] : [path.dirname(resolved)];
701
+ execFile(cmd, args, () => {});
702
+ jsonResponse(res, 200, { ok: true });
703
+ } catch (e) {
704
+ jsonResponse(res, 500, { error: e.message });
705
+ }
706
+ }).catch(e => jsonResponse(res, 400, { error: e.message }));
707
+ return true;
708
+ }
709
+
465
710
  return false; // Not handled
466
711
  }
467
712
 
@@ -508,4 +753,7 @@ module.exports = {
508
753
  _sanitizeReviewCommentInput: sanitizeReviewCommentInput,
509
754
  _sanitizeReviewCommentUpdate: sanitizeReviewCommentUpdate,
510
755
  _documentReview: documentReview,
756
+ _resolveWithinProject: resolveWithinProject,
757
+ _resolveWithinScratchpad: resolveWithinScratchpad,
758
+ _findHtmlInRepo: findHtmlInRepo,
511
759
  };