create-walle 0.9.20 → 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 (65) 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 +479 -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/docs/microsoft-dev-tunnel-phone-access-design.md +58 -50
  10. package/template/claude-task-manager/docs/phone-access-design.md +23 -7
  11. package/template/claude-task-manager/docs/walle-session-model-preferences.md +119 -0
  12. package/template/claude-task-manager/git-utils.js +146 -17
  13. package/template/claude-task-manager/lib/auth-rate-limit.js +23 -3
  14. package/template/claude-task-manager/lib/auth-rules.js +3 -0
  15. package/template/claude-task-manager/lib/document-review.js +33 -2
  16. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +115 -48
  17. package/template/claude-task-manager/lib/mobile-auth-api.js +14 -0
  18. package/template/claude-task-manager/lib/remote-relay-protocol.js +5 -0
  19. package/template/claude-task-manager/lib/restart-guard.js +68 -0
  20. package/template/claude-task-manager/lib/session-standup.js +36 -13
  21. package/template/claude-task-manager/lib/session-stream.js +11 -4
  22. package/template/claude-task-manager/lib/transport-security.js +50 -0
  23. package/template/claude-task-manager/lib/walle-external-actions.js +20 -3
  24. package/template/claude-task-manager/lib/walle-transcript.js +16 -0
  25. package/template/claude-task-manager/lib/worktree-active-sync.js +6 -3
  26. package/template/claude-task-manager/public/css/reviews.css +10 -0
  27. package/template/claude-task-manager/public/css/setup.css +13 -0
  28. package/template/claude-task-manager/public/css/walle.css +145 -0
  29. package/template/claude-task-manager/public/index.html +564 -44
  30. package/template/claude-task-manager/public/ipad.html +363 -0
  31. package/template/claude-task-manager/public/js/document-review-links.js +196 -0
  32. package/template/claude-task-manager/public/js/message-renderer.js +14 -3
  33. package/template/claude-task-manager/public/js/reviews.js +30 -6
  34. package/template/claude-task-manager/public/js/setup.js +57 -13
  35. package/template/claude-task-manager/public/js/stream-view.js +20 -1
  36. package/template/claude-task-manager/public/js/walle-session.js +31 -3
  37. package/template/claude-task-manager/public/js/walle.js +405 -39
  38. package/template/claude-task-manager/public/m/app.css +1213 -39
  39. package/template/claude-task-manager/public/m/app.js +1887 -97
  40. package/template/claude-task-manager/public/m/claim.html +9 -2
  41. package/template/claude-task-manager/public/m/index.html +48 -7
  42. package/template/claude-task-manager/public/m/sw.js +1 -1
  43. package/template/claude-task-manager/server.js +695 -78
  44. package/template/claude-task-manager/session-integrity.js +4 -0
  45. package/template/claude-task-manager/workers/state-detectors/codex.js +18 -3
  46. package/template/docs/designs/2026-05-17-portkey-gateway-provider-ux.md +86 -35
  47. package/template/package.json +1 -1
  48. package/template/wall-e/api-walle.js +19 -1
  49. package/template/wall-e/brain.js +152 -6
  50. package/template/wall-e/chat.js +117 -2
  51. package/template/wall-e/coding/stream-processor.js +36 -0
  52. package/template/wall-e/coding-orchestrator.js +151 -12
  53. package/template/wall-e/docs/external-action-controller.md +60 -2
  54. package/template/wall-e/external-action-controller.js +23 -1
  55. package/template/wall-e/external-action-gateway.js +163 -0
  56. package/template/wall-e/fly.toml +1 -0
  57. package/template/wall-e/http/model-admin.js +131 -0
  58. package/template/wall-e/lib/service-health.js +194 -0
  59. package/template/wall-e/llm/anthropic.js +7 -0
  60. package/template/wall-e/llm/client.js +46 -12
  61. package/template/wall-e/llm/openai.js +17 -2
  62. package/template/wall-e/llm/portkey-sync.js +201 -0
  63. package/template/wall-e/server.js +13 -0
  64. package/template/wall-e/tools/local-tools.js +122 -4
  65. package/template/website/index.html +10 -10
@@ -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
 
@@ -1459,6 +1485,47 @@ function migrateSchemaIfNeeded() {
1459
1485
  // column presence before writing title-generation status.
1460
1486
  }
1461
1487
  }
