create-walle 0.9.14 → 0.9.15

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 (66) hide show
  1. package/README.md +2 -2
  2. package/bin/create-walle.js +37 -2
  3. package/package.json +1 -1
  4. package/template/claude-task-manager/api-prompts.js +11 -2
  5. package/template/claude-task-manager/db.js +94 -75
  6. package/template/claude-task-manager/docs/session-tooltip-freshness-design.md +224 -0
  7. package/template/claude-task-manager/docs/session-ux-issue-review-2026-05-01.md +369 -0
  8. package/template/claude-task-manager/fuzzy-utils.js +10 -2
  9. package/template/claude-task-manager/git-utils.js +29 -7
  10. package/template/claude-task-manager/lib/agent-capabilities.js +1 -1
  11. package/template/claude-task-manager/lib/agent-presets.js +38 -5
  12. package/template/claude-task-manager/lib/codex-terminal-final.js +53 -0
  13. package/template/claude-task-manager/lib/ctm-session-context-api.js +222 -0
  14. package/template/claude-task-manager/lib/session-diagnostics.js +56 -0
  15. package/template/claude-task-manager/lib/session-history.js +165 -0
  16. package/template/claude-task-manager/lib/session-stream.js +253 -20
  17. package/template/claude-task-manager/lib/walle-ctm-history.js +49 -6
  18. package/template/claude-task-manager/lib/walle-mcp-auto-config.js +6 -2
  19. package/template/claude-task-manager/lib/walle-supervisor.js +3 -0
  20. package/template/claude-task-manager/lib/walle-transcript.js +1 -3
  21. package/template/claude-task-manager/package.json +1 -0
  22. package/template/claude-task-manager/public/css/walle.css +66 -0
  23. package/template/claude-task-manager/public/index.html +869 -223
  24. package/template/claude-task-manager/public/js/message-renderer.js +314 -35
  25. package/template/claude-task-manager/public/js/session-search-utils.js +15 -3
  26. package/template/claude-task-manager/public/js/session-status-precedence.js +125 -0
  27. package/template/claude-task-manager/public/js/stream-view.js +341 -49
  28. package/template/claude-task-manager/public/js/terminal-restore-state.js +57 -0
  29. package/template/claude-task-manager/public/js/walle-session.js +162 -11
  30. package/template/claude-task-manager/public/js/walle.js +109 -0
  31. package/template/claude-task-manager/server.js +600 -234
  32. package/template/claude-task-manager/session-integrity.js +19 -13
  33. package/template/claude-task-manager/workers/scrollback-worker.js +5 -6
  34. package/template/package.json +1 -1
  35. package/template/wall-e/agent-runners/claude-code.js +2 -0
  36. package/template/wall-e/agent.js +27 -1
  37. package/template/wall-e/api-walle.js +272 -46
  38. package/template/wall-e/brain.js +291 -42
  39. package/template/wall-e/chat.js +172 -15
  40. package/template/wall-e/coding/compaction-service.js +19 -5
  41. package/template/wall-e/coding/workspace-replay.js +1 -4
  42. package/template/wall-e/coding-orchestrator.js +224 -74
  43. package/template/wall-e/compat.js +0 -28
  44. package/template/wall-e/context/context-builder.js +3 -1
  45. package/template/wall-e/embeddings.js +2 -7
  46. package/template/wall-e/eval/agent-runner.js +14 -5
  47. package/template/wall-e/eval/benchmarks/chat-eval.json +66 -6
  48. package/template/wall-e/eval/cc-replay.js +1 -0
  49. package/template/wall-e/eval/debug-agent003.js +1 -0
  50. package/template/wall-e/eval/run-model-comparison.js +1 -0
  51. package/template/wall-e/eval/swebench-adapter.js +1 -0
  52. package/template/wall-e/evaluation/quorum-evaluator.js +0 -1
  53. package/template/wall-e/extraction/knowledge-extractor.js +1 -2
  54. package/template/wall-e/lib/mcp-integration.js +131 -15
  55. package/template/wall-e/loops/initiative.js +87 -2
  56. package/template/wall-e/mcp-server.js +621 -30
  57. package/template/wall-e/memory/ctm-context-client.js +230 -0
  58. package/template/wall-e/memory/ctm-session-context.js +490 -24
  59. package/template/wall-e/prompts/coding/memory-protocol.md +6 -0
  60. package/template/wall-e/server.js +5 -1
  61. package/template/wall-e/skills/_bundled/memory-search/SKILL.md +8 -0
  62. package/template/wall-e/skills/_bundled/slack-mentions/run.js +471 -188
  63. package/template/wall-e/skills/skill-planner.js +35 -2
  64. package/template/wall-e/slack/socket-mode-listener.js +276 -0
  65. package/template/wall-e/telemetry.js +70 -2
  66. package/template/website/index.html +2 -2
package/README.md CHANGED
@@ -20,9 +20,9 @@ A web dashboard for running and managing AI coding sessions across multiple prov
20
20
 
21
21
  An always-on AI agent that learns from your Slack, email, calendar, and coding sessions.
22
22
 
