create-walle 0.9.21 → 0.9.22

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 (52) hide show
  1. package/README.md +5 -5
  2. package/package.json +2 -2
  3. package/template/claude-task-manager/api-prompts.js +13 -0
  4. package/template/claude-task-manager/api-reviews.js +5 -2
  5. package/template/claude-task-manager/db.js +348 -15
  6. package/template/claude-task-manager/docs/app-update-refresh-protocol.md +69 -0
  7. package/template/claude-task-manager/docs/image-paste-ux.md +3 -0
  8. package/template/claude-task-manager/docs/ipad-web-preview.md +88 -0
  9. package/template/claude-task-manager/git-utils.js +146 -17
  10. package/template/claude-task-manager/lib/auth-rate-limit.js +23 -3
  11. package/template/claude-task-manager/lib/auth-rules.js +3 -0
  12. package/template/claude-task-manager/lib/document-review.js +33 -2
  13. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +83 -0
  14. package/template/claude-task-manager/lib/mobile-auth-api.js +14 -0
  15. package/template/claude-task-manager/lib/restart-guard.js +68 -0
  16. package/template/claude-task-manager/lib/session-standup.js +36 -13
  17. package/template/claude-task-manager/lib/session-stream.js +11 -4
  18. package/template/claude-task-manager/lib/transport-security.js +50 -0
  19. package/template/claude-task-manager/lib/walle-transcript.js +16 -0
  20. package/template/claude-task-manager/lib/worktree-active-sync.js +6 -3
  21. package/template/claude-task-manager/public/css/reviews.css +10 -0
  22. package/template/claude-task-manager/public/css/setup.css +13 -0
  23. package/template/claude-task-manager/public/css/walle.css +145 -0
  24. package/template/claude-task-manager/public/index.html +539 -44
  25. package/template/claude-task-manager/public/ipad.html +363 -0
  26. package/template/claude-task-manager/public/js/document-review-links.js +196 -0
  27. package/template/claude-task-manager/public/js/message-renderer.js +14 -3
  28. package/template/claude-task-manager/public/js/reviews.js +30 -6
  29. package/template/claude-task-manager/public/js/setup.js +42 -2
  30. package/template/claude-task-manager/public/js/stream-view.js +20 -1
  31. package/template/claude-task-manager/public/js/walle.js +314 -18
  32. package/template/claude-task-manager/public/m/app.css +789 -11
  33. package/template/claude-task-manager/public/m/app.js +1070 -67
  34. package/template/claude-task-manager/public/m/claim.html +9 -2
  35. package/template/claude-task-manager/public/m/index.html +17 -10
  36. package/template/claude-task-manager/public/m/sw.js +1 -1
  37. package/template/claude-task-manager/server.js +365 -95
  38. package/template/claude-task-manager/session-integrity.js +4 -0
  39. package/template/docs/designs/2026-05-17-portkey-gateway-provider-ux.md +86 -35
  40. package/template/package.json +1 -1
  41. package/template/wall-e/api-walle.js +19 -1
  42. package/template/wall-e/brain.js +152 -6
  43. package/template/wall-e/chat.js +85 -0
  44. package/template/wall-e/coding-orchestrator.js +106 -12
  45. package/template/wall-e/http/model-admin.js +131 -0
  46. package/template/wall-e/lib/service-health.js +194 -0
  47. package/template/wall-e/llm/anthropic.js +7 -0
  48. package/template/wall-e/llm/client.js +46 -12
  49. package/template/wall-e/llm/openai.js +17 -2
  50. package/template/wall-e/llm/portkey-sync.js +201 -0
  51. package/template/wall-e/server.js +13 -0
  52. package/template/website/index.html +10 -10
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # create-walle
2
2
 
3
- Set up **CTM + Wall-E** in one command — a browser-based command center for AI coding agents, remote phone access, review workflows, and a personal AI agent that builds a searchable second brain from your work life.
3
+ Set up **CTM + Wall-E** in one command — a browser-based command center for AI coding agents, remote phone and tablet access, review workflows, and a personal AI agent that builds a searchable second brain from your work life.
4
4
 
5
5
  ## What You Get
6
6
 
@@ -12,7 +12,7 @@ A web dashboard for running and managing AI coding sessions across multiple prov
12
12
  - **Prompt Editor** — Save, version, and organize prompts with folders, tags, chains, templates, and AI search
13
13
  - **Task Queue** — Queue prompts for sequential execution with auto-advance when the agent finishes, or step through manually
14
14
  - **Approval Workflows** — Auto-approve tool-use requests based on learned rules; uncertain cases escalate to you
15
- - **Remote Phone Access** — Pair your phone with a QR code and use a mobile CTM UI over Microsoft Dev Tunnels, Tailscale, Cloudflare Tunnel, or Walle Remote, with live prompts and model controls
15
+ - **Remote Phone & Tablet Access** — Pair your phone or tablet with a QR code and use a responsive CTM UI over Microsoft Dev Tunnels, Tailscale, Cloudflare Tunnel, or Walle Remote, with live prompts and model controls
16
16
  - **Code & Doc Review** — Review git diffs and Markdown docs side by side, add anchored comments, and send feedback into an agent session or queue
17
17
  - **Model Registry** — Manage providers (Anthropic, OpenAI, Google, DeepSeek, Ollama, LM Studio, MLX, and CLI subscription providers), compare pricing, switch models per session