1488
+ if (getSchemaVersion() < 6) {
1489
+ try {
1490
+ migrateToV6();
1491
+ } catch (e) {
1492
+ console.error('[db] Schema migration to v6 FAILED:', e.message);
1493
+ console.error('[db] Stack:', e.stack);
1494
+ // Non-fatal: Wall-E can still fall back to startup_tasks/model defaults,
1495
+ // but per-session model preferences will not persist until this succeeds.
1496
+ }
1497
+ }
1498
+ }
1499
+
1500
+ /**
1501
+ * Schema v6: Persist session-scoped model preferences.
1502
+ *
1503
+ * startup_tasks.model_id is a restore hint and intentionally has no provider
1504
+ * identity. Wall-E session model changes need a CTM-owned durable preference so
1505
+ * one tab's model switch cannot mutate Wall-E global defaults or another tab.
1506
+ */
1507
+ function migrateToV6() {
1508
+ const d = getDb();
1509
+ d.exec(`
1510
+ CREATE TABLE IF NOT EXISTS session_model_preferences (
1511
+ ctm_session_id TEXT PRIMARY KEY,
1512
+ agent_type TEXT NOT NULL DEFAULT 'walle',
1513
+ provider_type TEXT NOT NULL DEFAULT '',
1514
+ provider_id TEXT NOT NULL DEFAULT '',
1515
+ model_id TEXT NOT NULL DEFAULT '',
1516
+ registry_id TEXT NOT NULL DEFAULT '',
1517
+ scope TEXT NOT NULL DEFAULT 'session',
1518
+ source TEXT NOT NULL DEFAULT 'user',
1519
+ pinned INTEGER NOT NULL DEFAULT 1,
1520
+ created_at TEXT DEFAULT (datetime('now')),
1521
+ updated_at TEXT DEFAULT (datetime('now')),
1522
+ FOREIGN KEY (ctm_session_id) REFERENCES ctm_sessions(id) ON DELETE CASCADE
1523
+ );
1524
+ CREATE INDEX IF NOT EXISTS idx_session_model_preferences_agent
1525
+ ON session_model_preferences(agent_type, updated_at DESC);
1526
+ `);
1527
+ setSchemaVersion(6);
1528
+ console.log('[db] Schema migrated to v6 (session model preferences)');
1462
1529
  }
1463
1530
 