23
- - **Second Brain** — Automatically ingests your digital life into a searchable memory store with full-text search, knowledge extraction, and pattern detection
23
+ - **Second Brain** — Automatically ingests your digital life and coding sessions into a searchable memory store with full-text search, knowledge extraction, and pattern detection
24
24
  - **Proactive Intelligence** — Surfaces time-sensitive items, suggests actions, and delivers morning briefings and weekly reflections without being asked
25
- - **Chat with Tools** — Talk to Wall-E in the browser — it can search your memories, look up people, run skills, and call external tools via MCP (Slack, Glean, etc.)
25
+ - **Chat with Tools** — Talk to Wall-E in the browser — it can search memories, recall prior coding sessions, look up people, run skills, and call external tools via MCP
26
26
  - **20 Bundled Skills** — Morning briefing, weekly reflection, proactive alerts, Slack monitoring, email sync, calendar integration, coding agent, memory search, model training, model pricing sync, and more
27
27
  - **Multi-Model** — Works with Claude, GPT, Gemini, DeepSeek, and local models via Ollama, LM Studio, or MLX with smart routing
28
28
  - **Skill Management GUI** — Search, filter, create, edit, and monitor skills from the browser with rich cards, config forms, execution history, export/import, and pre-flight validation
@@ -17,6 +17,8 @@ const { injectMcpConfigs } = require('./mcp-inject');
17
17
  const TEMPLATE_DIR = path.join(__dirname, '..', 'template');
18
18
  const LABEL = 'com.walle.server';
19
19
  const INSTALL_PATH_FILE = path.join(process.env.HOME, '.walle', 'install-path');
20
+ const TELEMETRY_DATA_DIR = process.env.WALL_E_DATA_DIR || path.join(process.env.HOME || '/tmp', '.walle', 'data');
21
+ const CLI_LIFECYCLE_FILE = path.join(TELEMETRY_DATA_DIR, '.cli-lifecycle.jsonl');
20
22
  const MANAGED_PACKAGE_DIRS = ['claude-task-manager', 'wall-e'];