18
18
  - **Session Insights** — Analyze patterns across sessions to optimize prompts and workflows
@@ -35,7 +35,7 @@ An always-on AI agent that learns from your Slack, email, calendar, and coding s
35
35
  npx create-walle install ./walle
36
36
  ```
37
37
 
38
- This copies the project, installs dependencies, auto-detects your name and timezone, and starts the server. Open **http://localhost:3456** to finish setup in the browser, then pair your phone from Setup if you want remote access.
38
+ This copies the project, installs dependencies, auto-detects your name and timezone, and starts the server. Open **http://localhost:3456** to finish setup in the browser, then pair your phone or tablet from Setup if you want remote access.
39
39
 
40
40
  ## Commands
41
41
 
@@ -62,7 +62,7 @@ On first launch, the browser setup page guides you through:
62
62
  1. **Owner name** — auto-detected from `git config`
63
63
  2. **API key** — enter manually, or click "Auto-detect" to find it from your shell environment, Claude Code OAuth, or corporate devbox
64
64
  3. **Integrations** — connect Slack (OAuth), email and calendar auto-detected on macOS
65
- 4. **Remote phone access** — optional QR pairing with Microsoft Dev Tunnels, Tailscale, Cloudflare Tunnel, or Walle Remote, including phone-friendly prompts and model controls
65
+ 4. **Remote phone and tablet access** — optional QR pairing with Microsoft Dev Tunnels, Tailscale, Cloudflare Tunnel, or Walle Remote, including touch-friendly prompts and model controls
66
66
 
67
67
  ## Custom Port
68
68
 
@@ -78,7 +78,7 @@ Everything runs locally. CTM serves the dashboard, Wall-E runs as a background a
78
78
 
79
79
  | Component | Default Port | What it does |
80
80
  |-----------|-------------|--------------|
81
- | CTM | 3456 | Dashboard, multi-agent terminal, prompt editor, queue, model registry, code/doc review, remote phone UI |
81
+ | CTM | 3456 | Dashboard, multi-agent terminal, prompt editor, queue, model registry, code/doc review, remote phone/tablet UI |
82
82
  | Wall-E | 3457 | AI agent, brain database, skills engine, multi-model chat API |
83
83
 
84
84
  ## Links
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-walle",
3
- "version": "0.9.21",
4
- "description": "CTM + Wall-E \u2014 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 access, code/doc review, and an agent that learns from Slack, email & calendar.",
3
+ "version": "0.9.22",
4
+ "description": "CTM + Wall-E \u2014 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"
7
7
  },
@@ -143,6 +143,7 @@ function handlePromptApi(req, res, url) {
143
143
  // --- Images ---
144
144
  if (p === '/api/images/upload' && m === 'POST') return handleUploadImage(req, res, url);
145
145
  if (p === '/api/mobile/attachments/upload' && m === 'POST') return handleUploadMobileAttachment(req, res, url);
146
+ if (p === '/api/session/image-refs' && m === 'POST') return handleSessionImageRefs(req, res);
146
147
  if (p.match(/^\/api\/images\/\d+$/) && m === 'GET') return handleGetImage(req, res, url);
147
148
  if (p.match(/^\/api\/images\/\d+\/annotations$/) && m === 'PUT') return handleUpdateAnnotations(req, res, url);
148
149
  if (p.match(/^\/api\/images\/\d+$/) && m === 'DELETE') return handleDeleteImage(req, res, url);
@@ -677,6 +678,18 @@ async function handleUploadMobileAttachment(req, res, url) {
677
678
  }
678
679
  }
679
680
 
681
+ async function handleSessionImageRefs(req, res) {
682
+ try {
683
+ const body = await readBody(req, 256 * 1024);
684
+ const result = await db.recordSessionImageRefs(body || {});
685
+ const safeResult = { ...(result || {}) };
686
+ delete safeResult.refDir;
687
+ jsonResponse(res, 200, { ok: true, ...safeResult });
688
+ } catch (e) {
689
+ jsonResponse(res, 400, { ok: false, error: e.message });
690
+ }
691
+ }
692
+
680
693
  function handleGetImage(req, res, url) {
681
694
  const id = parseInt(url.pathname.split('/').pop());
682
695
  const img = db.getImage(id);
@@ -140,7 +140,7 @@ function handleReviewApi(req, res, url) {
140
140
  try {
141
141
  const filePath = url.searchParams.get('path');
142
142
  const line = url.searchParams.get('line') || 1;
143
- const document = documentReview.readDocument(filePath, { line });
143
+ const document = documentReview.readDocument(filePath, { line, cwd: url.searchParams.get('cwd') || '' });
144
144
  return jsonResponse(res, 200, { document });
145
145
  } catch (e) {
146
146
  return jsonResponse(res, e.statusCode || 500, { error: e.message });
@@ -150,7 +150,10 @@ function handleReviewApi(req, res, url) {
150
150
  // POST /api/reviews/document-review - create/reuse a review record for one document
151
151
  if (p === '/api/reviews/document-review' && m === 'POST') {
152
152
  readBody(req).then(body => {
153
- const document = documentReview.readDocument(body.path, { line: body.line || 1 });
153
+ const document = documentReview.readDocument(body.path, {
154
+ line: body.line || 1,
155
+ cwd: body.cwd || '',
156
+ });
154
157
  const review = db.listReviews({
155
158
  project_path: document.projectRoot,
156
159
  review_type: 'doc',
@@ -6,7 +6,7 @@ const zlib = require('zlib');
6
6
 
7
7
  const { execFileSync } = require('child_process');
8
8
  const { normalizeAgentType } = require('./lib/agent-capabilities');
9
- const { codexRolloutIdFromPath } = require('./lib/session-history');
9
+ const { codexRolloutIdFromPath, readCodexRolloutMetadata } = require('./lib/session-history');
10
10
  const { ensureTranscriptTables } = require('./lib/transcript-store');
11
11
  const {
12
12
  classifySqliteError,
@@ -19,6 +19,7 @@ const {
19
19
  const DATA_DIR = process.env.CTM_DATA_DIR || path.join(process.env.HOME, '.walle', 'data');
20
20
  const DEFAULT_DB_PATH = path.join(DATA_DIR, 'task-manager.db');
21
21
  const DEFAULT_IMAGES_DIR = path.join(DATA_DIR, 'images');
22
+ const SESSION_IMAGES_DIR = path.join(DATA_DIR, 'session-images');
22
23
  const BACKUP_DIR = path.join(DATA_DIR, 'backups');
23
24
 
24
25
  let db = null;
@@ -52,33 +53,57 @@ function resolveGitRoot(projectPath) {
52
53
 
53
54
  function _codexFileAgentSessionId(jsonlPath) {
54
55
  try {
55
- return codexRolloutIdFromPath(jsonlPath || '') || '';
56
+ const filePath = String(jsonlPath || '').trim();
57
+ if (!filePath) return '';
58
+ const pathId = codexRolloutIdFromPath(filePath);
59
+ if (!pathId && !String(path.basename(filePath || '')).startsWith('rollout-')) return '';
60
+ const meta = readCodexRolloutMetadata(filePath) || {};
61
+ return String(meta.id || pathId || '').trim();
56
62
  } catch {
57
63
  return '';
58
64
  }
59
65
  }
60
66
 
67
+ function _looksLikeCodexRolloutBasename(value) {
68
+ return /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(value || '').trim());
69
+ }
70
+
71
+ function _codexRolloutBasenameAgentSessionId(value) {
72
+ const match = String(value || '').trim().match(/^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
73
+ return match ? match[1].toLowerCase() : '';
74
+ }
75
+
76
+ function _codexResumeAlias(value, canonicalId) {
77
+ const clean = String(value || '').trim();
78
+ if (!clean || clean === canonicalId || _looksLikeCodexRolloutBasename(clean)) return '';
79
+ return clean;
80
+ }
81
+
61
82
  function _normalizeAgentSessionIdentity(data = {}) {
62
- const provider = normalizeAgentType(data.provider || '') || String(data.provider || '').toLowerCase();
83
+ const jsonlPath = String(data.jsonlPath || '').trim();
84
+ const codexFileAgentSessionId = _codexFileAgentSessionId(jsonlPath);
85
+ const provider = (codexFileAgentSessionId || _codexRolloutBasenameAgentSessionId(data.agentSessionId))
86
+ ? 'codex'
87
+ : (normalizeAgentType(data.provider || '') || String(data.provider || '').toLowerCase());
63
88
  let agentSessionId = String(data.agentSessionId || '').trim();
64
89
  let providerResumeId = String(data.providerResumeId || data.resumeSessionId || '').trim();
65
- const jsonlPath = String(data.jsonlPath || '').trim();
66
90
 
67
- if (provider === 'codex' && jsonlPath) {
68
- const fileAgentSessionId = _codexFileAgentSessionId(jsonlPath);
91
+ if (provider === 'codex') {
92
+ const fileAgentSessionId = codexFileAgentSessionId || _codexRolloutBasenameAgentSessionId(agentSessionId);
69
93
  if (fileAgentSessionId) {
70
94
  if (agentSessionId && agentSessionId !== fileAgentSessionId) {
71
- if (!providerResumeId) providerResumeId = agentSessionId;
95
+ if (!providerResumeId) providerResumeId = _codexResumeAlias(agentSessionId, fileAgentSessionId);
96
+ const sourceLabel = jsonlPath ? path.basename(jsonlPath) : agentSessionId;
72
97
  console.error(
73
- `[db] Codex agent_session_id/jsonl mismatch; using filename id ${fileAgentSessionId.slice(0, 8)} ` +
74
- `instead of ${agentSessionId.slice(0, 8)} for ${path.basename(jsonlPath)}`
98
+ `[db] Codex agent_session_id/jsonl mismatch; using rollout metadata id ${fileAgentSessionId.slice(0, 8)} ` +
99
+ `instead of ${agentSessionId.slice(0, 8)} for ${sourceLabel}`
75
100
  );
76
101
  }
77
102
  agentSessionId = fileAgentSessionId;
78
103
  }
79
104
  }
80
105
 
81
- return { agentSessionId, providerResumeId };
106
+ return { agentSessionId, providerResumeId, provider };
82
107
  }
83
108
 
84
109
  function getDb() {
@@ -110,6 +135,7 @@ function _ensureDataDirs(dbPath) {
110
135
  const dir = path.dirname(dbPath);
111
136
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
112
137
  if (!fs.existsSync(DEFAULT_IMAGES_DIR)) fs.mkdirSync(DEFAULT_IMAGES_DIR, { recursive: true });
138
+ if (!fs.existsSync(SESSION_IMAGES_DIR)) fs.mkdirSync(SESSION_IMAGES_DIR, { recursive: true });
113
139
  if (!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true });
114
140
  }
115
141
 
@@ -2316,6 +2342,157 @@ function deleteImage(id) {
2316
2342
  }
2317
2343
  }
2318
2344
 
2345
+ function _sanitizeSessionImageSegment(value, fallback) {
2346
+ const clean = String(value || '')
2347
+ .trim()
2348
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
2349
+ .replace(/-+/g, '-')
2350
+ .replace(/^[.-]+|[.-]+$/g, '')
2351
+ .slice(0, 120);
2352
+ return clean || fallback;
2353
+ }
2354
+
2355
+ function _sessionImageTimestampSlug(value) {
2356
+ let date = null;
2357
+ if (typeof value === 'number' && Number.isFinite(value)) date = new Date(value);
2358
+ else if (typeof value === 'string' && value.trim()) {
2359
+ const n = Number(value);
2360
+ date = Number.isFinite(n) && /^\d+$/.test(value.trim()) ? new Date(n) : new Date(value);
2361
+ }
2362
+ if (!date || Number.isNaN(date.getTime())) date = new Date();
2363
+ return date.toISOString().replace(/[-:.]/g, '').replace(/Z$/, 'Z');
2364
+ }
2365
+
2366
+ function _sessionImagePromptHash(text, fallbackParts = []) {
2367
+ const source = String(text || '').trim() || fallbackParts.map(v => String(v || '')).join('|') || String(Date.now());
2368
+ return crypto.createHash('sha256').update(source).digest('hex').slice(0, 16);
2369
+ }
2370
+
2371
+ function _pathInsideDir(filePath, dirPath) {
2372
+ const resolvedFile = path.resolve(filePath || '');
2373
+ const resolvedDir = path.resolve(dirPath || '');
2374
+ return resolvedFile === resolvedDir || resolvedFile.startsWith(resolvedDir + path.sep);
2375
+ }
2376
+
2377
+ function _imageRowForReference(ref) {
2378
+ const imageId = Number(ref && ref.id || 0);
2379
+ if (Number.isInteger(imageId) && imageId > 0) {
2380
+ const row = getImage(imageId);
2381
+ if (row) return row;
2382
+ }
2383
+
2384
+ const candidate = String(ref && (ref.file_path || ref.path) || '').trim();
2385
+ if (candidate && _pathInsideDir(candidate, DEFAULT_IMAGES_DIR)) {
2386
+ const resolved = path.resolve(candidate);
2387
+ const row = getDb().prepare(
2388
+ 'SELECT * FROM images WHERE file_path = ? OR file_path LIKE ? ORDER BY id DESC LIMIT 1'
2389
+ ).get(resolved, '%/' + path.basename(resolved));
2390
+ if (row) return row;
2391
+ if (fs.existsSync(resolved)) {
2392
+ return {
2393
+ id: null,
2394
+ filename: path.basename(resolved),
2395
+ mime_type: ref && (ref.mimeType || ref.mime_type) || 'image/png',
2396
+ file_path: resolved,
2397
+ };
2398
+ }
2399
+ }
2400
+
2401
+ const filename = path.basename(String(ref && ref.filename || '').trim());
2402
+ if (filename) {
2403
+ const row = getDb().prepare(
2404
+ 'SELECT * FROM images WHERE filename = ? OR file_path LIKE ? ORDER BY id DESC LIMIT 1'
2405
+ ).get(filename, '%/' + filename);
2406
+ if (row) return row;
2407
+ }
2408
+
2409
+ return null;
2410
+ }
2411
+
2412
+ async function _ensureRelativeSymlink(linkPath, relativeTarget) {
2413
+ try {
2414
+ const stat = await fs.promises.lstat(linkPath);
2415
+ if (stat.isSymbolicLink()) {
2416
+ const current = await fs.promises.readlink(linkPath);
2417
+ if (current === relativeTarget) return;
2418
+ }
2419
+ await fs.promises.unlink(linkPath);
2420
+ } catch (err) {
2421
+ if (!err || err.code !== 'ENOENT') throw err;
2422
+ }
2423
+ await fs.promises.symlink(relativeTarget, linkPath);
2424
+ }
2425
+
2426
+ async function recordSessionImageRefs(data = {}) {
2427
+ const sessionId = String(data.sessionId || data.session_id || '').trim();
2428
+ if (!sessionId) throw new Error('sessionId required');
2429
+ const refs = Array.isArray(data.images) ? data.images : Array.isArray(data.attachments) ? data.attachments : [];
2430
+ const sessionSlug = _sanitizeSessionImageSegment(sessionId, 'session');
2431
+ const timestampSlug = _sessionImageTimestampSlug(data.submittedAt || data.submitted_at || Date.now());
2432
+ const promptHash = _sanitizeSessionImageSegment(
2433
+ data.promptHash || data.prompt_hash || _sessionImagePromptHash(data.promptText || data.prompt_text || '', [sessionId, timestampSlug]),
2434
+ 'prompt'
2435
+ ).slice(0, 32);
2436
+ const refDir = path.join(SESSION_IMAGES_DIR, sessionSlug);
2437
+ await fs.promises.mkdir(refDir, { recursive: true });
2438
+
2439
+ const links = [];
2440
+ const skipped = [];
2441
+ const seenTargets = new Set();
2442
+ let index = 0;
2443
+ for (const ref of refs) {
2444
+ const row = _imageRowForReference(ref);
2445
+ if (!row || !row.file_path) {
2446
+ skipped.push({ label: ref && ref.label || '', reason: 'image_not_found' });
2447
+ continue;
2448
+ }
2449
+ const targetPath = path.resolve(row.file_path);
2450
+ if (!_pathInsideDir(targetPath, DEFAULT_IMAGES_DIR)) {
2451
+ skipped.push({ label: ref && ref.label || '', reason: 'outside_image_store' });
2452
+ continue;
2453
+ }
2454
+ if (!fs.existsSync(targetPath)) {
2455
+ skipped.push({ label: ref && ref.label || '', reason: 'file_missing' });
2456
+ continue;
2457
+ }
2458
+ if (seenTargets.has(targetPath)) continue;
2459
+ seenTargets.add(targetPath);
2460
+ index += 1;
2461
+ const ext = path.extname(targetPath) || path.extname(row.filename || '') || '.png';
2462
+ const linkName = `${timestampSlug}-${promptHash}-image-${String(index).padStart(2, '0')}${ext}`;
2463
+ const linkPath = path.join(refDir, linkName);
2464
+ const relativeTarget = path.relative(refDir, targetPath);
2465
+ await _ensureRelativeSymlink(linkPath, relativeTarget);
2466
+ links.push({
2467
+ label: ref && ref.label || `[Image #${index}]`,
2468
+ image_id: row.id || null,
2469
+ filename: row.filename || path.basename(targetPath),
2470
+ mime_type: row.mime_type || ref && ref.mimeType || 'image/png',
2471
+ link: linkName,
2472
+ target: relativeTarget,
2473
+ });
2474
+ }
2475
+
2476
+ const manifestName = `${timestampSlug}-${promptHash}.json`;
2477
+ const manifest = {
2478
+ session_id: sessionId,
2479
+ submitted_at: timestampSlug,
2480
+ prompt_hash: promptHash,
2481
+ prompt_text_hash: _sessionImagePromptHash(data.promptText || data.prompt_text || '', [sessionId, timestampSlug]),
2482
+ created_at: new Date().toISOString(),
2483
+ links,
2484
+ skipped,
2485
+ };
2486
+ await fs.promises.writeFile(path.join(refDir, manifestName), JSON.stringify(manifest, null, 2) + '\n');
2487
+ return {
2488
+ sessionId,
2489
+ refDir,
2490
+ manifest: manifestName,
2491
+ links,
2492
+ skipped,
2493
+ };
2494
+ }
2495
+
2319
2496
  // --- Chains ---