1464
1531
  /**
@@ -2275,6 +2342,157 @@ function deleteImage(id) {
2275
2342
  }
2276
2343
  }
2277
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
+
2278
2496
  // --- Chains ---
2279
2497
  function createChain({ name, description }) {
2280
2498
  const result = getDb().prepare('INSERT INTO chains (name, description) VALUES (?, ?)').run(name, description || '');
@@ -3268,6 +3486,95 @@ function getInsightsData(includeInternal) {
3268
3486
  };
3269
3487
  }
3270
3488
 
3489
+ // --- Session Model Preferences ---
3490
+ function _cleanSessionModelPreferenceInput(input = {}) {
3491
+ const clean = (value, max) => String(value || '').trim().slice(0, max);
3492
+ return {
3493
+ ctmSessionId: clean(input.ctmSessionId || input.ctm_session_id || input.sessionId || input.id, 160),
3494
+ agentType: clean(input.agentType || input.agent_type || 'walle', 60) || 'walle',
3495
+ providerType: clean(input.providerType || input.provider_type || input.model_provider || input.provider, 120),
3496
+ providerId: clean(input.providerId || input.provider_id, 160),
3497
+ modelId: clean(input.modelId || input.model_id || input.model, 256),
3498
+ registryId: clean(input.registryId || input.registry_id || input.model_registry_id, 256),
3499
+ scope: clean(input.scope || 'session', 40) || 'session',
3500
+ source: clean(input.source || 'user', 80) || 'user',
3501
+ pinned: input.pinned === false || input.pinned === 0 ? 0 : 1,
3502
+ };
3503
+ }
3504
+
3505
+ function upsertSessionModelPreference(input = {}) {
3506
+ const item = _cleanSessionModelPreferenceInput(input);
3507
+ if (!item.ctmSessionId) throw new Error('ctmSessionId is required');
3508
+ if (!item.modelId) {
3509
+ clearSessionModelPreference(item.ctmSessionId);
3510
+ return null;
3511
+ }
3512
+ const d = getDb();
3513
+ d.prepare(
3514
+ "INSERT OR IGNORE INTO ctm_sessions (id, provider, updated_at) VALUES (?, ?, datetime('now'))"
3515
+ ).run(item.ctmSessionId, item.agentType || 'walle');
3516
+ d.prepare(`
3517
+ INSERT INTO session_model_preferences (
3518
+ ctm_session_id, agent_type, provider_type, provider_id, model_id,
3519
+ registry_id, scope, source, pinned, updated_at
3520
+ )
3521
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
3522
+ ON CONFLICT(ctm_session_id) DO UPDATE SET
3523
+ agent_type = excluded.agent_type,
3524
+ provider_type = excluded.provider_type,
3525
+ provider_id = excluded.provider_id,
3526
+ model_id = excluded.model_id,
3527
+ registry_id = excluded.registry_id,
3528
+ scope = excluded.scope,
3529
+ source = excluded.source,
3530
+ pinned = excluded.pinned,
3531
+ updated_at = excluded.updated_at
3532
+ `).run(
3533
+ item.ctmSessionId, item.agentType, item.providerType, item.providerId,
3534
+ item.modelId, item.registryId, item.scope, item.source, item.pinned
3535
+ );
3536
+ flushWal();
3537
+ return getSessionModelPreference(item.ctmSessionId);
3538
+ }
3539
+
3540
+ function getSessionModelPreference(ctmSessionId) {
3541
+ const id = String(ctmSessionId || '').trim();
3542
+ if (!id) return null;
3543
+ const row = getDb().prepare(
3544
+ 'SELECT * FROM session_model_preferences WHERE ctm_session_id = ?'
3545
+ ).get(id);
3546
+ if (!row) return null;
3547
+ return {
3548
+ ctm_session_id: row.ctm_session_id,
3549
+ ctmSessionId: row.ctm_session_id,
3550
+ agent_type: row.agent_type || 'walle',
3551
+ agentType: row.agent_type || 'walle',
3552
+ provider_type: row.provider_type || '',
3553
+ providerType: row.provider_type || '',
3554
+ provider_id: row.provider_id || '',
3555
+ providerId: row.provider_id || '',
3556
+ model_id: row.model_id || '',
3557
+ modelId: row.model_id || '',
3558
+ registry_id: row.registry_id || '',
3559
+ registryId: row.registry_id || '',
3560
+ scope: row.scope || 'session',
3561
+ source: row.source || 'user',
3562
+ pinned: row.pinned !== 0,
3563
+ created_at: row.created_at || '',
3564
+ updated_at: row.updated_at || '',
3565
+ };
3566
+ }
3567
+
3568
+ function clearSessionModelPreference(ctmSessionId) {
3569
+ const id = String(ctmSessionId || '').trim();
3570
+ if (!id) return 0;
3571
+ const changes = getDb().prepare(
3572
+ 'DELETE FROM session_model_preferences WHERE ctm_session_id = ?'
3573
+ ).run(id).changes || 0;
3574
+ if (changes) flushWal();
3575
+ return changes;
3576
+ }
3577
+
3271
3578
  // --- Startup Tasks (crash-safe session restore) ---
3272
3579
  const CTM_INSTANCE_ID = `${require('os').hostname()}:${process.pid}:${Date.now()}`;
3273
3580
 
@@ -4436,6 +4743,7 @@ function upsertSession(id, data, opts) {
4436
4743
  const normalizedIdentity = _normalizeAgentSessionIdentity(data);
4437
4744
  const agentId = normalizedIdentity.agentSessionId;
4438
4745
  const providerResumeId = normalizedIdentity.providerResumeId;
4746
+ const provider = normalizedIdentity.provider || data.provider || '';
4439
4747
  function throwCrossTabClaim(existingCtmSessionId) {
4440
4748
  const err = new Error(
4441
4749
  `agent_session_id ${agentId} is already claimed by ctm_session ${existingCtmSessionId} (refusing cross-tab claim for ${id})`
@@ -4448,7 +4756,7 @@ function upsertSession(id, data, opts) {
4448
4756
  }
4449
4757
  const ctmParams = {
4450
4758
  id,
4451
- provider: data.provider || '',
4759
+ provider,
4452
4760
  project_path: data.projectPath || '',
4453
4761
  cwd: data.cwd || '',
4454
4762
  title: data.title || '',
@@ -4463,7 +4771,7 @@ function upsertSession(id, data, opts) {
4463
4771
  const agentParams = (agentId && agentId !== '__CLEAR__') ? {
4464
4772
  agent_session_id: agentId,
4465
4773
  ctm_session_id: id,
4466
- provider: data.provider || '',
4774
+ provider,
4467
4775
  provider_resume_id: providerResumeId || '',
4468
4776
  project_path: data.projectPath || '',
4469
4777
  jsonl_path: data.jsonlPath || '',
@@ -4809,6 +5117,161 @@ function repairCodexSessionMetadataFromConversations({ dryRun = false, limit = 5
4809
5117
  };
4810
5118
  }
4811
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
+
4812
5275
  /**
4813
5276
  * Get all session data in old-compatible format (for apiRecentSessions).
4814
5277
  * Returns merged ctm_sessions + agent_sessions rows.
@@ -4988,7 +5451,7 @@ module.exports = {
4988
5451
  createPrompt, updatePrompt, getPrompt, listPrompts, listChildPrompts, setPromptParent, createGroupFromPrompts, deletePrompt, duplicatePrompt, reorderPrompts,
4989
5452
  getPromptVersions, restorePromptVersion,
4990
5453
  createFolder, listFolders, updateFolder, deleteFolder, reorderFolders,
4991
- saveImage, getImage, updateImageAnnotations, listImages, deleteImage,
5454
+ saveImage, getImage, updateImageAnnotations, listImages, deleteImage, recordSessionImageRefs,
4992
5455
  createChain, getChain, listChains, updateChain, deleteChain,
4993
5456
  listPermissionRules, upsertPermissionRule, deletePermissionRule,
4994
5457
  listPermissionLog, addPermissionLog,
@@ -5013,6 +5476,7 @@ module.exports = {
5013
5476
  replaceInsightRecommendations, listInsightRecommendations,
5014
5477
  startAnalysisRun, completeAnalysisRun, getLastAnalysisRun,
5015
5478
  getInsightsData,
5479
+ upsertSessionModelPreference, getSessionModelPreference, clearSessionModelPreference,
5016
5480
  addStartupTask, updateStartupTaskLabel, updateStartupTaskClaudeSession, updateStartupTaskAgentSession, updateStartupTaskBranch, updateStartupTaskCwd, removeStartupTask, markStartupTaskExited, heartbeatStartupTasks, getStartupTask, listStartupTasks, clearStartupTasks,
5017
5481
  createSessionWithStartupTask,
5018
5482
  appendScrollbackBatch, cleanupScrollbackChunkSeqIntegrity, getNextScrollbackSeq, loadScrollback, loadScrollbackTail, clearScrollback,
@@ -5020,7 +5484,7 @@ module.exports = {
5020
5484
  listHooks, getEnabledHooks, createHook, deleteHook, toggleHook,
5021
5485
  upsertAnalysisFts, backfillFts, ftsSearchAnalyses, invalidateFtsRowidMap,
5022
5486
  extractSessionMessages, extractSessionMessagesAsync, replaceSessionMessages, backfillSessionMessages, backfillSessionMessagesAsync, ftsSearchMessages,
5023
- DEFAULT_IMAGES_DIR, BACKUP_DIR,
5487
+ DEFAULT_IMAGES_DIR, SESSION_IMAGES_DIR, BACKUP_DIR,
5024
5488
  // Workspaces (Phase 13)
5025
5489
  listWorkspaces, createWorkspace, updateWorkspace, deleteWorkspace,
5026
5490
  addSessionToWorkspace, removeSessionFromWorkspace, getWorkspaceSessions, getSessionWorkspace,
@@ -5034,7 +5498,7 @@ module.exports = {
5034
5498
  // CTM Sessions + Agent Sessions (v1 schema)
5035
5499
  getSessionIdentity, getLatestAgentSessionForCtm, getSessionConversationSourceIds, resolveSession, upsertSession, setSessionStar, getStarredSessionIds,
5036
5500
  setSessionTitleNew, setSessionTitleStatusNew, getSessionTitleNew, getAllSessionsData,
5037
- repairCodexSessionMetadataFromConversations,
5501
+ repairCodexSessionMetadataFromConversations, repairCodexRolloutAgentIdentities,
5038
5502
  getAgentSessions, getAgentSession, deleteCtmSession,
5039
5503
  // Schema version
5040
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.