21
23
  const NATIVE_DEPENDENCIES = new Set([
22
24
  'better-sqlite3',
@@ -28,6 +30,24 @@ const NATIVE_DEPENDENCIES = new Set([
28
30
  // Files to preserve during update (user config, not code)
29
31
  const PRESERVE_ON_UPDATE = ['.env', 'wall-e/wall-e-config.json'];
30
32
 
33
+ function writeCliLifecycleEvent(event, meta = {}) {
34
+ if (process.env.WALLE_TELEMETRY === '0' || process.env.WALLE_TELEMETRY === 'false') return;
35
+ try {
36
+ fs.mkdirSync(TELEMETRY_DATA_DIR, { recursive: true });
37
+ const pkg = require('../package.json');
38
+ const entry = {
39
+ event,
40
+ meta: {
41
+ command: meta.command || '',
42
+ package_version: pkg.version || 'unknown',
43
+ elapsed_ms: Number.isFinite(meta.elapsed_ms) ? meta.elapsed_ms : undefined,
44
+ },
45
+ t: Date.now(),
46
+ };
47
+ fs.appendFileSync(CLI_LIFECYCLE_FILE, JSON.stringify(entry) + '\n', { mode: 0o600 });
48
+ } catch {}
49
+ }
50
+
31
51
  // ── CLI Router ──
32
52
 
33
53
  if (require.main === module) {
@@ -97,9 +117,11 @@ function printMcpResults(wallePort) {
97
117
  } else if (r.action === 'already_configured') {
98
118
  console.log(` ${DIM}= ${r.tool} -- already configured${RESET}`);
99
119
  } else if (r.action === 'updated') {
100
- console.log(` ${GREEN}~ ${r.tool}${RESET} -- updated port in ${DIM}${r.configPath}${RESET}`);
120
+ const label = r.kind === 'agent_instructions' ? 'updated memory routing in' : 'updated port in';
121
+ console.log(` ${GREEN}~ ${r.tool}${RESET} -- ${label} ${DIM}${r.configPath}${RESET}`);
101
122
  } else {
102
- console.log(` ${GREEN}+ ${r.tool}${RESET} -- added Wall-E to ${DIM}${r.configPath}${RESET}`);
123
+ const label = r.kind === 'agent_instructions' ? 'added memory routing to' : 'added Wall-E to';
124
+ console.log(` ${GREEN}+ ${r.tool}${RESET} -- ${label} ${DIM}${r.configPath}${RESET}`);
103
125
  }
104
126
  }
105
127
  console.log(`\n ${DIM}Your AI coding tools can now access Wall-E's memory and knowledge.`);
@@ -140,6 +162,8 @@ function install(targetDir) {
140
162
  console.error(' Template not found. Try: npm cache clean --force && npx create-walle@latest');
141
163
  process.exit(1);
142
164
  }
165
+ const installStartedAt = Date.now();
166
+ writeCliLifecycleEvent('cli_install_started', { command: 'install' });
143
167
 
144
168
  const ownerName = detectName().replace(/[\r\n=]/g, '').trim().slice(0, 200);
145
169
  const timezone = detectTimezone();
@@ -216,12 +240,18 @@ function install(targetDir) {
216
240
  npx create-walle logs ${DIM}View logs${RESET}
217
241
  `);
218
242
  printMcpResults(parseInt(wallePort));
243
+ writeCliLifecycleEvent('cli_install_completed', {
244
+ command: 'install',
245
+ elapsed_ms: Date.now() - installStartedAt,
246
+ });
219
247
  }
220
248
 
221
249
  function update() {
222
250
  const dir = findWalleDir();
223
251
  const port = readPort(dir);
224
252
  const pkg = require('../package.json');
253
+ const updateStartedAt = Date.now();
254
+ writeCliLifecycleEvent('cli_update_started', { command: 'update' });
225
255
 
226
256
  console.log(`${BOLD}${CYAN} Wall-E${RESET} — Updating to v${pkg.version}...\n`);
227
257
  console.log(` ${DIM}Directory: ${dir}${RESET}`);
@@ -274,6 +304,10 @@ function update() {
274
304
  ${DIM}Your .env and config were preserved.${RESET}
275
305
  `);
276
306
  printMcpResults(parseInt(readWallePort(dir)));
307
+ writeCliLifecycleEvent('cli_update_completed', {
308
+ command: 'update',
309
+ elapsed_ms: Date.now() - updateStartedAt,
310
+ });
277
311
  }
278
312
 
279
313
  function start() {
@@ -732,4 +766,5 @@ module.exports = {
732
766
  npmCliCandidates,
733
767
  repairNativeDependencies,
734
768
  resolveNpmRunner,
769
+ writeCliLifecycleEvent,
735
770
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-walle",
3
- "version": "0.9.14",
3
+ "version": "0.9.15",
4
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, and an agent that learns from Slack, email & calendar.",
5
5
  "bin": {
6
6
  "create-walle": "bin/create-walle.js"
@@ -10,6 +10,12 @@ const walleClient = require('./lib/walle-client');
10
10
  const claudeDesktopSessions = require('./lib/claude-desktop-sessions');
11
11
  // AI search uses direct HTTP calls to Claude API (supports Portkey proxy)
12
12
 
13
+ let dbMaintenanceRunner = null;
14
+
15
+ function setDbMaintenanceRunner(fn) {
16
+ dbMaintenanceRunner = typeof fn === 'function' ? fn : null;
17
+ }
18
+
13
19
  // Embed a prompt (async, fire-and-forget)
14
20
  async function _embedPrompt(promptId, title, content) {
15
21
  try {
@@ -1467,7 +1473,10 @@ async function handleRestoreBackup(req, res) {
1467
1473
  try {
1468
1474
  const data = await readBody(req);
1469
1475
  if (!data.name) return jsonResponse(res, 400, { error: 'Missing backup name' });
1470
- const result = db.restoreBackup(data.name);
1476
+ const restore = () => db.restoreBackup(data.name);
1477
+ const result = dbMaintenanceRunner
1478
+ ? await dbMaintenanceRunner({ kind: 'restore-backup', backupName: data.name }, restore)
1479
+ : restore();
1471
1480
  jsonResponse(res, 200, result);
1472
1481
  } catch (e) { jsonResponse(res, 500, { error: e.message }); }
1473
1482
  }
@@ -2814,4 +2823,4 @@ function safeParse(json, fallback) {
2814
2823
  try { return JSON.parse(json); } catch { return fallback; }
2815
2824
  }
2816
2825
 
2817
- module.exports = { handlePromptApi, queueEngine, importPermissionsToDb, runIncrementalConversationImport, importSessionFile, setUiPrefsBroadcaster };
2826
+ module.exports = { handlePromptApi, queueEngine, importPermissionsToDb, runIncrementalConversationImport, importSessionFile, setUiPrefsBroadcaster, setDbMaintenanceRunner };
@@ -2141,7 +2141,7 @@ function listSessionConversations({ search, limit, offset, hostname, allDevices
2141
2141
  }
2142
2142
  if (search) {
2143
2143
  sql += ' AND (title LIKE ? OR first_message LIKE ? OR project_path LIKE ? OR messages LIKE ?)';
2144
- const q = `%${search}%`;
2144
+ const q = `%${normalizeSessionSearchValue(search) || search}%`;
2145
2145
  params.push(q, q, q, q);
2146
2146
  }
2147
2147
  sql += ' ORDER BY imported_at DESC';
@@ -2154,6 +2154,14 @@ function getSessionConversation(sessionId) {
2154
2154
  return getDb().prepare('SELECT * FROM session_conversations WHERE ctm_session_id = ?').get(sessionId);
2155
2155
  }
2156
2156
 
2157
+ function normalizeSessionSearchValue(value) {
2158
+ return String(value || '')
2159
+ .trim()
2160
+ .toLowerCase()
2161
+ .replace(/(^|[\s([{])[$/]+(?=[a-z0-9_-])/g, '$1')
2162
+ .replace(/\s+/g, ' ');
2163
+ }
2164
+
2157
2165
  function updateSessionModel(sessionId, modelProvider, modelId) {
2158
2166
  getDb().prepare(
2159
2167
  'UPDATE session_conversations SET model_provider = ?, model_id = ? WHERE ctm_session_id = ?'
@@ -2215,10 +2223,25 @@ function checkpointWal(mode) {
2215
2223
  const m = (mode || 'PASSIVE').toUpperCase();
2216
2224
  if (!_VALID_CHECKPOINT_MODES.has(m)) return;
2217
2225
  try {
2218
- db.pragma(`wal_checkpoint(${m})`);
2226
+ return db.pragma(`wal_checkpoint(${m})`);
2219
2227
  } catch {}
2220
2228
  }
2221
2229
 
2230
+ function checkpointWalOrThrow(mode) {
2231
+ if (!db) throw new Error('Database not initialized');
2232
+ const m = (mode || 'PASSIVE').toUpperCase();
2233
+ if (!_VALID_CHECKPOINT_MODES.has(m)) throw new Error(`Invalid WAL checkpoint mode: ${mode}`);
2234
+ const rows = db.pragma(`wal_checkpoint(${m})`);
2235
+ const row = Array.isArray(rows) ? rows[0] : rows;
2236
+ const busy = Number(row?.busy ?? row?.[0] ?? 0);
2237
+ if (busy > 0) {
2238
+ const log = row?.log ?? row?.[1] ?? 'unknown';
2239
+ const checkpointed = row?.checkpointed ?? row?.[2] ?? 'unknown';
2240
+ throw new Error(`WAL checkpoint ${m} could not complete: busy=${busy}, log=${log}, checkpointed=${checkpointed}`);
2241
+ }
2242
+ return rows;
2243
+ }
2244
+
2222
2245
  // Gzip a .db file to .db.gz and remove the original.
2223
2246
  // Returns the actual output path (destPath if gzip succeeded, srcPath if it didn't).
2224
2247
  function _gzipBackup(srcPath, destPath) {
@@ -2234,41 +2257,12 @@ function _gzipBackup(srcPath, destPath) {
2234
2257
  }
2235
2258
 
2236
2259
  function createBackup(label) {
2237
- if (!db || !currentDbPath) throw new Error('Database not initialized');
2238
- checkpointWal('TRUNCATE');
2239
-
2240
- const now = new Date();
2241
- const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
2242
- const tag = label ? `-${label.replace(/[^a-zA-Z0-9_-]/g, '')}` : '';
2243
- const tmpPath = path.join(BACKUP_DIR, `task-manager-${ts}${tag}.db`);
2244
- const backupName = `task-manager-${ts}${tag}.db.gz`;
2245
- const backupPath = path.join(BACKUP_DIR, backupName);
2246
-
2247
- // Use SQLite backup API, then gzip
2248
- db.backup(tmpPath).then(() => {
2249
- _gzipBackup(tmpPath, backupPath);
2250
- }).catch(() => {
2251
- fs.copyFileSync(currentDbPath, tmpPath);
2252
- _gzipBackup(tmpPath, backupPath);
2253
- });
2254
-
2255
- // Also copy images dir as a tarball if it has content
2256
- const imagesBackup = path.join(BACKUP_DIR, `images-${ts}${tag}.tar.gz`);
2257
- try {
2258
-
2259
- const imageFiles = fs.readdirSync(DEFAULT_IMAGES_DIR);
2260
- if (imageFiles.length > 0) {
2261
- require('child_process').spawnSync('tar', ['-czf', imagesBackup, '-C', path.dirname(DEFAULT_IMAGES_DIR), path.basename(DEFAULT_IMAGES_DIR)], { timeout: 30000 });
2262
- }
2263
- } catch {}
2264
-
2265
- cleanOldBackups();
2266
- return { backupName, backupPath, timestamp: now.toISOString() };
2260
+ return createBackupSync(label);
2267
2261
  }
2268
2262
 
2269
2263
  function createBackupSync(label) {
2270
2264
  if (!db || !currentDbPath) throw new Error('Database not initialized');
2271
- checkpointWal('TRUNCATE');
2265
+ checkpointWalOrThrow('TRUNCATE');
2272
2266
 
2273
2267
  const now = new Date();
2274
2268
  const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
@@ -2328,7 +2322,7 @@ function restoreBackup(backupName) {
2328
2322
  createBackupSync('pre-restore');
2329
2323
 
2330
2324
  // Close current DB
2331
- checkpointWal('TRUNCATE');
2325
+ checkpointWalOrThrow('TRUNCATE');
2332
2326
  if (db) { db.close(); db = null; }
2333
2327
 
2334
2328
  // Decompress if needed, then copy over current DB
@@ -2874,7 +2868,7 @@ function updateStartupTaskBranch(sessionId, branch, worktreePath) {
2874
2868
 
2875
2869
  function updateStartupTaskCwd(sessionId, cwd) {
2876
2870
  getDb().prepare('UPDATE startup_tasks SET cwd = ?, worktree_path = ? WHERE ctm_session_id = ?')
2877
- .run(cwd || '', cwd && cwd.includes('.claude/worktrees/') ? cwd : null, sessionId);
2871
+ .run(cwd || '', cwd && /\/\.(?:claude|walle)\/worktrees\//.test(cwd) ? cwd : null, sessionId);
2878
2872
  flushWal();
2879
2873
  }
2880
2874
 
@@ -3593,24 +3587,15 @@ function upsertSession(id, data, opts) {
3593
3587
  }
3594
3588
 
3595
3589
  const agentId = data.agentSessionId;
3596
-
3597
- // Cross-tab claim guard: before writing agent_sessions, make sure this
3598
- // agent_session_id isn't already owned by a different CTM tab. This is the
3599
- // main defense against mass-spawn races where multiple relink paths compete.
3600
- if (agentId && agentId !== '__CLEAR__' && !allowReclaim) {
3601
- const existing = d.prepare(
3602
- 'SELECT ctm_session_id FROM agent_sessions WHERE agent_session_id = ?'
3603
- ).get(agentId);
3604
- if (existing && existing.ctm_session_id && existing.ctm_session_id !== id) {
3605
- const err = new Error(
3606
- `agent_session_id ${agentId} is already claimed by ctm_session ${existing.ctm_session_id} (refusing cross-tab claim for ${id})`
3607
- );
3608
- err.code = 'E_CROSS_TAB_CLAIM';
3609
- err.existingCtmSessionId = existing.ctm_session_id;
3610
- err.attemptedCtmSessionId = id;
3611
- err.agentSessionId = agentId;
3612
- throw err;
3613
- }
3590
+ function throwCrossTabClaim(existingCtmSessionId) {
3591
+ const err = new Error(
3592
+ `agent_session_id ${agentId} is already claimed by ctm_session ${existingCtmSessionId} (refusing cross-tab claim for ${id})`
3593
+ );
3594
+ err.code = 'E_CROSS_TAB_CLAIM';
3595
+ err.existingCtmSessionId = existingCtmSessionId;
3596
+ err.attemptedCtmSessionId = id;
3597
+ err.agentSessionId = agentId;
3598
+ throw err;
3614
3599
  }
3615
3600
  const ctmParams = {
3616
3601
  id,
@@ -3636,6 +3621,7 @@ function upsertSession(id, data, opts) {
3636
3621
  git_branch: data.gitBranch || '',
3637
3622
  user_msg_count: data.userMsgCount || 0,
3638
3623
  slug: data.slug || '',
3624
+ allow_reclaim: allowReclaim ? 1 : 0,
3639
3625
  } : null;
3640
3626
 
3641
3627
  // Wrap both upserts in a transaction to keep ctm_sessions + agent_sessions atomic
@@ -3656,13 +3642,21 @@ function upsertSession(id, data, opts) {
3656
3642
 
3657
3643
  // If agent session data provided, upsert agent_sessions
3658
3644
  if (agentParams) {
3659
- d.prepare(`
3645
+ if (!allowReclaim) {
3646
+ const existing = d.prepare(
3647
+ 'SELECT ctm_session_id FROM agent_sessions WHERE agent_session_id = ?'
3648
+ ).get(agentId);
3649
+ if (existing && existing.ctm_session_id && existing.ctm_session_id !== id) {
3650
+ throwCrossTabClaim(existing.ctm_session_id);
3651
+ }
3652
+ }
3653
+ const result = d.prepare(`
3660
3654
  INSERT INTO agent_sessions (agent_session_id, ctm_session_id, provider, project_path, jsonl_path,
3661
3655
  first_message, file_size, modified_at, hostname, model, git_branch, user_msg_count, slug)
3662
3656
  VALUES (@agent_session_id, @ctm_session_id, @provider, @project_path, @jsonl_path,
3663
3657
  @first_message, @file_size, @modified_at, @hostname, @model, @git_branch, @user_msg_count, @slug)
3664
3658
  ON CONFLICT(agent_session_id) DO UPDATE SET
3665
- ctm_session_id = COALESCE(NULLIF(excluded.ctm_session_id, ''), agent_sessions.ctm_session_id),
3659
+ ctm_session_id = excluded.ctm_session_id,
3666
3660
  provider = COALESCE(NULLIF(excluded.provider, ''), agent_sessions.provider),
3667
3661
  project_path = COALESCE(NULLIF(excluded.project_path, ''), agent_sessions.project_path),
3668
3662
  jsonl_path = COALESCE(NULLIF(excluded.jsonl_path, ''), agent_sessions.jsonl_path),
@@ -3675,10 +3669,20 @@ function upsertSession(id, data, opts) {
3675
3669
  user_msg_count = CASE WHEN excluded.user_msg_count > 0 THEN excluded.user_msg_count ELSE agent_sessions.user_msg_count END,
3676
3670
  slug = COALESCE(NULLIF(excluded.slug, ''), agent_sessions.slug),
3677
3671
  updated_at = datetime('now')
3672
+ WHERE @allow_reclaim = 1
3673
+ OR agent_sessions.ctm_session_id IS NULL
3674
+ OR agent_sessions.ctm_session_id = excluded.ctm_session_id
3678
3675
  `).run(agentParams);
3676
+ if (!allowReclaim && result.changes === 0) {
3677
+ const existing = d.prepare(
3678
+ 'SELECT ctm_session_id FROM agent_sessions WHERE agent_session_id = ?'
3679
+ ).get(agentId);
3680
+ throwCrossTabClaim(existing?.ctm_session_id || 'unknown');
3681
+ }
3679
3682
  }
3680
3683
  });
3681
- txn();
3684
+ if (typeof txn.immediate === 'function') txn.immediate();
3685
+ else txn();
3682
3686
  }
3683
3687
 
3684
3688
  function setSessionStar(id, starred) {
@@ -3757,13 +3761,17 @@ function getSessionTitleNew(id) {
3757
3761
  function getAllSessionsData() {
3758
3762
  return getDb().prepare(`
3759
3763
  SELECT c.*, a.agent_session_id, a.jsonl_path, a.first_message, a.file_size,
3760
- a.modified_at, a.hostname, a.model, a.git_branch, a.user_msg_count,
3761
- a.last_user_content, a.first_assistant_text, a.rename_name
3764
+ a.modified_at, a.hostname, a.model, a.git_branch,
3765
+ MAX(COALESCE(a.user_msg_count, 0), COALESCE(sc.user_msg_count, 0)) as user_msg_count,
3766
+ COALESCE(NULLIF(a.last_user_content, ''), sc.last_user_content) as last_user_content,
3767
+ COALESCE(NULLIF(a.first_assistant_text, ''), sc.first_assistant_text) as first_assistant_text,
3768
+ COALESCE(NULLIF(a.rename_name, ''), sc.rename_name) as rename_name
3762
3769
  FROM ctm_sessions c
3763
3770
  LEFT JOIN (
3764
3771
  SELECT *, ROW_NUMBER() OVER (PARTITION BY ctm_session_id ORDER BY modified_at DESC, created_at DESC) as rn
3765
3772
  FROM agent_sessions
3766
3773
  ) a ON a.ctm_session_id = c.id AND a.rn = 1
3774
+ LEFT JOIN session_conversations sc ON sc.ctm_session_id = a.agent_session_id
3767
3775
  `).all();
3768
3776
  }
3769
3777
 
@@ -3789,22 +3797,33 @@ function getAgentSession(agentSessionId) {
3789
3797
  */
3790
3798
  function deleteCtmSession(ctmSessionId) {
3791
3799
  const d = getDb();
3792
- // Collect JSONL paths before delete (for disk cleanup)
3793
- const agentRows = d.prepare('SELECT jsonl_path FROM agent_sessions WHERE ctm_session_id = ?').all(ctmSessionId);
3794
- const jsonlPaths = agentRows.map(r => r.jsonl_path).filter(Boolean);
3795
-
3796
- // CASCADE delete: deleting from ctm_sessions cascades to agent_sessions
3797
- d.prepare('DELETE FROM ctm_sessions WHERE id = ?').run(ctmSessionId);
3798
-
3799
- // Also clean up other child tables (these don't have FK constraints)
3800
- try { d.prepare('DELETE FROM startup_tasks WHERE ctm_session_id = ?').run(ctmSessionId); } catch (e) { console.error('[db] deleteSession startup_tasks cleanup:', e.message); }
3801
- try { d.prepare('DELETE FROM scrollback_log WHERE ctm_session_id = ?').run(ctmSessionId); } catch (e) { console.error('[db] deleteSession scrollback_log cleanup:', e.message); }
3802
- try { d.prepare('DELETE FROM session_conversations WHERE ctm_session_id = ?').run(ctmSessionId); } catch (e) { console.error('[db] deleteSession session_conversations cleanup:', e.message); }
3803
- try { d.prepare('DELETE FROM session_messages WHERE ctm_session_id = ?').run(ctmSessionId); } catch (e) { console.error('[db] deleteSession session_messages cleanup:', e.message); }
3804
- try { d.prepare('DELETE FROM session_analyses WHERE ctm_session_id = ?').run(ctmSessionId); } catch (e) { console.error('[db] deleteSession session_analyses cleanup:', e.message); }
3805
- try { d.prepare('DELETE FROM prompt_queues WHERE ctm_session_id = ?').run(ctmSessionId); } catch (e) { console.error('[db] deleteSession prompt_queues cleanup:', e.message); }
3806
-
3807
- return jsonlPaths;
3800
+ const cleanupTables = [
3801
+ ['startup_tasks', 'ctm_session_id'],
3802
+ ['scrollback_log', 'ctm_session_id'],
3803
+ ['session_conversations', 'ctm_session_id'],
3804
+ ['session_messages', 'ctm_session_id'],
3805
+ ['session_analyses', 'ctm_session_id'],
3806
+ ['prompt_queues', 'ctm_session_id'],
3807
+ ];
3808
+ const txn = d.transaction(() => {
3809
+ // Collect JSONL paths before delete (for disk cleanup)
3810
+ const agentRows = d.prepare('SELECT jsonl_path FROM agent_sessions WHERE ctm_session_id = ?').all(ctmSessionId);
3811
+ const jsonlPaths = agentRows.map(r => r.jsonl_path).filter(Boolean);
3812
+
3813
+ // Also clean up child tables without FK constraints before the parent row is removed.
3814
+ for (const [table, idColumn] of cleanupTables) {
3815
+ const exists = d.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?").get(table);
3816
+ if (!exists) continue;
3817
+ const cols = d.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
3818
+ const column = cols.includes(idColumn) ? idColumn : (cols.includes('session_id') ? 'session_id' : null);
3819
+ if (column) d.prepare(`DELETE FROM ${table} WHERE ${column} = ?`).run(ctmSessionId);
3820
+ }
3821
+
3822
+ // CASCADE delete: deleting from ctm_sessions cascades to agent_sessions.
3823
+ d.prepare('DELETE FROM ctm_sessions WHERE id = ?').run(ctmSessionId);
3824
+ return jsonlPaths;
3825
+ });
3826
+ return txn();
3808
3827
  }
3809
3828
 
3810
3829
  // Legacy compatibility: upsertSessionIndex is now a no-op (session_index dropped)
@@ -3833,7 +3852,7 @@ module.exports = {
3833
3852
  getSessionTitle, setSessionTitle, isSessionUserRenamed, getAllSessionTitles,
3834
3853
  createTemplate, listTemplates, getTemplate, deleteTemplate,
3835
3854
  trackPromptUsage, getPromptUsageStats,
3836
- checkpointWal, createBackup, createBackupSync, listBackups, restoreBackup, deleteBackup, startDailyBackup,
3855
+ checkpointWal, checkpointWalOrThrow, createBackup, createBackupSync, listBackups, restoreBackup, deleteBackup, startDailyBackup,
3837
3856
  saveQueue, loadQueue, loadAllQueues, deleteQueueDb,
3838
3857
  listPermRules, addPermRule, removePermRule, bulkSetPermRules, getPermRulesByProject,
3839
3858
  listAutoApprovals, upsertAutoApproval, toggleAutoApproval, deleteAutoApproval, getEnabledAutoApprovals,
@@ -0,0 +1,224 @@
1
+ # Session Tooltip Freshness Design
2
+
3
+ ## Problem
4
+
5
+ The active-session tooltip can show an AI summary that is technically cached
6
+ correctly but no longer represents the current task. The most visible failure is
7
+ a running session whose tooltip headline still describes an older task while the
8
+ latest prompt and progress have already moved on.
9
+
10
+ This is worse than an empty tooltip because it gives false confidence. The
11
+ operator uses the tooltip to decide which session needs attention, so stale
12
+ intent text can send the user to the wrong session or hide a current blocker.
13
+
14
+ ## Existing Behavior
15
+
16
+ Current flow:
17
+
18
+ 1. `stream-view.js` opens the tooltip after hover and fetches
19
+ `/api/sessions/:id/summary?turns=3`.
20
+ 2. `SessionStream` stores cleaned user prompts in `userPromptCache`.
21
+ 3. A new user prompt debounces AI summary generation by 2 seconds.
22
+ 4. The configured provider generates a 10-15 word summary over the cached
23
+ prompt list.
24
+ 5. `getSummary()` returns `summary`, `intent`, `displayPrompt`, `lastPrompt`,
25
+ and `progress`.
26
+
27
+ Current failure modes:
28
+
29
+ - Cached `intent.source = ai-summary` is treated as authoritative even when its
30
+ timestamp is older than the latest prompt.
31
+ - The tooltip does not refetch or rerender while it is already open for the same
32
+ session.
33
+ - The AI summary input is a flat list of recent prompts, so older work can
34
+ dominate the summary after a task switch.
35
+ - The UI label `Intent` does not explain freshness or distinguish current
36
+ prompt evidence from slower AI synthesis.
37
+
38
+ ## Design Principles
39
+
40
+ - Latest user intent is the primary truth. AI is a compression layer, not a
41
+ source of freshness.
42
+ - Stale AI should be visible as context, never promoted as the current task.
43
+ - Refresh should be scoped to visible UI. Do not add broad polling across every
44
+ session.
45
+ - The tooltip should be copyable, stable, dense, and operational.
46
+ - Existing API fields must stay backward compatible for Session Overview and
47
+ older clients.
48
+
49
+ ## API Contract
50
+
51
+ `GET /api/sessions/:id/summary` keeps existing fields and adds:
52
+
53
+ ```js
54
+ {
55
+ currentTask: {
56
+ text: string | null,
57
+ source: 'latest-prompt' | 'ai-summary' | 'prompt-fallback' | 'title-fallback' | 'missing',
58
+ freshness: 'fresh' | 'updating' | 'stale' | 'missing',
59
+ updatedAt: number,
60
+ promptTimestamp: number,
61
+ staleReason?: string
62
+ },
63
+ latestPrompt: {
64
+ text: string | null,
65
+ timestamp: number
66
+ },
67
+ aiSummary: {
68
+ text: string | null,
69
+ source: string | null,
70
+ status: 'fresh' | 'updating' | 'stale' | 'fallback' | 'unavailable',
71
+ updatedAt: number,
72
+ promptTimestamp: number,
73
+ promptCount: number,
74
+ staleReason?: string
75
+ }
76
+ }
77
+ ```
78
+
79
+ Compatibility mapping:
80
+
81
+ - `intent` remains present.
82
+ - `summary` remains present.
83
+ - When AI is stale, `intent.text` should follow `currentTask.text` so existing
84
+ clients do not keep rendering stale AI as the primary task.
85
+ - `aiSummary.text` retains the older AI text for secondary display.
86
+
87
+ Freshness rules:
88
+
89
+ - If usable AI summary exists and `aiSummary.updatedAt >= latestPrompt.timestamp`,
90
+ `currentTask.source = ai-summary` and `freshness = fresh`.
91
+ - If usable AI summary exists but is older than the latest prompt,
92
+ `currentTask.source = latest-prompt`, `freshness = updating`, and
93
+ `aiSummary.status = stale` or `updating`.
94
+ - If no AI summary exists, use the most recent content-rich prompt and mark the
95
+ AI summary as `unavailable` or `fallback`.
96
+ - If a fallback summary is raw prompt text, keep current task on prompt evidence
97
+ and mark `aiSummary.status = fallback`.
98
+
99
+ ## Summary Generation
100
+
101
+ Change the AI prompt from a flat prompt list to latest-prompt-weighted input.
102
+
103
+ Recommended input:
104
+
105
+ ```text
106
+ Latest prompt:
107
+ <most recent cleaned prompt>
108
+
109
+ Recent context:
110
+ 1. <older prompt>
111
+ 2. <older prompt>
112
+ 3. <older prompt>
113
+ ```
114
+
115
+ Recommended system prompt:
116
+
117
+ ```text
118
+ Summarize the user's current task. Prioritize the latest prompt. Use older
119
+ prompts only as context. Return 8-14 words. Return only the summary, no quotes
120
+ or prefix.
121
+ ```
122
+
123
+ The cached summary should record the prompt timestamp and prompt count used to
124
+ generate it. This makes freshness testable without depending on provider speed.
125
+
126
+ ## Tooltip UX
127
+
128
+ Replace the main `Intent` section with `Current Task`.
129
+
130
+ Fresh state:
131
+
132
+ ```text
133
+ CURRENT TASK
134
+ [AI SUMMARY] [FRESH] [now]
135
+ Fixing Wall-E session history restore and tooltip freshness.
136
+ ```
137
+
138
+ Updating state:
139
+
140
+ ```text
141
+ CURRENT TASK
142
+ [LATEST PROMPT] [UPDATING] [now]
143
+ Does WallE coding agent have auto compact logic like OpenCode or Claude?
144
+
145
+ AI SUMMARY
146
+ [STALE] [2m ago]
147
+ Previous: Fixing session tab drag behavior and search issues.
148
+ ```
149
+
150
+ Unavailable state:
151
+
152
+ ```text
153
+ CURRENT TASK
154
+ [LATEST PROMPT] [FALLBACK] [now]
155
+ Fix the session tooltip freshness behavior.
156
+ ```
157
+
158
+ Progress remains separate and should keep using assistant-event evidence.
159
+
160
+ Interaction behavior:
161
+
162
+ - Opening a tooltip fetches summary immediately.
163
+ - If the tooltip is already open for that session, new stream events rerender it.
164
+ - While visible, perform a lightweight refresh every 10 seconds.
165
+ - Stop the refresh timer when the tooltip is hidden.
166
+ - Clicking inside the tooltip continues to preserve it for copy/select.
167
+ - Activating a different session tooltip replaces the current tooltip.
168
+
169
+ ## Implementation Plan
170
+
171
+ Phase 1: documentation
172
+
173
+ - Add this design note.
174
+ - Commit documentation by itself after TL review.
175
+
176
+ Phase 2: backend freshness
177
+
178
+ - Extend `SessionStream` cached summary metadata with prompt timestamp/count.
179
+ - Add `latestPrompt`, `aiSummary`, and `currentTask` to `getSummary()`.
180
+ - Ensure `intent` maps to `currentTask`, not stale AI.
181
+ - Update summary generation prompt to prioritize latest prompt.
182
+ - Add unit tests for fresh, stale, fallback, and provider-lag cases.
183
+
184
+ Phase 3: tooltip UI
185
+
186
+ - Render `Current Task` from `currentTask`.
187
+ - Render stale AI only as secondary context.
188
+ - Add freshness/status pills.
189
+ - Add visible-tooltip refresh and stream-event rerender.
190
+ - Add browser/render coverage for a tooltip that updates while open.
191
+
192
+ Phase 4: dev validation
193
+
194
+ - Start isolated CTM via `ctm-dev`.
195
+ - Verify service health on the dev port pair.
196
+ - Exercise stale summary behavior through a real browser.
197
+ - Confirm no browser console errors for the tooltip flow.
198
+ - Commit implementation after TL review.
199
+
200
+ ## Test Matrix
201
+
202
+ Backend:
203
+
204
+ - AI summary generated after latest prompt: primary task is AI summary.
205
+ - AI summary timestamp older than latest prompt: primary task is latest prompt.
206
+ - AI provider slow or failed: primary task remains latest prompt.
207
+ - Fallback summary raw prompt: primary task remains prompt evidence.
208
+ - Summary generation input puts latest prompt before recent context.
209
+
210
+ Frontend:
211
+
212
+ - Tooltip opens and labels `Current Task`.
213
+ - Stale AI is demoted into `AI Summary`.
214
+ - New user stream event updates an already-open tooltip.
215
+ - Fresh summary event promotes AI summary.
216
+ - Tooltip remains copyable and dismisses only on outside click or another
217
+ tooltip activation.
218
+
219
+ Dev validation:
220
+
221
+ - CTM dev server starts on a random non-primary port.
222
+ - `/api/services/status` succeeds on the dev port.
223
+ - Browser test uses the dev port, never `3456` or `3457`.
224
+ - Screenshot or DOM assertions prove the freshness state.