2320
2497
  function createChain({ name, description }) {
2321
2498
  const result = getDb().prepare('INSERT INTO chains (name, description) VALUES (?, ?)').run(name, description || '');
@@ -4566,6 +4743,7 @@ function upsertSession(id, data, opts) {
4566
4743
  const normalizedIdentity = _normalizeAgentSessionIdentity(data);
4567
4744
  const agentId = normalizedIdentity.agentSessionId;
4568
4745
  const providerResumeId = normalizedIdentity.providerResumeId;
4746
+ const provider = normalizedIdentity.provider || data.provider || '';
4569
4747
  function throwCrossTabClaim(existingCtmSessionId) {
4570
4748
  const err = new Error(
4571
4749
  `agent_session_id ${agentId} is already claimed by ctm_session ${existingCtmSessionId} (refusing cross-tab claim for ${id})`
@@ -4578,7 +4756,7 @@ function upsertSession(id, data, opts) {
4578
4756
  }
4579
4757
  const ctmParams = {
4580
4758
  id,
4581
- provider: data.provider || '',
4759
+ provider,
4582
4760
  project_path: data.projectPath || '',
4583
4761
  cwd: data.cwd || '',
4584
4762
  title: data.title || '',
@@ -4593,7 +4771,7 @@ function upsertSession(id, data, opts) {
4593
4771
  const agentParams = (agentId && agentId !== '__CLEAR__') ? {
4594
4772
  agent_session_id: agentId,
4595
4773
  ctm_session_id: id,
4596
- provider: data.provider || '',
4774
+ provider,
4597
4775
  provider_resume_id: providerResumeId || '',
4598
4776
  project_path: data.projectPath || '',
4599
4777
  jsonl_path: data.jsonlPath || '',
@@ -4939,6 +5117,161 @@ function repairCodexSessionMetadataFromConversations({ dryRun = false, limit = 5
4939
5117
  };
4940
5118
  }
4941
5119
 
5120
+ function _newerTimestamp(a, b) {
5121
+ const aMs = Date.parse(String(a || ''));
5122
+ const bMs = Date.parse(String(b || ''));
5123
+ if (!Number.isFinite(aMs)) return b || '';
5124
+ if (!Number.isFinite(bMs)) return a || '';
5125
+ return aMs >= bMs ? a : b;
5126
+ }
5127
+
5128
+ function _mergeCodexAgentRow(existing, stale, canonicalId) {
5129
+ const resumeAlias = _codexResumeAlias(existing?.provider_resume_id, canonicalId)
5130
+ || _codexResumeAlias(stale?.provider_resume_id, canonicalId)
5131
+ || _codexResumeAlias(stale?.agent_session_id, canonicalId);
5132
+ return {
5133
+ ctm_session_id: existing?.ctm_session_id || stale?.ctm_session_id || '',
5134
+ provider_resume_id: resumeAlias,
5135
+ project_path: existing?.project_path || stale?.project_path || '',
5136
+ jsonl_path: existing?.jsonl_path || stale?.jsonl_path || '',
5137
+ first_message: existing?.first_message || stale?.first_message || '',
5138
+ file_size: Math.max(Number(existing?.file_size || 0), Number(stale?.file_size || 0)),
5139
+ modified_at: _newerTimestamp(existing?.modified_at, stale?.modified_at),
5140
+ hostname: existing?.hostname || stale?.hostname || '',
5141
+ model: existing?.model || stale?.model || '',
5142
+ git_branch: existing?.git_branch || stale?.git_branch || '',
5143
+ user_msg_count: Math.max(Number(existing?.user_msg_count || 0), Number(stale?.user_msg_count || 0)),
5144
+ slug: existing?.slug || stale?.slug || '',
5145
+ };
5146
+ }
5147
+
5148
+ function repairCodexRolloutAgentIdentities({ dryRun = false, limit = 1000 } = {}) {
5149
+ const d = getDb();
5150
+ const candidates = d.prepare(`
5151
+ SELECT *
5152
+ FROM agent_sessions
5153
+ WHERE jsonl_path LIKE '%rollout-%.jsonl%'
5154
+ ORDER BY
5155
+ CASE
5156
+ WHEN COALESCE(provider, '') != 'codex' THEN 0
5157
+ WHEN agent_session_id LIKE 'rollout-%' THEN 0
5158
+ WHEN provider_resume_id LIKE 'rollout-%' THEN 0
5159
+ ELSE 1
5160
+ END,
5161
+ updated_at DESC,
5162
+ modified_at DESC
5163
+ LIMIT ?
5164
+ `).all(Math.max(1, Number(limit) || 1000));
5165
+
5166
+ const repairs = [];
5167
+ for (const row of candidates) {
5168
+ const canonicalId = _codexFileAgentSessionId(row.jsonl_path);
5169
+ if (!canonicalId) continue;
5170
+ const provider = normalizeAgentType(row.provider || '') || String(row.provider || '').toLowerCase();
5171
+ if (row.agent_session_id === canonicalId && provider === 'codex') continue;
5172
+ repairs.push({ ...row, canonicalId, provider });
5173
+ }
5174
+
5175
+ if (!dryRun && repairs.length > 0) {
5176
+ const selectAgent = d.prepare('SELECT * FROM agent_sessions WHERE agent_session_id = ?');
5177
+ const updateCanonical = d.prepare(`
5178
+ UPDATE agent_sessions SET
5179
+ ctm_session_id = COALESCE(NULLIF(?, ''), ctm_session_id),
5180
+ provider = 'codex',
5181
+ provider_resume_id = ?,
5182
+ project_path = COALESCE(NULLIF(?, ''), project_path),
5183
+ jsonl_path = COALESCE(NULLIF(?, ''), jsonl_path),
5184
+ first_message = COALESCE(NULLIF(?, ''), first_message),
5185
+ file_size = CASE WHEN ? > COALESCE(file_size, 0) THEN ? ELSE COALESCE(file_size, 0) END,
5186
+ modified_at = COALESCE(NULLIF(?, ''), modified_at),
5187
+ hostname = COALESCE(NULLIF(?, ''), hostname),
5188
+ model = COALESCE(NULLIF(?, ''), model),
5189
+ git_branch = COALESCE(NULLIF(?, ''), git_branch),
5190
+ user_msg_count = CASE WHEN ? > COALESCE(user_msg_count, 0) THEN ? ELSE COALESCE(user_msg_count, 0) END,
5191
+ slug = COALESCE(NULLIF(?, ''), slug),
5192
+ updated_at = datetime('now')
5193
+ WHERE agent_session_id = ?
5194
+ `);
5195
+ const updateInPlace = d.prepare(`
5196
+ UPDATE agent_sessions SET
5197
+ agent_session_id = ?,
5198
+ provider = 'codex',
5199
+ provider_resume_id = ?,
5200
+ updated_at = datetime('now')
5201
+ WHERE agent_session_id = ?
5202
+ `);
5203
+ const deleteStale = d.prepare('DELETE FROM agent_sessions WHERE agent_session_id = ?');
5204
+ const updateCtmProvider = d.prepare(`
5205
+ UPDATE ctm_sessions
5206
+ SET provider = 'codex', updated_at = datetime('now')
5207
+ WHERE id = ? AND COALESCE(provider, '') != 'codex'
5208
+ `);
5209
+
5210
+ const txn = d.transaction(() => {
5211
+ for (const row of repairs) {
5212
+ const canonical = selectAgent.get(row.canonicalId);
5213
+ if (canonical && canonical.agent_session_id !== row.agent_session_id) {
5214
+ const merged = _mergeCodexAgentRow(canonical, row, row.canonicalId);
5215
+ updateCanonical.run(
5216
+ merged.ctm_session_id,
5217
+ merged.provider_resume_id,
5218
+ merged.project_path,
5219
+ merged.jsonl_path,
5220
+ merged.first_message,
5221
+ merged.file_size,
5222
+ merged.file_size,
5223
+ merged.modified_at,
5224
+ merged.hostname,
5225
+ merged.model,
5226
+ merged.git_branch,
5227
+ merged.user_msg_count,
5228
+ merged.user_msg_count,
5229
+ merged.slug,
5230
+ row.canonicalId
5231
+ );
5232
+ deleteStale.run(row.agent_session_id);
5233
+ } else if (row.agent_session_id !== row.canonicalId) {
5234
+ const resumeAlias = _codexResumeAlias(row.provider_resume_id, row.canonicalId)
5235
+ || _codexResumeAlias(row.agent_session_id, row.canonicalId);
5236
+ updateInPlace.run(row.canonicalId, resumeAlias, row.agent_session_id);
5237
+ } else {
5238
+ updateCanonical.run(
5239
+ row.ctm_session_id || '',
5240
+ _codexResumeAlias(row.provider_resume_id, row.canonicalId),
5241
+ row.project_path || '',
5242
+ row.jsonl_path || '',
5243
+ row.first_message || '',
5244
+ Number(row.file_size || 0),
5245
+ Number(row.file_size || 0),
5246
+ row.modified_at || '',
5247
+ row.hostname || '',
5248
+ row.model || '',
5249
+ row.git_branch || '',
5250
+ Number(row.user_msg_count || 0),
5251
+ Number(row.user_msg_count || 0),
5252
+ row.slug || '',
5253
+ row.canonicalId
5254
+ );
5255
+ }
5256
+ if (row.ctm_session_id) updateCtmProvider.run(row.ctm_session_id);
5257
+ }
5258
+ });
5259
+ txn();
5260
+ flushWal();
5261
+ }
5262
+
5263
+ return {
5264
+ checked: candidates.length,
5265
+ repaired: repairs.length,
5266
+ rows: repairs.map(row => ({
5267
+ ctm_session_id: row.ctm_session_id,
5268
+ from_agent_session_id: row.agent_session_id,
5269
+ to_agent_session_id: row.canonicalId,
5270
+ provider: row.provider,
5271
+ })),
5272
+ };
5273
+ }
5274
+
4942
5275
  /**
4943
5276
  * Get all session data in old-compatible format (for apiRecentSessions).
4944
5277
  * Returns merged ctm_sessions + agent_sessions rows.
@@ -5118,7 +5451,7 @@ module.exports = {
5118
5451
  createPrompt, updatePrompt, getPrompt, listPrompts, listChildPrompts, setPromptParent, createGroupFromPrompts, deletePrompt, duplicatePrompt, reorderPrompts,
5119
5452
  getPromptVersions, restorePromptVersion,
5120
5453
  createFolder, listFolders, updateFolder, deleteFolder, reorderFolders,
5121
- saveImage, getImage, updateImageAnnotations, listImages, deleteImage,
5454
+ saveImage, getImage, updateImageAnnotations, listImages, deleteImage, recordSessionImageRefs,
5122
5455
  createChain, getChain, listChains, updateChain, deleteChain,
5123
5456
  listPermissionRules, upsertPermissionRule, deletePermissionRule,
5124
5457
  listPermissionLog, addPermissionLog,
@@ -5151,7 +5484,7 @@ module.exports = {
5151
5484
  listHooks, getEnabledHooks, createHook, deleteHook, toggleHook,
5152
5485
  upsertAnalysisFts, backfillFts, ftsSearchAnalyses, invalidateFtsRowidMap,
5153
5486
  extractSessionMessages, extractSessionMessagesAsync, replaceSessionMessages, backfillSessionMessages, backfillSessionMessagesAsync, ftsSearchMessages,
5154
- DEFAULT_IMAGES_DIR, BACKUP_DIR,
5487
+ DEFAULT_IMAGES_DIR, SESSION_IMAGES_DIR, BACKUP_DIR,
5155
5488
  // Workspaces (Phase 13)
5156
5489
  listWorkspaces, createWorkspace, updateWorkspace, deleteWorkspace,
5157
5490
  addSessionToWorkspace, removeSessionFromWorkspace, getWorkspaceSessions, getSessionWorkspace,
@@ -5165,7 +5498,7 @@ module.exports = {
5165
5498
  // CTM Sessions + Agent Sessions (v1 schema)
5166
5499
  getSessionIdentity, getLatestAgentSessionForCtm, getSessionConversationSourceIds, resolveSession, upsertSession, setSessionStar, getStarredSessionIds,
5167
5500
  setSessionTitleNew, setSessionTitleStatusNew, getSessionTitleNew, getAllSessionsData,
5168
- repairCodexSessionMetadataFromConversations,
5501
+ repairCodexSessionMetadataFromConversations, repairCodexRolloutAgentIdentities,
5169
5502
  getAgentSessions, getAgentSession, deleteCtmSession,
5170
5503
  // Schema version
5171
5504
  getSchemaVersion,
@@ -0,0 +1,69 @@
1
+ # CTM App Update Refresh Protocol
2
+
3
+ ## Problem
4
+
5
+ CTM is a long-lived browser app. A user can keep several CTM tabs open while
6
+ `create-walle update` replaces CTM and Wall-E code, restarts the server, and
7
+ serves newer HTML, CSS, and JavaScript. Existing browser tabs may reconnect to
8
+ the new server while still executing the old client bundle.
9
+
10
+ That is unsafe in both directions:
11
+
12
+ - silently keeping old code leaves users on stale UX after an update;
13
+ - blindly reloading every tab can destroy active terminal input, queue drafts,
14
+ screenshot edits, or mobile composer text.
15
+
16
+ ## Design
17
+
18
+ The server is the source of truth for the installed application identity. Each
19
+ WebSocket `hello` includes an app identity:
20
+
21
+ - `version`: the installed create-walle version;
22
+ - `components`: CTM and Wall-E package versions;
23
+ - `buildId`: a stable hash of key shipped files and package versions.
24
+
25
+ `buildId` is recomputed from file stats when version info is requested. It must
26
+ not be process-cached because update installers can replace static assets before
27
+ or during a server restart.
28
+
29
+ The client records the first app identity it sees for the current document. On
30
+ later WebSocket reconnects, if the server identity changes, the page is running
31
+ old code and must refresh before continuing normal operation.
32
+
33
+ ## UX Contract
34
+
35
+ When a changed app identity is detected:
36
+
37
+ 1. Broadcast `reload-required` to same-origin CTM tabs using `BroadcastChannel`.
38
+ 2. If the current tab is idle, reload immediately.
39
+ 3. If the current tab has active user work, show a sticky reload-required banner
40
+ and do not steal focus.
41
+ 4. The banner remains until the user clicks **Reload now** or the page is
42
+ refreshed.
43
+
44
+ Active user work includes focused xterm.js terminals, editable inputs,
45
+ contenteditable composers, open modals, open queue panel, and screenshot editor
46
+ state.
47
+
48
+ ## Mobile/PWA
49
+
50
+ The mobile service worker already uses a network-first shell strategy and
51
+ activates new workers. That only updates future navigations; a currently open
52
+ mobile document still needs the same runtime identity handshake. Mobile uses the
53
+ same `hello` comparison and shows a compact refresh banner when a composer or
54
+ detail view is active.
55
+
56
+ ## Non-Goals
57
+
58
+ - No hard reload while a user is typing.
59
+ - No reload prompt for ordinary CTM restarts when the app identity is unchanged.
60
+ - No dependency on service workers for desktop refresh behavior.
61
+
62
+ ## Verification
63
+
64
+ Focused render tests should cover:
65
+
66
+ - an idle desktop tab auto-refreshes on server identity change;
67
+ - a focused terminal desktop tab shows the reload banner without losing focus;
68
+ - another open tab receives the reload-required event through `BroadcastChannel`;
69
+ - mobile shows the refresh banner instead of silently keeping stale code.
@@ -12,6 +12,7 @@ CTM treats pasted screenshots as first-class attachments and shows compact promp
12
12
 
13
13
  - Terminal sessions: paste events and the macOS `Ctrl+V` compatibility path upload images and insert the compact token, for example `[Image #1]`. The token is what the user sees and edits in both Claude and Codex sessions. `Cmd+Shift+V` on macOS and `Ctrl+Shift+V` elsewhere intentionally paste the raw local path reference. The terminal context menu can copy all pasted image paths or URLs for the active session.
14
14
  - Terminal sessions backed by Claude Code or Codex use provider-native image handoff. CTM uploads and normalizes the browser clipboard image, then sends the normalized local image path as a bracketed paste to the PTY. The provider creates the real image attachment and renders its own compact `[Image #n]` chip, so the model receives image context while the terminal does not show raw paths.
15
+ - Terminal sessions backed by Claude Code or Codex also append durable image metadata when the prompt is submitted. CTM does not change the paste behavior: paste still inserts the compact token and keeps provider-native path handoff intact. At Enter/submit time, any unsent image attachments from the active input are appended once as `[Attached image: "..."]` or `[Attached images: "...", "..."]` before the newline reaches the PTY. This makes future Conversation/Review import able to resolve the image path even if the provider transcript only preserves terminal text.
15
16
  - Terminal text paste also normalizes CTM image references. If the clipboard contains `[Image #1: "/path/to/file.png"]`, a raw local image path, or an `/api/images/file/...` URL, default paste deduplicates repeated references and hands each local image path to provider-native attachment paste when possible.
16
17
  - Terminal image paste accepts browser clipboard files by `image/*` MIME type or by common image filename extension when the OS leaves the MIME type empty.
17
18
  - Wall-E sessions: pasted images become structured message attachments. The composer inserts `[Image #n]`; sending preserves the compact token in the message text and sends image data separately.
@@ -20,3 +21,5 @@ CTM treats pasted screenshots as first-class attachments and shows compact promp
20
21
  ## Provider Contract
21
22
 
22
23
  Compact tokens are UI references, not raw file paths. CTM keeps attachment metadata, normalized uploads, and copyable path/URL references beside the visible token. Provider adapters must not send only `[Image #n]` to a raw PTY. They should either send structured attachments, or, for Claude Code and Codex terminal sessions, trigger native image attachment by bracket-pasting the normalized local image path. Explicit path paste remains available for CLIs that require a visible local filesystem reference in the prompt text.
24
+
25
+ Submit-time metadata is a transcript durability layer, not a replacement for native provider image attachment. It must not run during paste, path-copy, or path-paste handling, and it must mark attachments as submitted or discarded so later prompts do not inherit stale image references.