create-walle 0.9.30 → 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 (36) hide show
  1. package/package.json +1 -1
  2. package/template/claude-task-manager/api-prompts.js +4 -4
  3. package/template/claude-task-manager/api-reviews.js +153 -3
  4. package/template/claude-task-manager/approval-agent.js +37 -5
  5. package/template/claude-task-manager/docs/prompt-manager-redesign-proposal.html +666 -0
  6. package/template/claude-task-manager/docs/resume-ux-redesign.html +493 -0
  7. package/template/claude-task-manager/docs/review-redesign-proposal.html +892 -0
  8. package/template/claude-task-manager/lib/db-owner-cooperative-scheduler.js +10 -1
  9. package/template/claude-task-manager/lib/headless-term-service.js +12 -1
  10. package/template/claude-task-manager/lib/native-agent-model-args.js +118 -0
  11. package/template/claude-task-manager/lib/session-history.js +4 -1
  12. package/template/claude-task-manager/lib/tui-input-modes.js +40 -0
  13. package/template/claude-task-manager/providers/claude-code.js +20 -1
  14. package/template/claude-task-manager/public/css/reviews.css +232 -0
  15. package/template/claude-task-manager/public/index.html +785 -134
  16. package/template/claude-task-manager/public/js/document-review-links.js +40 -4
  17. package/template/claude-task-manager/public/js/file-context-menu.js +185 -0
  18. package/template/claude-task-manager/public/js/message-renderer.js +27 -1
  19. package/template/claude-task-manager/public/js/resume-state.js +186 -0
  20. package/template/claude-task-manager/public/js/reviews.js +377 -70
  21. package/template/claude-task-manager/public/js/screenshot-router.js +91 -0
  22. package/template/claude-task-manager/public/js/session-search-utils.js +26 -0
  23. package/template/claude-task-manager/public/js/state-sync-client.js +95 -17
  24. package/template/claude-task-manager/public/js/stream-view.js +87 -20
  25. package/template/claude-task-manager/server.js +151 -6
  26. package/template/package.json +1 -1
  27. package/template/wall-e/api-walle.js +21 -3
  28. package/template/wall-e/chat.js +101 -8
  29. package/template/wall-e/llm/client.js +64 -5
  30. package/template/wall-e/llm/codex-cli.js +71 -23
  31. package/template/wall-e/llm/codex-cli.plugin.json +1 -0
  32. package/template/wall-e/llm/default-fallback.js +12 -6
  33. package/template/wall-e/llm/provider-error.js +7 -1
  34. package/template/wall-e/llm/provider-health-state.js +557 -11
  35. package/template/wall-e/llm/registry.js +6 -0
  36. package/template/wall-e/llm/routing-policy.js +255 -25
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-walle",
3
- "version": "0.9.30",
3
+ "version": "0.9.31",
4
4
  "description": "CTM + Wall-E — AI coding dashboard and personal digital twin agent. Multi-agent terminal for Claude Code, Codex, Gemini, Aider, OpenCode, and more, plus prompt editor, task queue, remote phone and tablet access, code/doc review, and an agent that learns from Slack, email & calendar.",
5
5
  "bin": {
6
6
  "create-walle": "bin/create-walle.js"
@@ -28,6 +28,7 @@ const { queryPromptExecutions } = require('./lib/prompt-executions-query');
28
28
  const { createIngestCooldown } = require('./lib/ingest-cooldown');
29
29
  const { claudeFileSessionId, _usageMetadata } = require('./lib/jsonl-conversation-parser');
30
30
  const structuredCapture = require('./lib/structured-capture');
31
+ const { coopSchedulerEnabled } = require('./lib/db-owner-cooperative-scheduler');
31
32
  // AI search uses direct HTTP calls to Claude API (supports Portkey proxy)
32
33
 
33
34
  let dbMaintenanceRunner = null;
@@ -1852,10 +1853,9 @@ function _readResetChunkedRouteStats() {
1852
1853
 
1853
1854
  function _chunkedImportFlags(sessionId, messages, fileSize, reason) {
1854
1855
  // Gated on the coop scheduler: deferring rows only helps when the cooperative scheduler is driving
1855
- // bounded slices (and the content-rows-backfill coop/legacy job fills the deferred rows). With the
1856
- // flag OFF the import behaves byte-for-byte as legacy (rows written inline) — so merging this branch
1857
- // is a true no-op on the primary until CTM_COOP_SCHEDULER=1 is set.
1858
- if (process.env.CTM_COOP_SCHEDULER !== '1') return { skipMessageRows: false, forceBlobWrite: false };
1856
+ // bounded slices (and the content-rows-backfill coop/legacy job fills the deferred rows). The feature
1857
+ // is ON by default; set CTM_COOP_SCHEDULER=0 to fall back to byte-for-byte legacy (rows written inline).
1858
+ if (!coopSchedulerEnabled()) return { skipMessageRows: false, forceBlobWrite: false };
1859
1859
  const minMsgs = Math.max(1, Number(process.env.CTM_IMPORT_CHUNK_MIN_MSGS || 1000));
1860
1860
  // ALSO trigger on file size: a giant rollout is tail-bounded at parse time (transcriptMaxBytes),
1861
1861
  // so `messages.length` here can be small even though the session is huge and its inline row write
@@ -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) {