create-walle 0.9.11 → 0.9.13

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 (167) hide show
  1. package/README.md +3 -3
  2. package/package.json +2 -2
  3. package/template/bin/dev.sh +7 -1
  4. package/template/bin/setup.js +53 -9
  5. package/template/bin/sync-images.js +53 -0
  6. package/template/builder-journal.md +17 -0
  7. package/template/claude-task-manager/api-prompts.js +98 -13
  8. package/template/claude-task-manager/api-reviews.js +82 -5
  9. package/template/claude-task-manager/db.js +32 -5
  10. package/template/claude-task-manager/docs/session-capture-foundation-design.md +1273 -0
  11. package/template/claude-task-manager/lib/claude-desktop-sessions.js +696 -0
  12. package/template/claude-task-manager/lib/coding-agent-models.js +49 -1
  13. package/template/claude-task-manager/lib/session-capture.js +421 -0
  14. package/template/claude-task-manager/lib/session-history.js +135 -15
  15. package/template/claude-task-manager/lib/session-jobs.js +10 -5
  16. package/template/claude-task-manager/lib/session-stream.js +87 -19
  17. package/template/claude-task-manager/lib/setup-provider-config.js +115 -0
  18. package/template/claude-task-manager/lib/walle-ctm-history.js +72 -0
  19. package/template/claude-task-manager/lib/walle-session-context.js +61 -0
  20. package/template/claude-task-manager/lib/walle-transcript.js +176 -0
  21. package/template/claude-task-manager/public/css/setup.css +35 -8
  22. package/template/claude-task-manager/public/css/walle-session.css +56 -0
  23. package/template/claude-task-manager/public/css/walle.css +120 -0
  24. package/template/claude-task-manager/public/index.html +814 -181
  25. package/template/claude-task-manager/public/js/message-renderer.js +148 -19
  26. package/template/claude-task-manager/public/js/reviews.js +120 -62
  27. package/template/claude-task-manager/public/js/setup.js +75 -31
  28. package/template/claude-task-manager/public/js/stream-view.js +115 -55
  29. package/template/claude-task-manager/public/js/walle-session.js +84 -2
  30. package/template/claude-task-manager/public/js/walle.js +308 -54
  31. package/template/claude-task-manager/server.js +1092 -146
  32. package/template/claude-task-manager/session-integrity.js +181 -54
  33. package/template/claude-task-manager/session-utils.js +123 -41
  34. package/template/claude-task-manager/workers/state-detectors/codex.js +5 -2
  35. package/template/package.json +1 -1
  36. package/template/wall-e/adapters/ctm.js +39 -18
  37. package/template/wall-e/agent-runners/contract.js +17 -0
  38. package/template/wall-e/agent-runners/index.js +22 -0
  39. package/template/wall-e/agent-runtime/harness.js +212 -0
  40. package/template/wall-e/agent-runtime/index.js +8 -0
  41. package/template/wall-e/agent-runtime/registry.js +67 -0
  42. package/template/wall-e/agent-runtime/session-store.js +179 -0
  43. package/template/wall-e/agent-runtime/spawn.js +208 -0
  44. package/template/wall-e/api-walle.js +174 -7
  45. package/template/wall-e/brain.js +266 -28
  46. package/template/wall-e/channels/policy.js +88 -0
  47. package/template/wall-e/channels/registry.js +15 -1
  48. package/template/wall-e/channels/reply-dispatcher.js +70 -0
  49. package/template/wall-e/channels/session-bindings.js +51 -0
  50. package/template/wall-e/chat/code-review-context.js +29 -0
  51. package/template/wall-e/chat.js +188 -42
  52. package/template/wall-e/coding/acp-adapter.js +188 -0
  53. package/template/wall-e/coding/agent-catalog.js +129 -0
  54. package/template/wall-e/coding/compaction-service.js +247 -0
  55. package/template/wall-e/coding/execution-trace.js +3 -0
  56. package/template/wall-e/coding/instruction-service.js +224 -0
  57. package/template/wall-e/coding/model-message.js +67 -0
  58. package/template/wall-e/coding/permission-rules-store.js +111 -0
  59. package/template/wall-e/coding/permission-service.js +266 -0
  60. package/template/wall-e/coding/prompt-bundle.js +67 -0
  61. package/template/wall-e/coding/prompt-runtime.js +243 -0
  62. package/template/wall-e/coding/provider-transform.js +188 -0
  63. package/template/wall-e/coding/runtime-mode.js +132 -0
  64. package/template/wall-e/coding/snapshot-service.js +155 -0
  65. package/template/wall-e/coding/stream-processor.js +268 -0
  66. package/template/wall-e/coding/task-tool.js +255 -0
  67. package/template/wall-e/coding/tool-registry.js +361 -0
  68. package/template/wall-e/coding/transcript-writer.js +143 -0
  69. package/template/wall-e/coding/workspace-replay.js +324 -0
  70. package/template/wall-e/coding-context.js +4 -22
  71. package/template/wall-e/coding-orchestrator.js +307 -18
  72. package/template/wall-e/coding-prompts.js +44 -3
  73. package/template/wall-e/context/context-builder.js +43 -1
  74. package/template/wall-e/context/topic-matcher.js +1 -1
  75. package/template/wall-e/eval/agent-runner.js +59 -13
  76. package/template/wall-e/eval/benchmarks/memory-retrieval.json +155 -57
  77. package/template/wall-e/eval/benchmarks.js +100 -16
  78. package/template/wall-e/eval/eval-orchestrator.js +218 -8
  79. package/template/wall-e/eval/harvester.js +62 -5
  80. package/template/wall-e/eval/head-to-head.js +23 -2
  81. package/template/wall-e/eval/humaneval-adapter.js +30 -5
  82. package/template/wall-e/eval/livecodebench-adapter.js +29 -5
  83. package/template/wall-e/eval/manifest.js +186 -0
  84. package/template/wall-e/eval/run-agent-benchmarks.js +66 -2
  85. package/template/wall-e/eval/session-retrieval-benchmark.js +150 -0
  86. package/template/wall-e/eval/session-transcripts.js +57 -4
  87. package/template/wall-e/eval/swebench-adapter.js +109 -3
  88. package/template/wall-e/evaluation/agent-router.js +53 -1
  89. package/template/wall-e/evaluation/coding-quorum.js +48 -1
  90. package/template/wall-e/evaluation/router.js +4 -2
  91. package/template/wall-e/evaluation/tier-selector.js +11 -1
  92. package/template/wall-e/extraction/contradiction.js +2 -2
  93. package/template/wall-e/extraction/indexer.js +2 -1
  94. package/template/wall-e/extraction/knowledge-extractor.js +2 -2
  95. package/template/wall-e/hooks/cli.js +92 -0
  96. package/template/wall-e/hooks/discovery.js +119 -0
  97. package/template/wall-e/hooks/index.js +7 -0
  98. package/template/wall-e/hooks/manifest.js +55 -0
  99. package/template/wall-e/hooks/runtime.js +84 -0
  100. package/template/wall-e/hooks/session-memory.js +225 -0
  101. package/template/wall-e/http/auth.js +6 -2
  102. package/template/wall-e/http/chat-api.js +54 -8
  103. package/template/wall-e/integrations/claude-plugin/hooks/hooks.json +27 -0
  104. package/template/wall-e/integrations/claude-plugin/hooks/walle-precompact-hook.sh +5 -0
  105. package/template/wall-e/integrations/claude-plugin/hooks/walle-stop-hook.sh +5 -0
  106. package/template/wall-e/integrations/codex-plugin/hooks/walle-hook.sh +7 -0
  107. package/template/wall-e/integrations/codex-plugin/hooks.json +37 -0
  108. package/template/wall-e/listening/calendar.js +3 -1
  109. package/template/wall-e/llm/client.js +64 -10
  110. package/template/wall-e/llm/google.js +39 -5
  111. package/template/wall-e/llm/ollama.js +1 -1
  112. package/template/wall-e/llm/ollama.plugin.json +1 -1
  113. package/template/wall-e/llm/provider-availability.js +10 -0
  114. package/template/wall-e/llm/provider-error.js +269 -0
  115. package/template/wall-e/llm/tool-adapter.js +48 -12
  116. package/template/wall-e/loops/boot.js +2 -1
  117. package/template/wall-e/loops/initiative.js +2 -2
  118. package/template/wall-e/loops/tasks.js +8 -47
  119. package/template/wall-e/loops/workspace-prompts.js +20 -0
  120. package/template/wall-e/mcp-server.js +442 -1
  121. package/template/wall-e/memory/session-ingest-service.js +159 -0
  122. package/template/wall-e/memory/source-indexer.js +289 -0
  123. package/template/wall-e/plugins/discovery.js +83 -0
  124. package/template/wall-e/plugins/manifest-loader.js +50 -10
  125. package/template/wall-e/plugins/manifest-schema.js +69 -0
  126. package/template/wall-e/plugins/model-catalog.js +55 -0
  127. package/template/wall-e/prompts/coding/base.txt +2 -0
  128. package/template/wall-e/prompts/coding/deepseek.txt +1 -0
  129. package/template/wall-e/prompts/coding/memory-protocol.md +9 -0
  130. package/template/wall-e/prompts/coding/plan.txt +1 -0
  131. package/template/wall-e/runtime/execution-trace.js +220 -0
  132. package/template/wall-e/security/audit.js +266 -0
  133. package/template/wall-e/security/ssrf.js +236 -0
  134. package/template/wall-e/session-files.js +303 -0
  135. package/template/wall-e/skills/_bundled/slack-backfill/SKILL.md +3 -0
  136. package/template/wall-e/skills/_bundled/slack-sync/SKILL.md +3 -0
  137. package/template/wall-e/skills/internal-skill-registry.js +2 -2
  138. package/template/wall-e/skills/script-skill-runner.js +143 -0
  139. package/template/wall-e/skills/skill-executor.js +5 -6
  140. package/template/wall-e/skills/skill-fallback.js +3 -1
  141. package/template/wall-e/skills/skill-harness-registry.js +7 -8
  142. package/template/wall-e/skills/skill-planner.js +52 -4
  143. package/template/wall-e/skills/slack-ingest.js +11 -3
  144. package/template/wall-e/sources/base.js +90 -0
  145. package/template/wall-e/sources/builtin.js +33 -0
  146. package/template/wall-e/sources/claude-code-jsonl.js +78 -0
  147. package/template/wall-e/sources/codex-jsonl.js +125 -0
  148. package/template/wall-e/sources/coding-session-utils.js +117 -0
  149. package/template/wall-e/sources/contract-suite.js +59 -0
  150. package/template/wall-e/sources/gemini-jsonl.js +85 -0
  151. package/template/wall-e/sources/index.js +9 -0
  152. package/template/wall-e/sources/jsonl-utils.js +181 -0
  153. package/template/wall-e/sources/record-types.js +252 -0
  154. package/template/wall-e/sources/registry.js +92 -0
  155. package/template/wall-e/sources/transforms.js +100 -0
  156. package/template/wall-e/sources/walle-jsonl.js +108 -0
  157. package/template/wall-e/tools/coding-middleware.js +31 -1
  158. package/template/wall-e/tools/file-tracker.js +25 -1
  159. package/template/wall-e/tools/local-tools.js +75 -47
  160. package/template/wall-e/tools/session-sharing.js +68 -1
  161. package/template/wall-e/tools/shell-analyzer.js +1 -1
  162. package/template/wall-e/tools/shell-policy.js +47 -0
  163. package/template/wall-e/tools/snapshot.js +42 -0
  164. package/template/wall-e/training/harvester.js +62 -5
  165. package/template/wall-e/utils/repair.js +253 -1
  166. package/template/website/index.html +3 -3
  167. package/template/wall-e/skills/_bundled/slack-mentions/.watched-threads.json +0 -18
@@ -33,9 +33,11 @@ const {
33
33
  findCodexThreadForCtmSession,
34
34
  findCodexThreadForSession,
35
35
  getCodexThreadById,
36
+ getCodexThreadResumeCwd,
36
37
  getResumeSpec,
37
38
  parseSessionStartMs,
38
39
  parseCodexJsonlIntoMessages,
40
+ parseCodexJsonlFileIntoMessages,
39
41
  } = require('./lib/session-history');
40
42
  const { atomicWriteFileSync } = require('./atomic-write');
41
43
  const { execFile: execFileCb, execFileSync } = require('child_process');
@@ -46,16 +48,37 @@ const compactStitch = require('./lib/compact-stitch');
46
48
  const { registerSessionJobs, registerStreamJobs } = require('./lib/session-jobs');
47
49
  const { SessionStream } = require('./lib/session-stream');
48
50
  const { getAllSessionFilesAsync: getAllSessionFilesAsyncFromUtils } = require('./session-utils');
51
+ const { SessionCapture } = require('./lib/session-capture');
49
52
  const { resolveFallback: resolveLaunchFallback, isEarlyExit } = require('./lib/launch-presets');
50
53
  const { buildTelemetryEnv, detectPresetId } = require('./lib/agent-presets');
51
- const { createCodingAgentModelSync } = require('./lib/coding-agent-models');
54
+ const {
55
+ createCodingAgentModelSync,
56
+ shouldApplyCodexAutoTitle,
57
+ } = require('./lib/coding-agent-models');
52
58
  const agentCliCache = require('./lib/agent-cli-cache');
53
59
  const walleClient = require('./lib/walle-client');
60
+ const walleTranscript = require('./lib/walle-transcript');
61
+ const claudeDesktopSessions = require('./lib/claude-desktop-sessions');
54
62
  const SessionSearchUtils = require('./public/js/session-search-utils.js');
55
63
  const agentHooksInstaller = require('./lib/agent-hooks-installer');
56
64
  const statusHooks = require('./lib/status-hooks');
57
65
  const { getStateDetector } = require('./workers/state-detectors');
58
66
  const { canResumeAgent, getAgentCapabilities, normalizeAgentType } = require('./lib/agent-capabilities');
67
+ const { resolveWalleChatContext } = require('./lib/walle-session-context');
68
+ const {
69
+ applyWalleToolEvent,
70
+ cloneToolCalls,
71
+ readWalleCtmHistory,
72
+ } = require('./lib/walle-ctm-history');
73
+ const {
74
+ SETUP_PROVIDER_TYPES,
75
+ SETUP_PROVIDER_NAMES,
76
+ SETUP_PROVIDER_ENV_KEYS,
77
+ sanitizeSetupProviderType,
78
+ resolveSetupDefaultSelection,
79
+ setupProviderHasRuntimeAccess,
80
+ setupProviderTypeList,
81
+ } = require('./lib/setup-provider-config');
59
82
  const {
60
83
  CONFIG_DIR,
61
84
  CONFIG_FILE,
@@ -85,6 +108,121 @@ function getWalleBrain() {
85
108
  } catch { return null; }
86
109
  }
87
110
 
111
+ function getProviderRuntimeState(type) {
112
+ const providerType = sanitizeSetupProviderType(type);
113
+ let hasStoredKey = false;
114
+ let authMethod = providerType === 'anthropic' || providerType === 'openai'
115
+ ? (process.env.WALLE_AUTH_METHOD || '')
116
+ : '';
117
+ try {
118
+ const brain = getWalleBrain();
119
+ const row = brain?.getDb?.().prepare(
120
+ 'SELECT api_key_encrypted, auth_method FROM model_providers WHERE type = ? AND enabled = 1 ORDER BY updated_at DESC LIMIT 1'
121
+ ).get(providerType);
122
+ hasStoredKey = !!row?.api_key_encrypted;
123
+ authMethod = row?.auth_method || authMethod || '';
124
+ } catch {}
125
+ return {
126
+ type: providerType,
127
+ authMethod,
128
+ hasStoredKey,
129
+ hasRuntimeAccess: setupProviderHasRuntimeAccess({
130
+ type: providerType,
131
+ env: process.env,
132
+ authMethod,
133
+ hasStoredKey,
134
+ }),
135
+ };
136
+ }
137
+
138
+ function getActiveWalleProviderState(providerType) {
139
+ return getProviderRuntimeState(providerType || process.env.WALLE_PROVIDER || 'anthropic');
140
+ }
141
+
142
+ function _extractLlmText(response) {
143
+ const content = response?.content ?? response?.text ?? '';
144
+ if (typeof content === 'string') return content;
145
+ if (Array.isArray(content)) {
146
+ return content.map((part) => {
147
+ if (!part) return '';
148
+ if (typeof part === 'string') return part;
149
+ return part.text || part.content || '';
150
+ }).filter(Boolean).join('\n');
151
+ }
152
+ return '';
153
+ }
154
+
155
+ function _storedProviderKey(brain, type) {
156
+ try {
157
+ const row = brain?.getDb?.().prepare(
158
+ 'SELECT api_key_encrypted FROM model_providers WHERE type = ? AND enabled = 1 AND api_key_encrypted IS NOT NULL ORDER BY updated_at DESC LIMIT 1'
159
+ ).get(type);
160
+ return row?.api_key_encrypted || '';
161
+ } catch {
162
+ return '';
163
+ }
164
+ }
165
+
166
+ async function generateSessionSummaryWithWalleProvider({ turnsText, sysPrompt }) {
167
+ const brain = getWalleBrain();
168
+ const provider = sanitizeSetupProviderType(
169
+ brain?.getKv?.('walle_provider') || process.env.WALLE_PROVIDER || 'anthropic'
170
+ );
171
+ if (!provider) return null;
172
+
173
+ const model = brain?.getKv?.('walle_model_' + provider)
174
+ || brain?.getKv?.('walle_model')
175
+ || process.env.WALLE_MODEL
176
+ || '';
177
+ const state = getProviderRuntimeState(provider);
178
+ const config = {};
179
+ let clientType = provider;
180
+
181
+ if (provider === 'anthropic' && state.authMethod === 'claude_cli') {
182
+ clientType = 'claude-cli';
183
+ } else if (provider === 'anthropic' && state.authMethod === 'oauth_proxy') {
184
+ config.apiKey = 'oauth-proxy-placeholder';
185
+ config.baseUrl = `http://127.0.0.1:${process.env.OAUTH_PROXY_PORT || '3458'}`;
186
+ } else if (provider === 'openai' && state.authMethod === 'codex_cli') {
187
+ clientType = 'codex-cli';
188
+ } else if (provider === 'ollama' || provider === 'mlx') {
189
+ if (provider === 'ollama' && process.env.OLLAMA_BASE_URL) config.baseUrl = process.env.OLLAMA_BASE_URL;
190
+ if (provider === 'mlx' && process.env.MLX_MODEL) config.model = process.env.MLX_MODEL;
191
+ } else {
192
+ const envKey = SETUP_PROVIDER_ENV_KEYS[provider];
193
+ const apiKey = _storedProviderKey(brain, provider) || (envKey ? process.env[envKey] : '');
194
+ if (!apiKey && !state.hasRuntimeAccess) return null;
195
+ if (apiKey) config.apiKey = apiKey;
196
+ if (provider === 'anthropic' && process.env.ANTHROPIC_BASE_URL) config.baseUrl = process.env.ANTHROPIC_BASE_URL;
197
+ if (provider === 'openai' && process.env.OPENAI_BASE_URL) config.baseUrl = process.env.OPENAI_BASE_URL;
198
+ if (provider === 'deepseek' && process.env.DEEPSEEK_BASE_URL) config.baseUrl = process.env.DEEPSEEK_BASE_URL;
199
+ if (provider === 'google' && process.env.GOOGLE_AUTH_MODE === 'oauth') {
200
+ config.authMode = 'oauth';
201
+ config.refreshToken = process.env.GOOGLE_REFRESH_TOKEN;
202
+ }
203
+ }
204
+
205
+ try {
206
+ const { createClient } = require(path.resolve(__dirname, '..', 'wall-e', 'llm', 'client'));
207
+ const client = createClient(clientType, config);
208
+ const response = await client.chat({
209
+ model: model || undefined,
210
+ system: sysPrompt,
211
+ messages: [{ role: 'user', content: turnsText }],
212
+ maxTokens: 60,
213
+ temperature: 0.2,
214
+ thinking: 'disabled',
215
+ reasoningEffort: 'low',
216
+ signal: AbortSignal.timeout(15000),
217
+ });
218
+ const text = _extractLlmText(response).replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
219
+ return text ? { text, model: `${provider}:${model || clientType}` } : null;
220
+ } catch (e) {
221
+ console.warn('[session-stream] configured summary provider failed:', e.message);
222
+ return null;
223
+ }
224
+ }
225
+
88
226
  function getShellRcSource(shell) {
89
227
  const name = path.basename(shell || '');
90
228
  if (name.includes('zsh')) {
@@ -148,6 +286,14 @@ function _isStatusOnlyPtyChunk(session, data) {
148
286
  return !!detector.isStatusOnlyChunk(filtered);
149
287
  }
150
288
 
289
+ function _isBusyStatusPtyChunk(session, data) {
290
+ const providerId = session?._providerId || _providerIdFromCmd(session?.cmd || '');
291
+ const detector = getStateDetector(providerId);
292
+ if (typeof detector.isBusyStatusChunk !== 'function') return false;
293
+ const filtered = detector.filterOutput ? detector.filterOutput(data) : data;
294
+ return !!detector.isBusyStatusChunk(filtered);
295
+ }
296
+
151
297
  function _isServerWaitingForInput(sessionId, session) {
152
298
  return !!(session?._waitingForInput || idleNotifyState.get(sessionId)?.notified);
153
299
  }
@@ -166,6 +312,7 @@ function handleAvailableAgentsApi(res) {
166
312
  let _ctmScheduler = null;
167
313
  let _jsonlWatcher = null;
168
314
  let _sessionStream = null;
315
+ let _sessionCapture = null;
169
316
  let _isShuttingDown = false;
170
317
 
171
318
  function isSqliteBusyError(err) {
@@ -198,17 +345,7 @@ function addStartupTaskWithRetry(label, args, attempt = 0) {
198
345
  const telemetry = { track() {}, flush() {}, start() {}, stop() {} };
199
346
 
200
347
  // --- Wall-E Session JSONL helpers ---
201
- const WALLE_SESSIONS_DIR = path.join(process.env.HOME, '.walle', 'sessions');
202
-
203
- function appendToJsonl(filePath, obj) {
204
- fs.appendFileSync(filePath, JSON.stringify(obj) + '\n');
205
- }
206
-
207
- function ensureWalleSessionsDir() {
208
- if (!fs.existsSync(WALLE_SESSIONS_DIR)) {
209
- fs.mkdirSync(WALLE_SESSIONS_DIR, { recursive: true });
210
- }
211
- }
348
+ const WALLE_SESSIONS_DIR = walleTranscript.defaultSessionsDir(process.env);
212
349
 
213
350
  const config = loadConfig();
214
351
  const AGENT_CLI_CACHE_FILE = path.join(CONFIG_DIR, 'agent-cli-cache.json');
@@ -816,6 +953,96 @@ function probeModel(apiKey, baseUrl, customHeadersB64, modelOrder) {
816
953
  });
817
954
  }
818
955
 
956
+ function probeClaudeCliAuth() {
957
+ return new Promise((resolve) => {
958
+ const { spawn } = require('child_process');
959
+ const childEnv = { ...process.env };
960
+ delete childEnv.ANTHROPIC_API_KEY;
961
+ delete childEnv.ANTHROPIC_AUTH_TOKEN;
962
+ let proc;
963
+ try {
964
+ proc = spawn('claude', ['auth', 'status'], {
965
+ stdio: ['ignore', 'pipe', 'pipe'],
966
+ timeout: 15000,
967
+ env: childEnv,
968
+ });
969
+ } catch (err) {
970
+ resolve({ ok: false, error: 'Could not spawn claude: ' + err.message });
971
+ return;
972
+ }
973
+ let stdout = '';
974
+ let stderr = '';
975
+ let settled = false;
976
+ const done = (result) => {
977
+ if (settled) return;
978
+ settled = true;
979
+ resolve(result);
980
+ };
981
+ proc.stdout.on('data', (d) => { stdout += d.toString(); });
982
+ proc.stderr.on('data', (d) => { stderr += d.toString(); });
983
+ proc.on('error', (err) => {
984
+ done({
985
+ ok: false,
986
+ error: err.code === 'ENOENT'
987
+ ? 'claude CLI not on PATH. Install Claude Code from claude.com/claude-code.'
988
+ : 'spawn failed: ' + err.message,
989
+ });
990
+ });
991
+ proc.on('close', (code) => {
992
+ if (code !== 0) {
993
+ done({ ok: false, error: 'claude auth status exited ' + code + ': ' + (stderr || stdout).slice(0, 300) });
994
+ return;
995
+ }
996
+ let parsed;
997
+ try { parsed = JSON.parse(stdout); }
998
+ catch { done({ ok: false, error: 'claude auth status stdout not JSON: ' + stdout.slice(0, 200) }); return; }
999
+ if (!parsed.loggedIn) {
1000
+ done({ ok: false, error: 'CLI installed but not signed in. Run `claude auth login`.' });
1001
+ return;
1002
+ }
1003
+ const label = [parsed.email, parsed.subscriptionType].filter(Boolean).join(' · ');
1004
+ done({ ok: true, model: label || 'claude.ai', reply: parsed.authMethod || 'subscription' });
1005
+ });
1006
+ });
1007
+ }
1008
+
1009
+ async function probeAnthropicOauthProxy() {
1010
+ const proxyStatus = await oauthProxySupervisor.status();
1011
+ if (!proxyStatus.running) {
1012
+ return { ok: false, error: 'OAuth proxy is not running. Click "I understand, enable it" to start it.', proxy_status: proxyStatus };
1013
+ }
1014
+ const port = proxyStatus.port;
1015
+ try {
1016
+ const r = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
1017
+ method: 'POST',
1018
+ headers: {
1019
+ 'Content-Type': 'application/json',
1020
+ 'anthropic-version': '2023-06-01',
1021
+ },
1022
+ body: JSON.stringify({
1023
+ model: 'claude-haiku-4-5-20251001',
1024
+ max_tokens: 20,
1025
+ messages: [{ role: 'user', content: 'reply with the literal string OK and nothing else' }],
1026
+ }),
1027
+ signal: AbortSignal.timeout(20000),
1028
+ });
1029
+ if (!r.ok) {
1030
+ const txt = await r.text().catch(() => '');
1031
+ return { ok: false, error: `Proxy returned ${r.status}: ${txt.slice(0, 250)}`, proxy_status: proxyStatus };
1032
+ }
1033
+ const d = await r.json();
1034
+ const reply = (d.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('').trim();
1035
+ return {
1036
+ ok: reply.length > 0,
1037
+ model: d.model || 'claude-haiku-4-5',
1038
+ reply: reply.slice(0, 50),
1039
+ proxy_status: proxyStatus,
1040
+ };
1041
+ } catch (err) {
1042
+ return { ok: false, error: 'Proxy probe failed: ' + err.message, proxy_status: proxyStatus };
1043
+ }
1044
+ }
1045
+
819
1046
  // --- API Handlers ---
820
1047
  async function handleApi(req, res, url) {
821
1048
  // --- Setup API ---
@@ -865,9 +1092,11 @@ async function handleApi(req, res, url) {
865
1092
  if (!hasApiKey && walleProvider === 'google' && (process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY)) hasApiKey = true;
866
1093
  if (!hasApiKey && walleProvider === 'deepseek' && process.env.DEEPSEEK_API_KEY) hasApiKey = true;
867
1094
  if (!hasApiKey && (walleProvider === 'ollama' || walleProvider === 'mlx')) hasApiKey = true;
868
- const authMethod = process.env.WALLE_AUTH_METHOD || '';
1095
+ const providerState = getActiveWalleProviderState(walleProvider);
1096
+ if (!hasApiKey && providerState.hasRuntimeAccess) hasApiKey = true;
1097
+ const authMethod = providerState.authMethod || '';
869
1098
  const codingViaCli = process.env.WALLE_CODING_USE_CLI === 'true';
870
- res.end(JSON.stringify({ owner_name: ownerName, has_api_key: hasApiKey, slack_connected: slackConnected, slack_team: slackTeam, needs_setup: setup.needsSetup(), version, ctm_data_dir: ctmDataDir, walle_data_dir: walleDataDir, hostname: HOSTNAME, walle_model: walleModel, walle_provider: walleProvider, auth_method: authMethod, coding_via_cli: codingViaCli, service_alerts: serviceAlerts }));
1099
+ res.end(JSON.stringify({ owner_name: ownerName, has_api_key: hasApiKey, slack_connected: slackConnected, slack_team: slackTeam, needs_setup: !hasApiKey && setup.needsSetup(), version, ctm_data_dir: ctmDataDir, walle_data_dir: walleDataDir, hostname: HOSTNAME, walle_model: walleModel, walle_provider: walleProvider, auth_method: authMethod, coding_via_cli: codingViaCli, service_alerts: serviceAlerts }));
871
1100
  return;
872
1101
  }
873
1102
  if (url.pathname === '/api/setup/test-key' && req.method === 'GET') {
@@ -1725,6 +1954,12 @@ async function handleApi(req, res, url) {
1725
1954
  const walleProvider = typeof data.provider === 'string'
1726
1955
  ? data.provider.replace(/[^a-z]/g, '').slice(0, 20)
1727
1956
  : '';
1957
+ if (walleProvider && !SETUP_PROVIDER_TYPES.includes(walleProvider)) {
1958
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1959
+ res.end(JSON.stringify({ error: 'Invalid provider type. Must be one of: ' + setupProviderTypeList() }));
1960
+ return;
1961
+ }
1962
+ const providerForApiKey = walleProvider || 'anthropic';
1728
1963
  // Anthropic auth_method picker: 'api_key' (default), 'claude_cli', or
1729
1964
  // 'oauth_proxy'. Anything else gets normalized to '' (= cleared).
1730
1965
  // OpenAI auth_method picker: 'api_key' (default) or 'codex_cli'.
@@ -1764,9 +1999,10 @@ async function handleApi(req, res, url) {
1764
1999
  keysToReplace.add('ANTHROPIC_CUSTOM_HEADERS_B64');
1765
2000
  } else if (apiKey) {
1766
2001
  // Only strip the key for the provider being configured — preserve others
1767
- if (walleProvider === 'openai') { keysToReplace.add('OPENAI_API_KEY'); }
1768
- else if (walleProvider === 'google') { keysToReplace.add('GOOGLE_API_KEY'); keysToReplace.add('GEMINI_API_KEY'); keysToReplace.add('GOOGLE_AUTH_MODE'); keysToReplace.add('GOOGLE_REFRESH_TOKEN'); }
1769
- else { keysToReplace.add('ANTHROPIC_API_KEY'); keysToReplace.add('ANTHROPIC_BASE_URL'); keysToReplace.add('ANTHROPIC_AUTH_TOKEN'); keysToReplace.add('ANTHROPIC_CUSTOM_HEADERS_B64'); }
2002
+ if (providerForApiKey === 'openai') { keysToReplace.add('OPENAI_API_KEY'); }
2003
+ else if (providerForApiKey === 'google') { keysToReplace.add('GOOGLE_API_KEY'); keysToReplace.add('GEMINI_API_KEY'); keysToReplace.add('GOOGLE_AUTH_MODE'); keysToReplace.add('GOOGLE_REFRESH_TOKEN'); }
2004
+ else if (providerForApiKey === 'deepseek') { keysToReplace.add('DEEPSEEK_API_KEY'); }
2005
+ else if (providerForApiKey === 'anthropic') { keysToReplace.add('ANTHROPIC_API_KEY'); keysToReplace.add('ANTHROPIC_BASE_URL'); keysToReplace.add('ANTHROPIC_AUTH_TOKEN'); keysToReplace.add('ANTHROPIC_CUSTOM_HEADERS_B64'); }
1770
2006
  }
1771
2007
  // Read existing .env, keep lines that aren't being replaced
1772
2008
  try {
@@ -1823,10 +2059,10 @@ async function handleApi(req, res, url) {
1823
2059
  delete process.env.ANTHROPIC_API_KEY;
1824
2060
  } else if (apiKey) {
1825
2061
  // Save key under the correct provider env var
1826
- if (walleProvider === 'openai') {
2062
+ if (providerForApiKey === 'openai') {
1827
2063
  lines.push(`OPENAI_API_KEY=${apiKey}`);
1828
2064
  process.env.OPENAI_API_KEY = apiKey;
1829
- } else if (walleProvider === 'google') {
2065
+ } else if (providerForApiKey === 'google') {
1830
2066
  const googleAuthMode = typeof data.google_auth_mode === 'string' ? data.google_auth_mode.replace(/[^a-z]/g, '') : '';
1831
2067
  const googleRefreshToken = typeof data.google_refresh_token === 'string' ? data.google_refresh_token.replace(/[\r\n]/g, '').slice(0, 2000) : '';
1832
2068
  lines.push(`GOOGLE_API_KEY=${apiKey}`);
@@ -1840,7 +2076,10 @@ async function handleApi(req, res, url) {
1840
2076
  delete process.env.GOOGLE_AUTH_MODE;
1841
2077
  delete process.env.GOOGLE_REFRESH_TOKEN;
1842
2078
  }
1843
- } else {
2079
+ } else if (providerForApiKey === 'deepseek') {
2080
+ lines.push(`DEEPSEEK_API_KEY=${apiKey}`);
2081
+ process.env.DEEPSEEK_API_KEY = apiKey;
2082
+ } else if (providerForApiKey === 'anthropic') {
1844
2083
  // Default: Anthropic
1845
2084
  lines.push(`ANTHROPIC_API_KEY=${apiKey}`);
1846
2085
  process.env.ANTHROPIC_API_KEY = apiKey;
@@ -1897,17 +2136,27 @@ async function handleApi(req, res, url) {
1897
2136
  const defaultProvider = brain.getKv('walle_provider') || 'anthropic';
1898
2137
  const defaultModel = brain.getKv('walle_model') || '';
1899
2138
  const rows = brain.listModelProviders();
1900
- const ENV_KEY_MAP = { anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY', google: 'GOOGLE_API_KEY', ollama: null };
1901
2139
  const providers = rows.map(row => {
1902
2140
  const model = brain.getKv('walle_model_' + row.type) || (row.type === defaultProvider ? defaultModel : '');
1903
- const envKey = ENV_KEY_MAP[row.type];
1904
- const hasKey = !!(row.api_key_encrypted) || (envKey ? !!process.env[envKey] : false) || row.type === 'ollama';
2141
+ const envKey = SETUP_PROVIDER_ENV_KEYS[row.type];
2142
+ let hasStoredKey = false;
2143
+ try {
2144
+ const fullRow = typeof brain.getModelProviderWithKey === 'function' ? brain.getModelProviderWithKey(row.id) : null;
2145
+ hasStoredKey = !!fullRow?.api_key_encrypted;
2146
+ } catch {}
2147
+ const authMethod = row.auth_method || 'api_key';
2148
+ const hasKey = setupProviderHasRuntimeAccess({
2149
+ type: row.type,
2150
+ env: process.env,
2151
+ authMethod,
2152
+ hasStoredKey,
2153
+ }) || (envKey ? !!process.env[envKey] : false);
1905
2154
  let status = 'unknown', lastTested = null, error = null;
1906
2155
  return {
1907
2156
  id: row.id, type: row.type, name: row.name,
1908
2157
  enabled: !!row.enabled, is_default: row.type === defaultProvider,
1909
2158
  has_key: hasKey, model: model || null,
1910
- auth_method: row.auth_method || 'api_key',
2159
+ auth_method: authMethod,
1911
2160
  status, last_tested: lastTested, error
1912
2161
  };
1913
2162
  });
@@ -1928,13 +2177,10 @@ async function handleApi(req, res, url) {
1928
2177
  req.on('end', () => {
1929
2178
  try {
1930
2179
  const data = JSON.parse(body);
1931
- const VALID_TYPES = ['anthropic', 'openai', 'google', 'ollama', 'deepseek'];
1932
- const PROVIDER_NAMES = { anthropic: 'Anthropic', openai: 'OpenAI', google: 'Google Gemini', ollama: 'Ollama (Local)', mlx: 'MLX (Local)', deepseek: 'DeepSeek' };
1933
- const ENV_VAR_MAP = { anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY', google: 'GOOGLE_API_KEY', deepseek: 'DEEPSEEK_API_KEY' };
1934
- const type = typeof data.type === 'string' ? data.type.replace(/[^a-z]/g, '').slice(0, 20) : '';
1935
- if (!VALID_TYPES.includes(type)) {
2180
+ const type = sanitizeSetupProviderType(data.type);
2181
+ if (!SETUP_PROVIDER_TYPES.includes(type)) {
1936
2182
  res.writeHead(400, { 'Content-Type': 'application/json' });
1937
- res.end(JSON.stringify({ error: 'Invalid provider type. Must be one of: ' + VALID_TYPES.join(', ') }));
2183
+ res.end(JSON.stringify({ error: 'Invalid provider type. Must be one of: ' + setupProviderTypeList() }));
1938
2184
  return;
1939
2185
  }
1940
2186
  const apiKey = typeof data.api_key === 'string' ? data.api_key.replace(/[\r\n\s]/g, '').slice(0, 200) : '';
@@ -1946,18 +2192,41 @@ async function handleApi(req, res, url) {
1946
2192
 
1947
2193
  const brain = getWalleBrain();
1948
2194
  if (brain) {
2195
+ const currentDefaultProvider = brain.getKv('walle_provider') || process.env.WALLE_PROVIDER || 'anthropic';
2196
+ const syncDefaultModel = !!(model && currentDefaultProvider === type && !setDefault);
2197
+ let existingApiKey = null;
2198
+ if (!apiKey) {
2199
+ try {
2200
+ const existingProvider = typeof brain.getModelProviderWithKey === 'function'
2201
+ ? brain.getModelProviderWithKey(type + '-default')
2202
+ : null;
2203
+ existingApiKey = existingProvider?.api_key_encrypted || null;
2204
+ if (!existingApiKey) {
2205
+ const row = brain.getDb().prepare(
2206
+ 'SELECT api_key_encrypted FROM model_providers WHERE type = ? AND enabled = 1 AND api_key_encrypted IS NOT NULL ORDER BY updated_at DESC LIMIT 1'
2207
+ ).get(type);
2208
+ existingApiKey = row?.api_key_encrypted || null;
2209
+ }
2210
+ } catch {}
2211
+ }
1949
2212
  // Upsert provider in DB
1950
2213
  brain.upsertModelProvider({
1951
2214
  id: type + '-default',
1952
- name: PROVIDER_NAMES[type] || type,
2215
+ name: SETUP_PROVIDER_NAMES[type] || type,
1953
2216
  type,
1954
2217
  baseUrl: null,
1955
- apiKeyEncrypted: apiKey || null,
2218
+ apiKeyEncrypted: apiKey || existingApiKey || null,
1956
2219
  customHeaders: null,
1957
2220
  enabled,
1958
2221
  });
1959
2222
  // Store per-provider model preference
1960
- if (model) brain.setKv('walle_model_' + type, model);
2223
+ if (model) {
2224
+ brain.setKv('walle_model_' + type, model);
2225
+ if (syncDefaultModel) {
2226
+ brain.setKv('walle_model', model);
2227
+ process.env.WALLE_MODEL = model;
2228
+ }
2229
+ }
1961
2230
  // Persist auth method (must run AFTER upsert so the row exists)
1962
2231
  if (authMethod) {
1963
2232
  try { brain.setProviderAuthMethod(type, authMethod); } catch (_) { /* invalid method silently dropped — already gated above */ }
@@ -1980,7 +2249,11 @@ async function handleApi(req, res, url) {
1980
2249
  // If set_default, update global defaults
1981
2250
  if (setDefault) {
1982
2251
  brain.setKv('walle_provider', type);
1983
- if (model) brain.setKv('walle_model', model);
2252
+ process.env.WALLE_PROVIDER = type;
2253
+ if (model) {
2254
+ brain.setKv('walle_model', model);
2255
+ process.env.WALLE_MODEL = model;
2256
+ }
1984
2257
  }
1985
2258
  }
1986
2259
 
@@ -1990,12 +2263,12 @@ async function handleApi(req, res, url) {
1990
2263
  // present — auth_method=oauth_proxy (and similar CLI methods) do
1991
2264
  // not require an API key but still need WALLE_AUTH_METHOD wired
1992
2265
  // into the daemon's env so its provider factory routes correctly.
1993
- const writesEnv = !!(apiKey && ENV_VAR_MAP[type])
2266
+ const writesEnv = !!(apiKey && SETUP_PROVIDER_ENV_KEYS[type])
1994
2267
  || (authMethod && (type === 'anthropic' || type === 'openai'))
1995
2268
  || setDefault;
1996
2269
  if (writesEnv) {
1997
2270
  const envPath = path.resolve(__dirname, '..', '.env');
1998
- const envVar = ENV_VAR_MAP[type];
2271
+ const envVar = SETUP_PROVIDER_ENV_KEYS[type];
1999
2272
  const lines = [];
2000
2273
  const keysToReplace = new Set();
2001
2274
  if (apiKey && envVar) {
@@ -2084,6 +2357,7 @@ async function handleApi(req, res, url) {
2084
2357
 
2085
2358
  // Sync model registry and restart
2086
2359
  try { seedDefaultModels(); } catch {}
2360
+ setup.clearSetupCache();
2087
2361
  walleSupervisor.restartQuiet();
2088
2362
 
2089
2363
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -2104,11 +2378,38 @@ async function handleApi(req, res, url) {
2104
2378
  req.on('end', async () => {
2105
2379
  try {
2106
2380
  const data = JSON.parse(body);
2107
- const type = typeof data.type === 'string' ? data.type.replace(/[^a-z]/g, '') : '';
2381
+ const type = sanitizeSetupProviderType(data.type);
2108
2382
  const startMs = Date.now();
2383
+ const storedProviderKey = (providerType) => {
2384
+ try {
2385
+ const brain = getWalleBrain();
2386
+ const row = brain?.getDb?.().prepare(
2387
+ 'SELECT api_key_encrypted FROM model_providers WHERE type = ? AND enabled = 1 AND api_key_encrypted IS NOT NULL ORDER BY updated_at DESC LIMIT 1'
2388
+ ).get(providerType);
2389
+ return row?.api_key_encrypted || '';
2390
+ } catch { return ''; }
2391
+ };
2109
2392
 
2110
2393
  if (type === 'anthropic') {
2111
- const key = process.env.ANTHROPIC_API_KEY;
2394
+ const runtime = getProviderRuntimeState('anthropic');
2395
+ const authMethod = typeof data.auth_method === 'string' && data.auth_method
2396
+ ? data.auth_method
2397
+ : runtime.authMethod || 'api_key';
2398
+ if (authMethod === 'claude_cli') {
2399
+ const result = await probeClaudeCliAuth();
2400
+ const latency = Date.now() - startMs;
2401
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2402
+ res.end(JSON.stringify({ ...result, latency_ms: latency, auth_method: authMethod }));
2403
+ return;
2404
+ }
2405
+ if (authMethod === 'oauth_proxy') {
2406
+ const result = await probeAnthropicOauthProxy();
2407
+ const latency = Date.now() - startMs;
2408
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2409
+ res.end(JSON.stringify({ ...result, latency_ms: latency, auth_method: authMethod }));
2410
+ return;
2411
+ }
2412
+ const key = process.env.ANTHROPIC_API_KEY || storedProviderKey('anthropic');
2112
2413
  if (!key) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'No ANTHROPIC_API_KEY configured' })); return; }
2113
2414
  const result = await probeModel(key, process.env.ANTHROPIC_BASE_URL, process.env.ANTHROPIC_CUSTOM_HEADERS_B64);
2114
2415
  const latency = Date.now() - startMs;
@@ -2123,7 +2424,7 @@ async function handleApi(req, res, url) {
2123
2424
  }
2124
2425
 
2125
2426
  if (type === 'openai') {
2126
- const key = process.env.OPENAI_API_KEY;
2427
+ const key = process.env.OPENAI_API_KEY || storedProviderKey('openai');
2127
2428
  if (!key) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'No OPENAI_API_KEY configured' })); return; }
2128
2429
  try {
2129
2430
  const resp = await fetch('https://api.openai.com/v1/chat/completions', {
@@ -2152,7 +2453,7 @@ async function handleApi(req, res, url) {
2152
2453
  }
2153
2454
 
2154
2455
  if (type === 'google') {
2155
- const key = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
2456
+ const key = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY || storedProviderKey('google');
2156
2457
  if (!key) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'No GOOGLE_API_KEY configured' })); return; }
2157
2458
  // Detect OAuth token
2158
2459
  if (key.startsWith('ya29.')) {
@@ -2186,6 +2487,36 @@ async function handleApi(req, res, url) {
2186
2487
  return;
2187
2488
  }
2188
2489
 
2490
+ if (type === 'deepseek') {
2491
+ const key = process.env.DEEPSEEK_API_KEY || storedProviderKey('deepseek');
2492
+ if (!key) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'No DEEPSEEK_API_KEY configured' })); return; }
2493
+ try {
2494
+ const resp = await fetch('https://api.deepseek.com/v1/models', {
2495
+ headers: { 'Authorization': 'Bearer ' + key },
2496
+ signal: AbortSignal.timeout(10000),
2497
+ });
2498
+ const latency = Date.now() - startMs;
2499
+ if (resp.ok) {
2500
+ const model = (process.env.WALLE_PROVIDER === 'deepseek' && /^deepseek-/.test(process.env.WALLE_MODEL || ''))
2501
+ ? process.env.WALLE_MODEL
2502
+ : 'deepseek-v4-flash';
2503
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2504
+ res.end(JSON.stringify({ ok: true, model, latency_ms: latency }));
2505
+ } else {
2506
+ const result = await resp.json().catch(() => ({}));
2507
+ let diagnosis = null;
2508
+ if (resp.status === 429) diagnosis = 'Billing quota exceeded — top up your DeepSeek account balance';
2509
+ else if (resp.status === 401 || resp.status === 403) diagnosis = 'Invalid API key';
2510
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2511
+ res.end(JSON.stringify({ ok: false, error: result.error?.message || `HTTP ${resp.status}`, diagnosis }));
2512
+ }
2513
+ } catch (e) {
2514
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2515
+ res.end(JSON.stringify({ ok: false, error: e.message }));
2516
+ }
2517
+ return;
2518
+ }
2519
+
2189
2520
  if (type === 'ollama') {
2190
2521
  try {
2191
2522
  const resp = await fetch('http://localhost:11434/api/tags', { signal: AbortSignal.timeout(5000) });
@@ -2221,22 +2552,24 @@ async function handleApi(req, res, url) {
2221
2552
  let body = '';
2222
2553
  let bodyLen = 0;
2223
2554
  req.on('data', c => { bodyLen += c.length; if (bodyLen > 8192) { req.destroy(); return; } body += c; });
2224
- req.on('end', () => {
2555
+ req.on('end', async () => {
2225
2556
  try {
2226
2557
  const data = JSON.parse(body);
2227
- const VALID_TYPES = ['anthropic', 'openai', 'google', 'ollama'];
2228
- const type = typeof data.type === 'string' ? data.type.replace(/[^a-z]/g, '').slice(0, 20) : '';
2229
- if (!VALID_TYPES.includes(type)) {
2558
+ const brain = getWalleBrain();
2559
+ const type = sanitizeSetupProviderType(data.type);
2560
+ const storedModel = brain ? brain.getKv('walle_model_' + type) : '';
2561
+ const selection = resolveSetupDefaultSelection({ type, model: data.model, storedModel });
2562
+ if (!selection.ok) {
2230
2563
  res.writeHead(400, { 'Content-Type': 'application/json' });
2231
- res.end(JSON.stringify({ error: 'Invalid provider type' }));
2564
+ res.end(JSON.stringify({ error: selection.error }));
2232
2565
  return;
2233
2566
  }
2234
2567
 
2235
- const brain = getWalleBrain();
2568
+ const { requestedModel, targetModel } = selection;
2236
2569
  if (brain) {
2237
2570
  brain.setKv('walle_provider', type);
2238
- const perProviderModel = brain.getKv('walle_model_' + type);
2239
- if (perProviderModel) brain.setKv('walle_model', perProviderModel);
2571
+ if (requestedModel) brain.setKv('walle_model_' + type, requestedModel);
2572
+ brain.setKv('walle_model', targetModel || '');
2240
2573
  }
2241
2574
 
2242
2575
  // Update .env
@@ -2255,16 +2588,26 @@ async function handleApi(req, res, url) {
2255
2588
  lines.push('');
2256
2589
  lines.push(`WALLE_PROVIDER=${type}`);
2257
2590
  process.env.WALLE_PROVIDER = type;
2258
- const perModel = brain ? brain.getKv('walle_model_' + type) : null;
2259
- if (perModel) {
2260
- lines.push(`WALLE_MODEL=${perModel}`);
2261
- process.env.WALLE_MODEL = perModel;
2591
+ if (targetModel) {
2592
+ lines.push(`WALLE_MODEL=${targetModel}`);
2593
+ process.env.WALLE_MODEL = targetModel;
2594
+ } else {
2595
+ delete process.env.WALLE_MODEL;
2262
2596
  }
2263
2597
  atomicWriteFileSync(envPath, lines.join('\n') + '\n', { mode: 0o600 });
2264
2598
 
2265
- walleSupervisor.restartQuiet();
2599
+ setup.clearSetupCache();
2600
+ let liveUpdated = false;
2601
+ try {
2602
+ const upstream = await walleClient.requestJson('/api/wall-e/setup/config', {
2603
+ method: 'POST',
2604
+ body: { provider: type, model: targetModel || '' },
2605
+ });
2606
+ liveUpdated = upstream.status < 400 && upstream.json && upstream.json.ok !== false;
2607
+ } catch (_) {}
2608
+ if (!liveUpdated) walleSupervisor.restartQuiet();
2266
2609
  res.writeHead(200, { 'Content-Type': 'application/json' });
2267
- res.end(JSON.stringify({ ok: true }));
2610
+ res.end(JSON.stringify({ ok: true, live_updated: liveUpdated, restart_scheduled: !liveUpdated }));
2268
2611
  } catch (e) {
2269
2612
  res.writeHead(400, { 'Content-Type': 'application/json' });
2270
2613
  res.end(JSON.stringify({ error: e.message }));
@@ -2725,7 +3068,7 @@ async function handleApi(req, res, url) {
2725
3068
  // --- Session Stream API ---
2726
3069
  if (url.pathname === '/api/stream/status' && req.method === 'GET') {
2727
3070
  res.writeHead(200, { 'Content-Type': 'application/json' });
2728
- return res.end(JSON.stringify({ sessions: _sessionStream ? _sessionStream.getAllStatuses() : [] }));
3071
+ return res.end(JSON.stringify({ sessions: _sessionCapture ? _sessionCapture.getAllStatuses() : _sessionStream ? _sessionStream.getAllStatuses() : [] }));
2729
3072
  }
2730
3073
  if (url.pathname.startsWith('/api/sessions/') && url.pathname.endsWith('/stream') && req.method === 'GET') {
2731
3074
  const parts = url.pathname.split('/');
@@ -2739,7 +3082,7 @@ async function handleApi(req, res, url) {
2739
3082
  let agentId = sessionId;
2740
3083
  const ctmSession = sessions.get(sessionId);
2741
3084
  if (ctmSession && ctmSession._claudeSessionId) agentId = ctmSession._claudeSessionId;
2742
- const events = _sessionStream.getRecentEvents(agentId, limit);
3085
+ const events = _sessionCapture ? _sessionCapture.getRecentEvents(agentId, limit) : _sessionStream.getRecentEvents(agentId, limit);
2743
3086
  res.writeHead(200, { 'Content-Type': 'application/json' });
2744
3087
  return res.end(JSON.stringify(events));
2745
3088
  }
@@ -2754,7 +3097,7 @@ async function handleApi(req, res, url) {
2754
3097
  let agentId = sessionId;
2755
3098
  const ctmSession = sessions.get(sessionId);
2756
3099
  if (ctmSession && ctmSession._claudeSessionId) agentId = ctmSession._claudeSessionId;
2757
- let summary = _sessionStream.getSummary(agentId, turns);
3100
+ let summary = _sessionCapture ? _sessionCapture.getSummary(agentId, turns) : _sessionStream.getSummary(agentId, turns);
2758
3101
  // Fallback: if SessionStream doesn't track this session, try DB directly
2759
3102
  if (!summary) {
2760
3103
  try {
@@ -3184,6 +3527,22 @@ function isSyntheticModelName(model) {
3184
3527
  return typeof model === 'string' && /^<[^>]+>$/.test(model.trim());
3185
3528
  }
3186
3529
 
3530
+ function inferModelProviderFromId(model) {
3531
+ if (!model || isSyntheticModelName(model)) return '';
3532
+ if (model.startsWith('claude-')) return 'anthropic';
3533
+ if (model.startsWith('codex-')) return 'openai';
3534
+ if (model.startsWith('gemini-')) return 'google';
3535
+ if (model.startsWith('deepseek-')) return 'deepseek';
3536
+ if (model.startsWith('gpt-') || model.startsWith('o1-') || model.startsWith('o3-') || model.startsWith('o4-')) return 'openai';
3537
+ return 'unknown';
3538
+ }
3539
+
3540
+ function sessionMessageText(messageOrContent) {
3541
+ if (!messageOrContent) return '';
3542
+ const content = messageOrContent.content !== undefined ? messageOrContent.content : messageOrContent;
3543
+ return walleTranscript.extractText(content);
3544
+ }
3545
+
3187
3546
  function statSessionFileInfo(filePath) {
3188
3547
  if (!filePath) return { filePath: '', fileSize: 0, modifiedAt: '' };
3189
3548
  try {
@@ -3210,6 +3569,11 @@ function dbRowFileInfo(row) {
3210
3569
 
3211
3570
  // Helper: parse a session JSONL file for metadata
3212
3571
  function parseSessionFile(filePath, projectPath, projectEntry) {
3572
+ if (projectEntry === claudeDesktopSessions.DESKTOP_PROJECT_ENTRY ||
3573
+ claudeDesktopSessions.isVirtualSessionPath(filePath)) {
3574
+ return claudeDesktopSessions.parseSessionFile(filePath, projectPath, projectEntry);
3575
+ }
3576
+
3213
3577
  const fileStat = fs.statSync(filePath);
3214
3578
  const modifiedAt = fileStat.mtime.toISOString();
3215
3579
  const sessionId = path.basename(filePath).replace(/\.jsonl(\.bak)?$/, '');
@@ -3239,24 +3603,37 @@ function parseSessionFile(filePath, projectPath, projectEntry) {
3239
3603
  let version = '';
3240
3604
  let gitBranch = '';
3241
3605
  let sessionModel = '';
3606
+ let modelProvider = '';
3607
+ let agent = 'claude';
3242
3608
  let userMsgCount = 0;
3243
3609
  let allUserMessages = [];
3244
3610
 
3245
3611
  for (const line of lines) {
3246
3612
  try {
3247
3613
  const entry = JSON.parse(line);
3614
+ const isWalle = entry.provider === 'walle' || entry.type === 'walle_part';
3615
+ if (isWalle) agent = 'walle';
3616
+ if (entry.type === 'session_meta' && isWalle) {
3617
+ sessionCwd = entry.cwd || sessionCwd;
3618
+ timestamp = entry.timestamp || timestamp;
3619
+ version = entry.version || version;
3620
+ gitBranch = entry.gitBranch || gitBranch;
3621
+ if (!sessionModel && entry.modelId && !isSyntheticModelName(entry.modelId)) {
3622
+ sessionModel = entry.modelId;
3623
+ modelProvider = entry.modelProvider || inferModelProviderFromId(entry.modelId);
3624
+ } else if (!modelProvider && entry.modelProvider) {
3625
+ modelProvider = entry.modelProvider;
3626
+ }
3627
+ continue;
3628
+ }
3248
3629
  // Extract model from assistant messages (stored in message.model)
3249
- const entryModel = entry.type === 'assistant' ? (entry.message?.model || entry.model || '') : '';
3630
+ const entryModel = entry.type === 'assistant' ? (entry.message?.model || entry.model || entry.modelId || '') : '';
3250
3631
  if (!sessionModel && entryModel && !isSyntheticModelName(entryModel)) {
3251
3632
  sessionModel = entryModel;
3633
+ modelProvider = entry.modelProvider || inferModelProviderFromId(entryModel);
3252
3634
  }
3253
3635
  if (entry.type === 'user' && entry.message?.role === 'user') {
3254
- const content = entry.message.content;
3255
- const text = typeof content === 'string'
3256
- ? content
3257
- : Array.isArray(content)
3258
- ? (content.find(c => c.type === 'text')?.text || '')
3259
- : '';
3636
+ const text = sessionMessageText(entry.message);
3260
3637
  userMsgCount++;
3261
3638
  const isArtifact = /^\[(?:Request interrupted|Tool use|Error|Retrying)/.test(text);
3262
3639
  if (text && !isArtifact) allUserMessages.push(text.slice(0, 200));
@@ -3267,6 +3644,17 @@ function parseSessionFile(filePath, projectPath, projectEntry) {
3267
3644
  version = entry.version || version;
3268
3645
  gitBranch = entry.gitBranch || gitBranch;
3269
3646
  }
3647
+ } else if (entry.type === 'user' && entry.provider === 'walle' && typeof entry.content === 'string') {
3648
+ const text = entry.content;
3649
+ userMsgCount++;
3650
+ if (text) allUserMessages.push(text.slice(0, 200));
3651
+ if (!firstUserMessage && text) {
3652
+ firstUserMessage = text.slice(0, 200);
3653
+ sessionCwd = entry.cwd || sessionCwd;
3654
+ timestamp = entry.timestamp || timestamp;
3655
+ version = entry.version || version;
3656
+ gitBranch = entry.gitBranch || gitBranch;
3657
+ }
3270
3658
  }
3271
3659
  } catch {
3272
3660
  // JSON.parse failed — line may be truncated (e.g. 1MB+ image block cut off
@@ -3326,6 +3714,9 @@ function parseSessionFile(filePath, projectPath, projectEntry) {
3326
3714
  gitBranch,
3327
3715
  fileSize: fileStat.size,
3328
3716
  model: sessionModel,
3717
+ modelProvider,
3718
+ agent,
3719
+ jsonlPath: filePath,
3329
3720
  };
3330
3721
  }
3331
3722
 
@@ -3334,30 +3725,49 @@ function getAllSessionFiles() {
3334
3725
  const claudeProjectsDir = path.join(process.env.HOME, '.claude', 'projects');
3335
3726
  const results = [];
3336
3727
 
3337
- if (!fs.existsSync(claudeProjectsDir)) return results;
3338
-
3339
- for (const projectEntry of fs.readdirSync(claudeProjectsDir)) {
3340
- const projectDir = path.join(claudeProjectsDir, projectEntry);
3341
- let stat;
3342
- try { stat = fs.statSync(projectDir); } catch { continue; }
3343
- if (!stat.isDirectory()) continue;
3728
+ if (fs.existsSync(claudeProjectsDir)) {
3729
+ for (const projectEntry of fs.readdirSync(claudeProjectsDir)) {
3730
+ const projectDir = path.join(claudeProjectsDir, projectEntry);
3731
+ let stat;
3732
+ try { stat = fs.statSync(projectDir); } catch { continue; }
3733
+ if (!stat.isDirectory()) continue;
3344
3734
 
3345
- const projectPath = decodeProjectEntry(projectEntry);
3346
- let files;
3347
- try { files = fs.readdirSync(projectDir); } catch { continue; }
3735
+ const projectPath = decodeProjectEntry(projectEntry);
3736
+ let files;
3737
+ try { files = fs.readdirSync(projectDir); } catch { continue; }
3348
3738
 
3349
- const fileSet = new Set(files);
3350
- for (const file of files) {
3351
- if (!file.endsWith('.jsonl') && !file.endsWith('.jsonl.bak')) continue;
3352
- // Skip .jsonl.bak when the .jsonl version exists (Claude Code creates
3353
- // .bak on session migration/compaction — showing both causes duplicates)
3354
- if (file.endsWith('.jsonl.bak') && fileSet.has(file.replace(/\.bak$/, ''))) continue;
3355
- const filePath = path.join(projectDir, file);
3356
- // Extract session ID: strip .jsonl or .jsonl.bak
3357
- const sessionId = file.replace(/\.jsonl(\.bak)?$/, '');
3358
- results.push({ filePath, projectPath, projectEntry, sessionId });
3739
+ const fileSet = new Set(files);
3740
+ for (const file of files) {
3741
+ if (!file.endsWith('.jsonl') && !file.endsWith('.jsonl.bak')) continue;
3742
+ // Skip .jsonl.bak when the .jsonl version exists (Claude Code creates
3743
+ // .bak on session migration/compaction — showing both causes duplicates)
3744
+ if (file.endsWith('.jsonl.bak') && fileSet.has(file.replace(/\.bak$/, ''))) continue;
3745
+ const filePath = path.join(projectDir, file);
3746
+ // Extract session ID: strip .jsonl or .jsonl.bak
3747
+ const sessionId = file.replace(/\.jsonl(\.bak)?$/, '');
3748
+ results.push({ filePath, projectPath, projectEntry, sessionId });
3749
+ }
3359
3750
  }
3360
3751
  }
3752
+
3753
+ let walleFiles;
3754
+ try { walleFiles = fs.readdirSync(WALLE_SESSIONS_DIR); } catch { walleFiles = []; }
3755
+ const walleFileSet = new Set(walleFiles);
3756
+ for (const file of walleFiles) {
3757
+ if (!file.endsWith('.jsonl') && !file.endsWith('.jsonl.bak')) continue;
3758
+ if (file.endsWith('.jsonl.bak') && walleFileSet.has(file.replace(/\.bak$/, ''))) continue;
3759
+ const filePath = path.join(WALLE_SESSIONS_DIR, file);
3760
+ let stat;
3761
+ try { stat = fs.statSync(filePath); } catch { continue; }
3762
+ if (!stat.isFile()) continue;
3763
+ results.push({
3764
+ filePath,
3765
+ projectPath: WALLE_SESSIONS_DIR,
3766
+ projectEntry: walleTranscript.WALLE_PROJECT_ENTRY,
3767
+ sessionId: file.replace(/\.jsonl(\.bak)?$/, ''),
3768
+ });
3769
+ }
3770
+ for (const entry of claudeDesktopSessions.listSessionFileEntries()) results.push(entry);
3361
3771
  return results;
3362
3772
  }
3363
3773
 
@@ -3496,8 +3906,9 @@ function apiRecentSessions(req, res, url) {
3496
3906
  const data = {
3497
3907
  projectEntry: s.projectEntry, projectPath: s.project, cwd: s.cwd,
3498
3908
  label: s.title, title: s.title, firstMessage: s.firstMessage,
3499
- jsonlPath: s.projectEntry ? path.join(process.env.HOME, '.claude', 'projects', s.projectEntry, `${s.sessionId}.jsonl`) : '',
3909
+ jsonlPath: s.jsonlPath || (s.projectEntry && s.agent !== 'walle' && s.agent !== claudeDesktopSessions.DESKTOP_AGENT ? path.join(process.env.HOME, '.claude', 'projects', s.projectEntry, `${s.sessionId}.jsonl`) : ''),
3500
3910
  fileSize: s.fileSize, modifiedAt: s.modifiedAt, hostname: HOSTNAME,
3911
+ createdAt: s.timestamp || '',
3501
3912
  slug: s.slug || '',
3502
3913
  };
3503
3914
  dbModule.upsertSessionIndex(s.sessionId, s.sessionId, data);
@@ -3726,6 +4137,19 @@ function apiSessionMessages(req, res, url) {
3726
4137
  return;
3727
4138
  }
3728
4139
 
4140
+ if (projectEntry === claudeDesktopSessions.DESKTOP_PROJECT_ENTRY || claudeDesktopSessions.getSession(sessionId)) {
4141
+ const messages = claudeDesktopSessions.getMessages(sessionId, { includeMetadataNote: true });
4142
+ if (messages) {
4143
+ res.writeHead(200, { 'Content-Type': 'application/json' });
4144
+ if (paginated) {
4145
+ res.end(JSON.stringify(_paginateMessages(messages, offset, limit)));
4146
+ } else {
4147
+ res.end(JSON.stringify(messages));
4148
+ }
4149
+ return;
4150
+ }
4151
+ }
4152
+
3729
4153
  // --- Step 1: DB cache fast path (covers ALL agent sessions for this CTM session) ---
3730
4154
  if (!noCache) {
3731
4155
  try {
@@ -3778,39 +4202,55 @@ function apiSessionMessages(req, res, url) {
3778
4202
  }).filter(Boolean).sort((a, b) => a.birthtimeMs - b.birthtimeMs);
3779
4203
 
3780
4204
  let bytesRead = 0;
4205
+ const codexSeenUsers = new Set();
4206
+ const codexParsedFiles = [];
3781
4207
  const SMALL_FILE_THRESHOLD = 1024 * 1024; // 1MB — always read small files (subagents)
3782
4208
  for (const { fp, size } of filesWithTime) {
3783
4209
  const isCodexRollout = fp.includes(`${path.sep}.codex${path.sep}sessions${path.sep}`);
3784
- if (bytesRead > 0 && bytesRead + size > MAX_SYNC_SIZE && size > SMALL_FILE_THRESHOLD) {
4210
+ if (!isCodexRollout && bytesRead > 0 && bytesRead + size > MAX_SYNC_SIZE && size > SMALL_FILE_THRESHOLD) {
3785
4211
  // Budget exceeded — skip large files, but keep going for small subagent files
3786
4212
  partialLoad = true;
3787
4213
  continue; // continue instead of break — small files after this will still be read
3788
4214
  }
3789
4215
  try {
3790
- // For very large individual files (>50MB), read only the last 10MB
3791
- // to show the most recent conversation without blocking the event loop
3792
4216
  const LARGE_FILE_TAIL = 50 * 1024 * 1024;
3793
4217
  const TAIL_READ = 10 * 1024 * 1024;
3794
- if (size > LARGE_FILE_TAIL) {
4218
+ let fileBytesRead = 0;
4219
+ if (isCodexRollout) {
4220
+ // Codex rollouts are large because they include tool/event payloads.
4221
+ // The semantic user/assistant transcript is much smaller, so stream
4222
+ // the whole JSONL file and keep old eval output visible after restart.
4223
+ const before = messages.length;
4224
+ const parsed = parseCodexJsonlFileIntoMessages(fp, messages, { seenUsers: codexSeenUsers });
4225
+ fileBytesRead = parsed.bytesRead || size;
4226
+ codexParsedFiles.push({
4227
+ fp,
4228
+ size,
4229
+ parsed,
4230
+ messages: messages.slice(before),
4231
+ });
4232
+ } else if (size > LARGE_FILE_TAIL) {
4233
+ // For very large individual files (>50MB), read only the last 10MB
4234
+ // to show the most recent conversation without blocking the event loop
3795
4235
  const fd = fs.openSync(fp, 'r');
3796
4236
  try {
3797
4237
  const readSize = Math.min(size, TAIL_READ);
3798
4238
  const buf = Buffer.alloc(readSize);
3799
4239
  fs.readSync(fd, buf, 0, readSize, size - readSize);
4240
+ fileBytesRead = readSize;
3800
4241
  let content = buf.toString('utf8');
3801
4242
  // Trim to first complete line (the partial first line from offset read)
3802
4243
  const nlIdx = content.indexOf('\n');
3803
4244
  if (nlIdx > 0) content = content.slice(nlIdx + 1);
3804
- if (isCodexRollout) parseCodexJsonlIntoMessages(content, messages);
3805
- else _parseJsonlIntoMessages(content, messages);
4245
+ _parseJsonlIntoMessages(content, messages);
3806
4246
  partialLoad = true; // Signal that we didn't load the full file
3807
4247
  } finally { fs.closeSync(fd); }
3808
4248
  } else {
3809
4249
  const content = fs.readFileSync(fp, 'utf8');
3810
- if (isCodexRollout) parseCodexJsonlIntoMessages(content, messages);
3811
- else _parseJsonlIntoMessages(content, messages);
4250
+ fileBytesRead = size;
4251
+ _parseJsonlIntoMessages(content, messages);
3812
4252
  }
3813
- bytesRead += Math.min(size, LARGE_FILE_TAIL);
4253
+ bytesRead += fileBytesRead;
3814
4254
  } catch (e) { console.error(`[session-messages] error reading ${path.basename(fp).slice(0, 8)}:`, e.message); }
3815
4255
  }
3816
4256
 
@@ -3820,6 +4260,10 @@ function apiSessionMessages(req, res, url) {
3820
4260
  // Sort all messages by timestamp across files
3821
4261
  messages.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
3822
4262
 
4263
+ if (codexParsedFiles.length > 0) {
4264
+ _cacheParsedCodexMessages(sessionId, codexParsedFiles);
4265
+ }
4266
+
3823
4267
  if (paginated) {
3824
4268
  const page = _paginateMessages(messages, offset, limit);
3825
4269
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -3846,6 +4290,75 @@ function apiSessionMessages(req, res, url) {
3846
4290
  }
3847
4291
  }
3848
4292
 
4293
+ function _codexAgentIdFromRolloutPath(filePath) {
4294
+ const base = path.basename(filePath || '');
4295
+ const m = base.match(/-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
4296
+ return m ? m[1] : '';
4297
+ }
4298
+
4299
+ function _cacheParsedCodexMessages(ctmSessionId, parsedFiles) {
4300
+ for (const item of parsedFiles || []) {
4301
+ const meta = item.parsed?.sessionMeta || {};
4302
+ const agentSessionId = meta.id || _codexAgentIdFromRolloutPath(item.fp);
4303
+ const fileMessages = Array.isArray(item.messages) ? item.messages : [];
4304
+ if (!agentSessionId || fileMessages.length === 0) continue;
4305
+
4306
+ const users = fileMessages.filter(m => m.role === 'user' && m.text);
4307
+ const assistants = fileMessages.filter(m => m.role === 'assistant' && m.text);
4308
+ const firstUser = users[0]?.text || '';
4309
+ const lastUser = users[users.length - 1]?.text || '';
4310
+ const firstAssistant = assistants[0]?.text || '';
4311
+ const title = firstUser
4312
+ ? firstUser.split('\n')[0].replace(/^#+\s*/, '').replace(/[*_`]/g, '').trim().slice(0, 80)
4313
+ : '';
4314
+
4315
+ try {
4316
+ dbModule.importSessionConversation({
4317
+ session_id: agentSessionId,
4318
+ project_path: meta.cwd || '',
4319
+ messages: fileMessages,
4320
+ user_msg_count: users.length,
4321
+ assistant_msg_count: assistants.length,
4322
+ title,
4323
+ first_message: firstUser.slice(0, 500),
4324
+ last_user_content: lastUser.slice(0, 500),
4325
+ first_assistant_text: firstAssistant.slice(0, 500),
4326
+ git_branch: meta.git_branch || '',
4327
+ file_size: item.size || item.parsed?.bytesRead || 0,
4328
+ session_created_at: meta.timestamp || fileMessages[0]?.timestamp || '',
4329
+ hostname: os.hostname(),
4330
+ model_provider: meta.model ? 'openai' : '',
4331
+ model_id: meta.model || '',
4332
+ });
4333
+ } catch (e) {
4334
+ console.error(`[session-messages] Codex cache write failed for ${agentSessionId.slice(0, 8)}:`, e.message);
4335
+ }
4336
+
4337
+ try {
4338
+ const st = fs.statSync(item.fp);
4339
+ const modifiedAt = st.mtime ? st.mtime.toISOString() : '';
4340
+ const projectDir = path.dirname(item.fp);
4341
+ dbModule.updateStartupTaskAgentSession(ctmSessionId, agentSessionId, projectDir);
4342
+ dbModule.upsertSession(ctmSessionId, {
4343
+ agentSessionId,
4344
+ provider: 'codex',
4345
+ cwd: meta.cwd || '',
4346
+ projectPath: meta.cwd || '',
4347
+ title,
4348
+ jsonlPath: item.fp,
4349
+ fileSize: item.size || st.size || item.parsed?.bytesRead || 0,
4350
+ modifiedAt,
4351
+ model: meta.model || '',
4352
+ gitBranch: meta.git_branch || '',
4353
+ hostname: os.hostname(),
4354
+ userMsgCount: users.length,
4355
+ });
4356
+ } catch (e) {
4357
+ console.error(`[session-messages] Codex index update failed for ${agentSessionId.slice(0, 8)}:`, e.message);
4358
+ }
4359
+ }
4360
+ }
4361
+
3849
4362
  /**
3850
4363
  * GET /api/session/export?id=<sessionId>&format=<md|markdown|json|html>
3851
4364
  *
@@ -3875,6 +4388,35 @@ function apiSessionExport(req, res, url) {
3875
4388
  return;
3876
4389
  }
3877
4390
 
4391
+ const desktopSession = claudeDesktopSessions.getSession(sessionId);
4392
+ if (desktopSession) {
4393
+ const messages = claudeDesktopSessions.getMessages(sessionId, { includeMetadataNote: true }) || [];
4394
+ const meta = {
4395
+ sessionName: desktopSession.title || desktopSession.name || desktopSession.summary || sessionId,
4396
+ sessionId,
4397
+ projectPath: claudeDesktopSessions.DESKTOP_PROJECT_PATH,
4398
+ createdAt: desktopSession.createdAt || desktopSession.updatedAt || '',
4399
+ };
4400
+ let body;
4401
+ switch (formatInfo.ext) {
4402
+ case 'md': body = _msgExport.toMarkdown(messages, meta); break;
4403
+ case 'json': body = _msgExport.toJson(messages, meta); break;
4404
+ case 'html': body = _msgExport.toHtml(messages, meta); break;
4405
+ default:
4406
+ res.writeHead(500, { 'Content-Type': 'application/json' });
4407
+ res.end(JSON.stringify({ error: 'Internal format error' }));
4408
+ return;
4409
+ }
4410
+ const filename = _msgExport.sanitizeFilename(meta.sessionName) + '.' + formatInfo.ext;
4411
+ res.writeHead(200, {
4412
+ 'Content-Type': formatInfo.contentType,
4413
+ 'Content-Disposition': `attachment; filename="${filename}"`,
4414
+ 'Cache-Control': 'no-store',
4415
+ });
4416
+ res.end(body);
4417
+ return;
4418
+ }
4419
+
3878
4420
  // Load messages — cache fast path, then JSONL fallback (kept to the
3879
4421
  // same logic as apiSessionMessages so export == display).
3880
4422
  let messages = null;
@@ -3950,7 +4492,11 @@ function _computeExpectedFileSize(agentSessionId, dbFileSize) {
3950
4492
  'SELECT jsonl_path FROM agent_sessions WHERE agent_session_id = ?'
3951
4493
  ).get(agentSessionId);
3952
4494
  if (row && row.jsonl_path) {
3953
- // dbFileSize tracks the live .jsonl; add the .bak when it exists.
4495
+ // Prefer the live stat over agent_sessions.file_size. The DB value can
4496
+ // lag an active Codex rollout, and a stale expected size would make the
4497
+ // conversation cache look fresh after new output was appended.
4498
+ try { total = fs.statSync(row.jsonl_path).size; } catch {}
4499
+ // dbFileSize/live stat tracks the live .jsonl; add the .bak when it exists.
3954
4500
  try { total += fs.statSync(row.jsonl_path + '.bak').size; } catch {}
3955
4501
  }
3956
4502
  } catch {}
@@ -4044,7 +4590,14 @@ function _resolveAllSessionFiles(sessionId, projectEntry) {
4044
4590
  const codexIds = new Set([sessionId]);
4045
4591
 
4046
4592
  // 1. Try direct file from projectEntry — include both .jsonl and .bak.
4047
- if (projectEntry && PROJECT_ENTRY_RE.test(projectEntry)) {
4593
+ if (projectEntry === walleTranscript.WALLE_PROJECT_ENTRY) {
4594
+ const walleRoot = path.resolve(WALLE_SESSIONS_DIR);
4595
+ const basePath = path.resolve(walleRoot, `${sessionId}.jsonl`);
4596
+ if (basePath.startsWith(walleRoot + path.sep)) {
4597
+ if (fs.existsSync(basePath)) filePaths.add(basePath);
4598
+ if (fs.existsSync(basePath + '.bak')) filePaths.add(basePath + '.bak');
4599
+ }
4600
+ } else if (projectEntry && PROJECT_ENTRY_RE.test(projectEntry)) {
4048
4601
  const basePath = path.resolve(claudeProjectsDir, projectEntry, `${sessionId}.jsonl`);
4049
4602
  if (basePath.startsWith(claudeProjectsDir + '/')) {
4050
4603
  if (fs.existsSync(basePath)) filePaths.add(basePath);
@@ -4284,7 +4837,7 @@ function _materializeRestoredSessionIndex(session) {
4284
4837
  const fileInfo = _agentSessionFileInfo(agentType, agentSessionId, session._claudeProjectDir || '');
4285
4838
  let existingTitle = null;
4286
4839
  try { existingTitle = dbModule.getSessionTitleNew(session.id); } catch {}
4287
- const title = existingTitle?.title || session.label || '';
4840
+ const title = existingTitle?.userRenamed ? existingTitle.title : (session.label || existingTitle?.title || '');
4288
4841
 
4289
4842
  try {
4290
4843
  dbModule.upsertSession(session.id, {
@@ -4423,9 +4976,11 @@ function _linkCodexThreadToSession(ctmSessionId, liveSession, thread, source) {
4423
4976
  const title = _codexThreadTitle(thread);
4424
4977
  try {
4425
4978
  const titleInfo = dbModule.getSessionTitleNew(ctmSessionId);
4426
- if (title && !(titleInfo && titleInfo.userRenamed)) {
4979
+ if (title && shouldApplyCodexAutoTitle({ session: liveSession, existingTitle: titleInfo })) {
4427
4980
  liveSession.label = `Codex: ${title}`;
4428
4981
  liveSession._codexTitleSynced = true;
4982
+ } else if (title) {
4983
+ liveSession._codexTitleSynced = true;
4429
4984
  }
4430
4985
  } catch {}
4431
4986
 
@@ -4555,6 +5110,8 @@ function _parseJsonlIntoMessages(content, messages) {
4555
5110
  const cleaned = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
4556
5111
  messages.push({ role: 'user', text: cleaned || text, timestamp: entry.timestamp });
4557
5112
  }
5113
+ } else if (entry.type === 'user' && entry.provider === 'walle' && typeof entry.content === 'string') {
5114
+ messages.push({ role: 'user', text: entry.content, timestamp: entry.timestamp });
4558
5115
  } else if (entry.type === 'assistant' && entry.message?.role === 'assistant') {
4559
5116
  const c = entry.message.content;
4560
5117
  if (!Array.isArray(c)) continue;
@@ -4575,6 +5132,22 @@ function _parseJsonlIntoMessages(content, messages) {
4575
5132
  messages.push({ role: 'assistant', text: parts.join('\n'), timestamp: entry.timestamp, _parent: entry.parentUuid });
4576
5133
  }
4577
5134
  }
5135
+ } else if (entry.type === 'assistant' && entry.provider === 'walle' && typeof entry.content === 'string') {
5136
+ if (entry.content) messages.push({ role: 'assistant', text: entry.content, timestamp: entry.timestamp });
5137
+ } else if (entry.type === 'walle_part') {
5138
+ const data = entry.data || {};
5139
+ let text = '';
5140
+ if (entry.partType === 'tool_call') {
5141
+ text = `[Tool: ${data.name || 'tool'}]`;
5142
+ } else if (entry.partType === 'tool_result') {
5143
+ const result = typeof data.result === 'string' ? data.result : '';
5144
+ text = `[Tool result: ${data.name || 'tool'}]${result ? '\n' + result : ''}`;
5145
+ } else if (entry.partType === 'error') {
5146
+ text = `[Wall-E error] ${data.message || ''}`.trim();
5147
+ } else if (entry.partType === 'cancelled') {
5148
+ text = '[Wall-E cancelled]';
5149
+ }
5150
+ if (text) messages.push({ role: 'system', text, timestamp: entry.timestamp });
4578
5151
  }
4579
5152
  } catch { /* skip malformed line */ }
4580
5153
  }
@@ -4836,7 +5409,7 @@ function apiCleanEmptySessions(req, res) {
4836
5409
  if (row.first_message) continue;
4837
5410
  if ((row.user_msg_count || 0) > 0) continue;
4838
5411
  // Skip if a JSONL file still exists on disk for this row
4839
- if (row.jsonl_path && fs.existsSync(row.jsonl_path)) continue;
5412
+ if (row.jsonl_path && fs.existsSync(claudeDesktopSessions.sourcePathForStat(row.jsonl_path))) continue;
4840
5413
  // Skip active sessions (currently running)
4841
5414
  if (sessions.has(row.id)) continue;
4842
5415
  // Safe to delete — empty DB row with no disk backing and not active
@@ -5875,13 +6448,14 @@ wss.on('connection', (ws, req) => {
5875
6448
  if (ws._streamSubscriptions.has(agentId)) return;
5876
6449
  // Send initial batch — include ctmSessionId so the client can find the
5877
6450
  // correct DOM element (conversation-view is keyed by CTM ID, not agent ID)
5878
- const initEvents = _sessionStream.getRecentEvents(agentId, 100);
6451
+ const initEvents = _sessionCapture ? _sessionCapture.getRecentEvents(agentId, 100) : _sessionStream.getRecentEvents(agentId, 100);
5879
6452
  if (ws.readyState === 1) {
5880
6453
  ws.send(JSON.stringify({ type: 'stream-init', sessionId: agentId, ctmSessionId: msg.sessionId, events: initEvents }));
5881
6454
  }
5882
6455
  // Subscribe to live events — include ctmSessionId for DOM lookup
5883
6456
  const _ctmIdForStream = msg.sessionId;
5884
- const unsub = _sessionStream.subscribe(agentId, (evt) => {
6457
+ const streamApi = _sessionCapture || _sessionStream;
6458
+ const unsub = streamApi.subscribe(agentId, (evt) => {
5885
6459
  if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'stream-event', ctmSessionId: _ctmIdForStream, ...evt }));
5886
6460
  });
5887
6461
  ws._streamSubscriptions.set(agentId, unsub);
@@ -6104,12 +6678,19 @@ function _approverState(buf) {
6104
6678
  }
6105
6679
 
6106
6680
  function broadcastToSession(sessionId, session, data) {
6681
+ _recordCaptureSignal(data);
6107
6682
  const payload = JSON.stringify(data);
6108
6683
  for (const client of session.clients) {
6109
6684
  if (client.readyState === 1) client.send(payload);
6110
6685
  }
6111
6686
  }
6112
6687
 
6688
+ function _recordCaptureSignal(data) {
6689
+ if (!_sessionCapture || !data || typeof data !== 'object') return;
6690
+ if (data.type === 'stream-status') return;
6691
+ try { _sessionCapture.ingestServerMessage(data); } catch (e) { /* capture must never block UI broadcasts */ }
6692
+ }
6693
+
6113
6694
  function _broadcastApproverState(sessionId, state) {
6114
6695
  const session = sessions.get(sessionId);
6115
6696
  if (!session) return;
@@ -6412,6 +6993,27 @@ const IDLE_PROMPT_PATTERNS = [
6412
6993
  ];
6413
6994
  const IDLE_NOTIFY_DELAY_MS = 5000; // Wait 5s of silence after prompt marker
6414
6995
 
6996
+ function _markServerSessionResumedFromPty(sessionId, session, source) {
6997
+ const now = Date.now();
6998
+ const st = idleNotifyState.get(sessionId);
6999
+ if (st) {
7000
+ if (st.timer) { clearTimeout(st.timer); st.timer = null; }
7001
+ st.notified = false;
7002
+ st.buf = '';
7003
+ st.freshBuf = '';
7004
+ st.lastOutput = now;
7005
+ st._lastCheck = now;
7006
+ }
7007
+ if (session) {
7008
+ session._waitingForInput = false;
7009
+ session.lastActivity = now;
7010
+ }
7011
+ broadcastToAll({ type: 'session-resumed', id: sessionId, source, timestamp: now });
7012
+ if (telemetryReceiver.hasAuthoritativeSource(sessionId)) {
7013
+ broadcastToAll({ type: 'session.status', id: sessionId, working: true, source, timestamp: now });
7014
+ }
7015
+ }
7016
+
6415
7017
  function checkIdleNotify(sessionId, session, data) {
6416
7018
  const hasAuthoritativeSource = telemetryReceiver.hasAuthoritativeSource(sessionId);
6417
7019
  let st = idleNotifyState.get(sessionId);
@@ -6496,6 +7098,7 @@ function checkIdleNotify(sessionId, session, data) {
6496
7098
  reason,
6497
7099
  label: session.label || '',
6498
7100
  snippet: lastLines.trim().split('\n').slice(-3).join('\n').slice(0, 200),
7101
+ timestamp: Date.now(),
6499
7102
  });
6500
7103
  }
6501
7104
  return;
@@ -6511,12 +7114,86 @@ function cleanIdleNotify(sessionId) {
6511
7114
  }
6512
7115
 
6513
7116
  function broadcastToAll(data) {
7117
+ _recordCaptureSignal(data);
6514
7118
  const payload = typeof data === 'string' ? data : JSON.stringify(data);
6515
7119
  for (const client of wss.clients) {
6516
7120
  if (client.readyState === 1) client.send(payload);
6517
7121
  }
6518
7122
  }
6519
7123
 
7124
+ function _recordWalleTranscriptAppend(session, appendResult) {
7125
+ if (!session || !appendResult || !appendResult.record) return appendResult;
7126
+ if (appendResult.uuid) session._walleLastTranscriptUuid = appendResult.uuid;
7127
+ try {
7128
+ const stat = fs.statSync(appendResult.filePath);
7129
+ session._walleTranscriptFileSize = stat.size || 0;
7130
+ session._walleTranscriptModifiedAt = stat.mtime ? stat.mtime.toISOString() : '';
7131
+ } catch {}
7132
+ broadcastToAll({
7133
+ type: 'walle-transcript-append',
7134
+ id: session.id,
7135
+ sessionId: session.id,
7136
+ chatSessionId: session.chatSessionId || '',
7137
+ jsonlPath: appendResult.filePath,
7138
+ offset: appendResult.offset,
7139
+ bytes: appendResult.bytes,
7140
+ recordType: appendResult.record.type,
7141
+ partType: appendResult.record.partType || '',
7142
+ timestamp: appendResult.record.timestamp || new Date().toISOString(),
7143
+ });
7144
+ return appendResult;
7145
+ }
7146
+
7147
+ function _appendWallePart(session, partType, data) {
7148
+ if (!session || !session.jsonlPath) return null;
7149
+ return _recordWalleTranscriptAppend(session, walleTranscript.appendPart(session.jsonlPath, {
7150
+ sessionId: session.id,
7151
+ chatSessionId: session.chatSessionId,
7152
+ parentUuid: session._walleLastTranscriptUuid || null,
7153
+ cwd: session.cwd,
7154
+ partType,
7155
+ data,
7156
+ }));
7157
+ }
7158
+
7159
+ function _walleProgressTranscriptPart(event) {
7160
+ if (!event || typeof event !== 'object') return null;
7161
+ if (event.type === 'thinking') return { partType: 'thinking', data: { turn: event.turn || null } };
7162
+ if (event.type === 'model_selected') {
7163
+ return {
7164
+ partType: 'model',
7165
+ data: {
7166
+ model: event.model || event.model_id || '',
7167
+ provider: event.provider || '',
7168
+ },
7169
+ };
7170
+ }
7171
+ if (event.type === 'tool_call') {
7172
+ return {
7173
+ partType: 'tool_call',
7174
+ data: {
7175
+ id: event.id || event.tool_call_id || '',
7176
+ name: event.name || event.tool || '',
7177
+ input: event.input || event.arguments || null,
7178
+ summary: event.summary || '',
7179
+ },
7180
+ };
7181
+ }
7182
+ if (event.type === 'tool_result') {
7183
+ return {
7184
+ partType: 'tool_result',
7185
+ data: {
7186
+ id: event.id || event.tool_call_id || '',
7187
+ name: event.name || event.tool || '',
7188
+ result: event.result || event.output || '',
7189
+ summary: event.summary || '',
7190
+ },
7191
+ };
7192
+ }
7193
+ if (event.type === 'cancelled') return { partType: 'cancelled', data: {} };
7194
+ return null;
7195
+ }
7196
+
6520
7197
  // Notify all clients (except the originator) that a set of ui_* prefs changed.
6521
7198
  // Receivers refetch via GET /api/settings?prefix=ui_ and apply selectively —
6522
7199
  // keeping the broadcast tiny and the source of truth on the server.
@@ -6563,6 +7240,26 @@ function handleCreate(ws, msg) {
6563
7240
  const shell = msg.shell || process.env.SHELL || '/bin/zsh';
6564
7241
  let cmd = msg.cmd || shell;
6565
7242
  let args = msg.args || [];
7243
+ let _codexResumeCwdToHeal = null;
7244
+
7245
+ // Codex resume records the thread's original cwd. If CTM starts `codex
7246
+ // resume <thread>` from a different cwd, Codex stops on an interactive
7247
+ // "choose working directory" picker. Launch from the recorded thread cwd
7248
+ // when it still exists so restart restore stays unattended.
7249
+ try {
7250
+ const preConfigAgentType = detectAgentType(cmd);
7251
+ const preConfigResume = extractResumeTarget(preConfigAgentType, args);
7252
+ if (preConfigAgentType === 'codex' && preConfigResume?.sessionId) {
7253
+ const resumeCwd = getCodexThreadResumeCwd(preConfigResume.sessionId, { fallbackCwd: cwd });
7254
+ if (resumeCwd && resumeCwd !== cwd && fs.existsSync(resumeCwd)) {
7255
+ console.log(`[codex-resume] using thread cwd for ${String(preConfigResume.sessionId).slice(0,8)}: ${cwd} -> ${resumeCwd}`);
7256
+ _codexResumeCwdToHeal = resumeCwd;
7257
+ cwd = resumeCwd;
7258
+ }
7259
+ }
7260
+ } catch (e) {
7261
+ console.error('[codex-resume] cwd resolution error:', e.message);
7262
+ }
6566
7263
 
6567
7264
  // Per-project .ctm.json overrides — project wins for provider/cmd/args/model/env/hooks.
6568
7265
  // Only honor project config when the caller did NOT pass an explicit --skipProjectConfig flag.
@@ -6674,9 +7371,16 @@ function handleCreate(ws, msg) {
6674
7371
  }
6675
7372
 
6676
7373
  // If a user-renamed title exists in the DB (e.g. from a previous session that was
6677
- // renamed before CTM restart), prefer it over the stale startup_tasks label
7374
+ // renamed before CTM restart), prefer it over the stale startup_tasks label.
7375
+ // On restore, a non-user DB title may be an inferred agent title; keep the
7376
+ // startup task label as the running tab's durable name.
6678
7377
  const existingTitle = dbModule.getSessionTitleNew(id);
6679
- if (existingTitle && existingTitle.title && (msg._isRestore || existingTitle.userRenamed)) {
7378
+ const shouldUseExistingTitle = existingTitle && existingTitle.title && (
7379
+ existingTitle.userRenamed
7380
+ || !msg._isRestore
7381
+ || (msg._isRestore && !msg.label)
7382
+ );
7383
+ if (shouldUseExistingTitle) {
6680
7384
  label = existingTitle.title;
6681
7385
  }
6682
7386
 
@@ -6686,9 +7390,16 @@ function handleCreate(ws, msg) {
6686
7390
  // Override auto-generated shell label if user didn't provide one
6687
7391
  if (!msg.label) label = 'Wall-E session';
6688
7392
  const chatSessionId = msg.chatSessionId || `walle-${id}`;
6689
- ensureWalleSessionsDir();
6690
- const jsonlPath = path.join(WALLE_SESSIONS_DIR, `${id}.jsonl`);
6691
- fs.writeFileSync(jsonlPath, '');
7393
+ const jsonlPath = walleTranscript.sessionPath(WALLE_SESSIONS_DIR, id);
7394
+ const createResult = walleTranscript.createSession(jsonlPath, {
7395
+ reset: !msg._isRestore,
7396
+ sessionId: id,
7397
+ chatSessionId,
7398
+ cwd,
7399
+ label,
7400
+ modelId: model_id || '',
7401
+ modelProvider: model_provider || '',
7402
+ });
6692
7403
 
6693
7404
  const session = {
6694
7405
  id, type: 'walle', label: label || 'Wall-E session', cwd,
@@ -6697,10 +7408,30 @@ function handleCreate(ws, msg) {
6697
7408
  chatSessionId, jsonlPath,
6698
7409
  abortController: null,
6699
7410
  model_id: model_id || null, model_provider: model_provider || null,
7411
+ _walleLastTranscriptUuid: createResult?.uuid || walleTranscript.readLastUuid(jsonlPath) || null,
6700
7412
  };
6701
7413
  sessions.set(id, session);
6702
7414
  telemetry.track('ctm_session', { type: 'walle' });
6703
7415
 
7416
+ if (createResult) _recordWalleTranscriptAppend(session, createResult);
7417
+ try {
7418
+ const st = fs.statSync(jsonlPath);
7419
+ dbModule.upsertSession(id, {
7420
+ agentSessionId: id,
7421
+ provider: 'walle',
7422
+ cwd,
7423
+ projectPath: cwd,
7424
+ title: session.label,
7425
+ jsonlPath,
7426
+ fileSize: st.size || 0,
7427
+ modifiedAt: st.mtime ? st.mtime.toISOString() : '',
7428
+ model: model_id || '',
7429
+ hostname: HOSTNAME,
7430
+ });
7431
+ } catch (e) {
7432
+ console.error('[walle-session] index create error:', e.message);
7433
+ }
7434
+
6704
7435
  if (!msg._isRestore) {
6705
7436
  addStartupTaskWithRetry('walle', [id, session.label, '', [], cwd, model_id, 'walle', chatSessionId]);
6706
7437
  }
@@ -6866,6 +7597,9 @@ function handleCreate(ws, msg) {
6866
7597
  try { dbModule.updateStartupTaskClaudeSession(id, _injectedSessionId, projectDir); }
6867
7598
  catch (e) { console.error('[ctm] updateStartupTaskClaudeSession (session-id inject) error:', e.message); }
6868
7599
  }
7600
+ } else if (_codexResumeCwdToHeal) {
7601
+ try { dbModule.updateStartupTaskCwd(id, _codexResumeCwdToHeal); }
7602
+ catch (e) { console.error('[codex-resume] heal startup cwd error:', e.message); }
6869
7603
  }
6870
7604
 
6871
7605
  // Output batching — coalesce rapid PTY chunks into fewer WS messages.
@@ -7055,7 +7789,10 @@ function handleCreate(ws, msg) {
7055
7789
  // Claude both emit idle TUI redraws that contain a few printable chars.
7056
7790
  const activeChunk = _isActivePtyChunk(session, data);
7057
7791
  const statusOnlyChunk = _isStatusOnlyPtyChunk(session, data);
7058
- const activityChunk = activeChunk && !(statusOnlyChunk && _isServerWaitingForInput(id, session));
7792
+ const busyStatusChunk = _isBusyStatusPtyChunk(session, data);
7793
+ const waitingForInput = _isServerWaitingForInput(id, session);
7794
+ const activityChunk = activeChunk && !(statusOnlyChunk && waitingForInput && !busyStatusChunk);
7795
+ if (busyStatusChunk && waitingForInput) _markServerSessionResumedFromPty(id, session, 'codex-working-status');
7059
7796
  if (activityChunk) session.lastActivity = Date.now();
7060
7797
  // Monotonic output byte counter — read by approval-agent's Phase 3
7061
7798
  // post-keystroke verification (sendApprovalKeystroke in approval-agent.js).
@@ -7645,7 +8382,10 @@ function apiAttachSession(req, res) {
7645
8382
  ptyProcess.onData((data) => {
7646
8383
  const activeChunk = _isActivePtyChunk(session, data);
7647
8384
  const statusOnlyChunk = _isStatusOnlyPtyChunk(session, data);
7648
- const activityChunk = activeChunk && !(statusOnlyChunk && _isServerWaitingForInput(tabId, session));
8385
+ const busyStatusChunk = _isBusyStatusPtyChunk(session, data);
8386
+ const waitingForInput = _isServerWaitingForInput(tabId, session);
8387
+ const activityChunk = activeChunk && !(statusOnlyChunk && waitingForInput && !busyStatusChunk);
8388
+ if (busyStatusChunk && waitingForInput) _markServerSessionResumedFromPty(tabId, session, 'codex-working-status');
7649
8389
  if (activityChunk) session.lastActivity = Date.now();
7650
8390
  const cleanData = data.indexOf('\x1b[') >= 0
7651
8391
  ? data.replace(/\x1b\[3J|\x1b\[\?100[0236]h|\x1b\[\?1015h/g, '')
@@ -7771,7 +8511,13 @@ function handleAttach(ws, msg) {
7771
8511
  walleClient.requestJson(`/api/wall-e/chat/history?session_id=${encodeURIComponent(session.chatSessionId)}&limit=100`)
7772
8512
  .then((upstream) => {
7773
8513
  if (ws.readyState === 1) {
7774
- ws.send(JSON.stringify({ type: 'walle-history', id: session.id, messages: upstream.json?.data || [] }));
8514
+ const ctmHistory = readWalleCtmHistory(session.jsonlPath);
8515
+ const upstreamHistory = upstream.json?.data || [];
8516
+ ws.send(JSON.stringify({
8517
+ type: 'walle-history',
8518
+ id: session.id,
8519
+ messages: ctmHistory.length ? ctmHistory : upstreamHistory,
8520
+ }));
7775
8521
  }
7776
8522
  })
7777
8523
  .catch((e) => console.error('[attach] walle-history replay error:', e.message));
@@ -8034,12 +8780,111 @@ async function _maybeBroadcastWorktreeFinishGate(session, sessionId) {
8034
8780
  }
8035
8781
  }
8036
8782
 
8783
+ const SESSION_WORKTREE_STATUS_TTL_MS = 4000;
8784
+ let _sessionWorktreeStatusCache = new Map();
8785
+ let _sessionWorktreeStatusLastRefreshAt = 0;
8786
+ let _sessionWorktreeStatusRefreshInFlight = false;
8787
+
8788
+ function _sessionNeedsWorktreeStatus(session) {
8789
+ if (!session) return false;
8790
+ const branch = session.branch || '';
8791
+ if (branch === 'main' || branch === 'master') return false;
8792
+ return !!(session.worktree_path || (branch && session.cwd));
8793
+ }
8794
+
8795
+ function _normalizeSessionWorktreeStatus(session, wt) {
8796
+ if (!session || !wt) return null;
8797
+ const branch = wt.branch || session.branch || '';
8798
+ if (!branch || branch === 'main' || branch === 'master' || wt.isMain) return null;
8799
+ const dirtyFiles = Number(wt.dirtyFiles || 0);
8800
+ const unmergedCommits = Number(wt.unmergedCommits || 0);
8801
+ const ahead = Number(wt.ahead || 0);
8802
+ const behind = Number(wt.behind || 0);
8803
+ return {
8804
+ branch,
8805
+ worktreeName: wt.worktreeName || path.basename(wt.path || session.worktree_path || session.cwd || branch),
8806
+ worktreePath: wt.path || session.worktree_path || session.cwd || null,
8807
+ state: wt.state || 'unknown',
8808
+ dirtyFiles,
8809
+ unmergedCommits,
8810
+ ahead,
8811
+ behind,
8812
+ summary: wt.summary || '',
8813
+ needsAttention: dirtyFiles > 0 || unmergedCommits > 0,
8814
+ };
8815
+ }
8816
+
8817
+ function _sessionWorktreeStatusSnapshot(map) {
8818
+ return JSON.stringify([...map.entries()]
8819
+ .sort(([a], [b]) => a.localeCompare(b))
8820
+ .map(([id, wt]) => [
8821
+ id,
8822
+ wt.branch,
8823
+ wt.state,
8824
+ wt.dirtyFiles,
8825
+ wt.unmergedCommits,
8826
+ wt.ahead,
8827
+ wt.behind,
8828
+ wt.summary,
8829
+ wt.needsAttention,
8830
+ ]));
8831
+ }
8832
+
8833
+ async function _refreshSessionWorktreeStatusCache(options = {}) {
8834
+ if (_sessionWorktreeStatusRefreshInFlight) return;
8835
+ const now = Date.now();
8836
+ if (!options.force && (now - _sessionWorktreeStatusLastRefreshAt) < SESSION_WORKTREE_STATUS_TTL_MS) return;
8837
+
8838
+ const candidates = Array.from(sessions.values()).filter(_sessionNeedsWorktreeStatus);
8839
+ if (candidates.length === 0) {
8840
+ if (_sessionWorktreeStatusCache.size > 0) {
8841
+ _sessionWorktreeStatusCache = new Map();
8842
+ broadcastSessionList(true);
8843
+ }
8844
+ return;
8845
+ }
8846
+
8847
+ _sessionWorktreeStatusRefreshInFlight = true;
8848
+ _sessionWorktreeStatusLastRefreshAt = now;
8849
+ try {
8850
+ const worktrees = await gitUtilsWorktree.listRichWorktrees(_projectRoot);
8851
+ const next = new Map();
8852
+ for (const session of candidates) {
8853
+ const wt = _findSessionWorktree(worktrees, session);
8854
+ const status = _normalizeSessionWorktreeStatus(session, wt);
8855
+ if (status && status.needsAttention) next.set(session.id, status);
8856
+ }
8857
+ const changed = _sessionWorktreeStatusSnapshot(next) !== _sessionWorktreeStatusSnapshot(_sessionWorktreeStatusCache);
8858
+ _sessionWorktreeStatusCache = next;
8859
+ if (changed) broadcastSessionList(true);
8860
+ } catch (e) {
8861
+ console.error('[session-worktree-status] refresh error:', e.message || e);
8862
+ } finally {
8863
+ _sessionWorktreeStatusRefreshInFlight = false;
8864
+ }
8865
+ }
8866
+
8867
+ function _sessionWorktreeStatusPayload(session) {
8868
+ const status = _sessionWorktreeStatusCache.get(session?.id);
8869
+ if (!status || !status.needsAttention) return null;
8870
+ return status;
8871
+ }
8872
+
8037
8873
  async function handleWalleMessage(ws, msg) {
8038
8874
  const session = sessions.get(msg.id);
8039
8875
  if (!session || session.type !== 'walle') return;
8040
8876
 
8041
8877
  session.lastActivity = Date.now();
8042
- appendToJsonl(session.jsonlPath, { type: 'user', content: msg.text, timestamp: new Date().toISOString() });
8878
+ const userAppend = walleTranscript.appendUserMessage(session.jsonlPath, {
8879
+ sessionId: session.id,
8880
+ chatSessionId: session.chatSessionId,
8881
+ parentUuid: session._walleLastTranscriptUuid || null,
8882
+ cwd: session.cwd,
8883
+ text: msg.text,
8884
+ modelId: msg.model || session.model_id || '',
8885
+ modelProvider: session.model_provider || '',
8886
+ });
8887
+ _recordWalleTranscriptAppend(session, userAppend);
8043
8888
 
8044
8889
  session.abortController = new AbortController();
8045
8890
 
@@ -8050,10 +8895,20 @@ async function handleWalleMessage(ws, msg) {
8050
8895
  }
8051
8896
  };
8052
8897
 
8053
- broadcastToSession({ type: 'walle-progress', id: session.id, event: { type: 'thinking' } });
8898
+ const thinkingEvent = { type: 'thinking' };
8899
+ broadcastToSession({ type: 'walle-progress', id: session.id, event: thinkingEvent });
8900
+ _appendWallePart(session, 'thinking', { turn: null });
8054
8901
 
8055
8902
  try {
8056
8903
  let result = null;
8904
+ const turnToolCalls = [];
8905
+ const contextSession = msg.contextSessionId ? sessions.get(msg.contextSessionId) : null;
8906
+ const chatContext = resolveWalleChatContext({
8907
+ session,
8908
+ contextSession,
8909
+ requestedCwd: msg.cwd,
8910
+ });
8911
+ const effectiveCwd = chatContext.cwd || session.cwd;
8057
8912
  const upstream = await walleClient.requestSse('/api/wall-e/chat?stream=1', {
8058
8913
  method: 'POST',
8059
8914
  signal: session.abortController.signal,
@@ -8062,8 +8917,15 @@ async function handleWalleMessage(ws, msg) {
8062
8917
  session_id: session.chatSessionId,
8063
8918
  channel: 'ctm-session',
8064
8919
  model: msg.model || undefined,
8065
- cwd: session.cwd,
8066
- context: { cwd: session.cwd },
8920
+ cwd: effectiveCwd,
8921
+ context: {
8922
+ cwd: effectiveCwd,
8923
+ projectPath: effectiveCwd,
8924
+ ctmSessionId: session.id,
8925
+ contextSessionId: chatContext.contextSessionId,
8926
+ contextSessionLabel: chatContext.contextSessionLabel,
8927
+ cwdSource: chatContext.source,
8928
+ },
8067
8929
  },
8068
8930
  onEvent: (event) => {
8069
8931
  if (!event || typeof event !== 'object') return;
@@ -8072,25 +8934,69 @@ async function handleWalleMessage(ws, msg) {
8072
8934
  return;
8073
8935
  }
8074
8936
  if (event.type === 'error') {
8075
- throw new Error(event.error || 'Wall-E chat failed');
8937
+ const err = new Error(event.message || event.error || 'Wall-E chat failed');
8938
+ if (event.providerError) {
8939
+ err.code = event.code || 'AI_PROVIDER_ERROR';
8940
+ err.providerError = event.providerError;
8941
+ }
8942
+ throw err;
8943
+ }
8944
+ if (event.type === 'tool_call' || event.type === 'tool_result' || event.type === 'tool_done') {
8945
+ applyWalleToolEvent(turnToolCalls, event);
8946
+ appendToJsonl(session.jsonlPath, { ...event, timestamp: new Date().toISOString() });
8076
8947
  }
8077
8948
  broadcastToSession({ type: 'walle-progress', id: session.id, event });
8949
+ const part = _walleProgressTranscriptPart(event);
8950
+ if (part) _appendWallePart(session, part.partType, part.data);
8078
8951
  },
8079
8952
  });
8080
8953
  if (upstream.status >= 400) {
8081
8954
  let parsed = null;
8082
8955
  try { parsed = JSON.parse(upstream.body || '{}'); } catch {}
8083
- throw new Error(parsed?.error || `Wall-E chat failed (${upstream.status})`);
8956
+ const err = new Error(parsed?.message || parsed?.error || `Wall-E chat failed (${upstream.status})`);
8957
+ if (parsed?.providerError) {
8958
+ err.code = parsed.code || 'AI_PROVIDER_ERROR';
8959
+ err.providerError = parsed.providerError;
8960
+ }
8961
+ throw err;
8084
8962
  }
8085
8963
  if (!result) throw new Error('Wall-E chat ended without a final response');
8086
8964
 
8087
- appendToJsonl(session.jsonlPath, {
8088
- type: 'assistant', content: result.reply,
8089
- model: result.model, provider: result.provider,
8965
+ const assistantAppend = walleTranscript.appendAssistantMessage(session.jsonlPath, {
8966
+ sessionId: session.id,
8967
+ chatSessionId: session.chatSessionId,
8968
+ parentUuid: session._walleLastTranscriptUuid || null,
8969
+ cwd: session.cwd,
8970
+ text: result.reply,
8971
+ model: result.model,
8972
+ provider: result.provider,
8973
+ modelProvider: result.provider || session.model_provider || '',
8090
8974
  latencyMs: result.latencyMs,
8091
- inputTokens: result.tokens?.input, outputTokens: result.tokens?.output,
8092
- cost: result.cost, timestamp: new Date().toISOString(),
8975
+ tokens: result.tokens || {},
8976
+ cost: result.cost,
8977
+ toolCalls: cloneToolCalls(turnToolCalls),
8978
+ timestamp: new Date().toISOString(),
8093
8979
  });
8980
+ _recordWalleTranscriptAppend(session, assistantAppend);
8981
+ try {
8982
+ const st = fs.statSync(session.jsonlPath);
8983
+ dbModule.upsertSession(session.id, {
8984
+ agentSessionId: session.id,
8985
+ provider: 'walle',
8986
+ cwd: session.cwd,
8987
+ projectPath: session.cwd,
8988
+ title: session.label,
8989
+ firstMessage: msg.text || '',
8990
+ jsonlPath: session.jsonlPath,
8991
+ fileSize: st.size || 0,
8992
+ modifiedAt: st.mtime ? st.mtime.toISOString() : '',
8993
+ model: result.model || msg.model || session.model_id || '',
8994
+ userMsgCount: 1,
8995
+ hostname: HOSTNAME,
8996
+ });
8997
+ } catch (e) {
8998
+ console.error('[walle-session] index update error:', e.message);
8999
+ }
8094
9000
 
8095
9001
  broadcastToSession({
8096
9002
  type: 'walle-response', id: session.id,
@@ -8100,9 +9006,22 @@ async function handleWalleMessage(ws, msg) {
8100
9006
  });
8101
9007
  } catch (err) {
8102
9008
  if (err.message === 'Cancelled') {
8103
- broadcastToSession({ type: 'walle-progress', id: session.id, event: { type: 'cancelled' } });
9009
+ const cancelEvent = { type: 'cancelled' };
9010
+ broadcastToSession({ type: 'walle-progress', id: session.id, event: cancelEvent });
9011
+ _appendWallePart(session, 'cancelled', {});
8104
9012
  } else {
8105
- broadcastToSession({ type: 'walle-error', id: session.id, error: err.message });
9013
+ broadcastToSession({
9014
+ type: 'walle-error',
9015
+ id: session.id,
9016
+ error: err.message,
9017
+ code: err.code,
9018
+ providerError: err.providerError || null,
9019
+ });
9020
+ _appendWallePart(session, 'error', {
9021
+ message: err.message || 'Wall-E chat failed',
9022
+ code: err.code,
9023
+ providerError: err.providerError || null,
9024
+ });
8106
9025
  }
8107
9026
  } finally {
8108
9027
  session.abortController = null;
@@ -8255,6 +9174,7 @@ function _sessionPayload(s) {
8255
9174
  model_provider: s.model_provider,
8256
9175
  branch: s.branch || null,
8257
9176
  worktree_path: s.worktree_path || null,
9177
+ worktreeStatus: _sessionWorktreeStatusPayload(s),
8258
9178
  agentType,
8259
9179
  agentCapabilities: {
8260
9180
  structuredTranscript: !!caps.structuredTranscript,
@@ -8283,6 +9203,7 @@ let _broadcastTimer = null;
8283
9203
  function _doBroadcastSessionList() {
8284
9204
  _broadcastTimer = null;
8285
9205
  if (_restoreInProgress) return;
9206
+ _refreshSessionWorktreeStatusCache().catch(() => {});
8286
9207
  // Invalidate the HTTP API cache so the next GET /api/recent-sessions reflects changes
8287
9208
  _recentSessionsCache = null;
8288
9209
  const payload = JSON.stringify({
@@ -8403,6 +9324,7 @@ setInterval(async () => {
8403
9324
  } catch {} // git rev-parse failures are normal for non-git dirs
8404
9325
  }
8405
9326
  if (changed) broadcastSessionList();
9327
+ await _refreshSessionWorktreeStatusCache({ force: true });
8406
9328
  } finally {
8407
9329
  _branchRefreshRunning = false;
8408
9330
  }
@@ -8414,6 +9336,7 @@ function shutdown() {
8414
9336
  _isShuttingDown = true;
8415
9337
  // Stop scheduler, session stream, and file watcher first
8416
9338
  try { if (_ctmScheduler) _ctmScheduler.shutdown().catch(() => {}); } catch {}
9339
+ try { if (_sessionCapture) _sessionCapture.detach(); } catch {}
8417
9340
  try { if (_sessionStream) _sessionStream.stop(); } catch {}
8418
9341
  try { if (_jsonlWatcher) _jsonlWatcher.stop(); } catch {}
8419
9342
  // Ask scrollback worker to close its DB connection before terminating.
@@ -8605,11 +9528,15 @@ function isValidWorktreeCwd(cwd) {
8605
9528
 
8606
9529
  // Validate git ref names (branch names, tags) — reject values starting with - and special chars
8607
9530
  const VALID_GIT_REF = /^[a-zA-Z0-9][a-zA-Z0-9._\/-]*$/;
9531
+ const WORKTREE_JSON_HEADERS = {
9532
+ 'Content-Type': 'application/json',
9533
+ 'Cache-Control': 'no-store, max-age=0',
9534
+ };
8608
9535
 
8609
9536
  function apiListWorktrees(req, res) {
8610
9537
  const cwd = new URL(req.url, 'http://localhost').searchParams.get('cwd') || _projectRoot;
8611
9538
  if (!isValidWorktreeCwd(cwd)) {
8612
- res.writeHead(403, { 'Content-Type': 'application/json' });
9539
+ res.writeHead(403, WORKTREE_JSON_HEADERS);
8613
9540
  return res.end(JSON.stringify({ error: 'cwd must be within project root' }));
8614
9541
  }
8615
9542
  gitUtilsWorktree.listRichWorktrees(cwd).then(worktrees => {
@@ -8651,10 +9578,10 @@ function apiListWorktrees(req, res) {
8651
9578
  !!w.sessionId || ['ghost','ahead','behind','diverged','dirty','detached'].includes(w.state)
8652
9579
  ).length + (mainRemote.state && !['synced', 'unknown'].includes(mainRemote.state) ? 1 : 0),
8653
9580
  };
8654
- res.writeHead(200, { 'Content-Type': 'application/json' });
9581
+ res.writeHead(200, WORKTREE_JSON_HEADERS);
8655
9582
  res.end(JSON.stringify({ worktrees, cwd, counts, mainRemote }));
8656
9583
  }).catch(e => {
8657
- res.writeHead(500, { 'Content-Type': 'application/json' });
9584
+ res.writeHead(500, WORKTREE_JSON_HEADERS);
8658
9585
  res.end(JSON.stringify({ error: e.message }));
8659
9586
  });
8660
9587
  }
@@ -8825,31 +9752,31 @@ function apiSyncWorktree(req, res) {
8825
9752
  try {
8826
9753
  const { name, strategy, cwd: reqCwd } = JSON.parse(body);
8827
9754
  if (!name || !VALID_GIT_REF.test(name)) {
8828
- res.writeHead(400, { 'Content-Type': 'application/json' });
9755
+ res.writeHead(400, WORKTREE_JSON_HEADERS);
8829
9756
  return res.end(JSON.stringify({ error: 'Invalid branch name' }));
8830
9757
  }
8831
9758
  if (strategy && !['merge', 'rebase'].includes(strategy)) {
8832
- res.writeHead(400, { 'Content-Type': 'application/json' });
9759
+ res.writeHead(400, WORKTREE_JSON_HEADERS);
8833
9760
  return res.end(JSON.stringify({ error: 'Invalid sync strategy. Use: merge or rebase' }));
8834
9761
  }
8835
9762
  const cwd = reqCwd || _projectRoot;
8836
9763
  if (!isValidWorktreeCwd(cwd)) {
8837
- res.writeHead(403, { 'Content-Type': 'application/json' });
9764
+ res.writeHead(403, WORKTREE_JSON_HEADERS);
8838
9765
  return res.end(JSON.stringify({ error: 'cwd must be within project root' }));
8839
9766
  }
8840
9767
  gitUtilsWorktree.syncWorktree(cwd, name, strategy || 'merge').then(result => {
8841
9768
  if (result.conflicts) {
8842
- res.writeHead(409, { 'Content-Type': 'application/json' });
9769
+ res.writeHead(409, WORKTREE_JSON_HEADERS);
8843
9770
  return res.end(JSON.stringify(result));
8844
9771
  }
8845
- res.writeHead(result.merged ? 200 : 500, { 'Content-Type': 'application/json' });
9772
+ res.writeHead(result.merged ? 200 : 500, WORKTREE_JSON_HEADERS);
8846
9773
  res.end(JSON.stringify(result));
8847
9774
  }).catch(e => {
8848
- res.writeHead(500, { 'Content-Type': 'application/json' });
9775
+ res.writeHead(500, WORKTREE_JSON_HEADERS);
8849
9776
  res.end(JSON.stringify({ error: e.message }));
8850
9777
  });
8851
9778
  } catch (e) {
8852
- res.writeHead(400, { 'Content-Type': 'application/json' });
9779
+ res.writeHead(400, WORKTREE_JSON_HEADERS);
8853
9780
  res.end(JSON.stringify({ error: 'Invalid JSON body' }));
8854
9781
  }
8855
9782
  });
@@ -8863,12 +9790,12 @@ function apiSyncAllWorktrees(req, res) {
8863
9790
  const data = body ? JSON.parse(body) : {};
8864
9791
  const { strategy, cwd: reqCwd } = data;
8865
9792
  if (strategy && !['merge', 'rebase'].includes(strategy)) {
8866
- res.writeHead(400, { 'Content-Type': 'application/json' });
9793
+ res.writeHead(400, WORKTREE_JSON_HEADERS);
8867
9794
  return res.end(JSON.stringify({ error: 'Invalid sync strategy. Use: merge or rebase' }));
8868
9795
  }
8869
9796
  const cwd = reqCwd || _projectRoot;
8870
9797
  if (!isValidWorktreeCwd(cwd)) {
8871
- res.writeHead(403, { 'Content-Type': 'application/json' });
9798
+ res.writeHead(403, WORKTREE_JSON_HEADERS);
8872
9799
  return res.end(JSON.stringify({ error: 'cwd must be within project root' }));
8873
9800
  }
8874
9801
  const activeWorktreePaths = [...sessions.values()]
@@ -8878,14 +9805,14 @@ function apiSyncAllWorktrees(req, res) {
8878
9805
  gitUtilsWorktree.syncAllWorktrees(cwd, strategy || 'merge', {
8879
9806
  excludePaths: activeWorktreePaths,
8880
9807
  }).then(result => {
8881
- res.writeHead(result.failed > 0 ? 207 : 200, { 'Content-Type': 'application/json' });
9808
+ res.writeHead(result.failed > 0 ? 207 : 200, WORKTREE_JSON_HEADERS);
8882
9809
  res.end(JSON.stringify(result));
8883
9810
  }).catch(e => {
8884
- res.writeHead(500, { 'Content-Type': 'application/json' });
9811
+ res.writeHead(500, WORKTREE_JSON_HEADERS);
8885
9812
  res.end(JSON.stringify({ error: e.message }));
8886
9813
  });
8887
9814
  } catch (e) {
8888
- res.writeHead(400, { 'Content-Type': 'application/json' });
9815
+ res.writeHead(400, WORKTREE_JSON_HEADERS);
8889
9816
  res.end(JSON.stringify({ error: 'Invalid JSON body' }));
8890
9817
  }
8891
9818
  });
@@ -9596,12 +10523,22 @@ server.on('listening', () => {
9596
10523
  console.log(' Wall-E disabled (WALLE_DISABLED=1)');
9597
10524
  }
9598
10525
 
9599
- // OAuth proxy boot-start: when the user has WALLE_AUTH_METHOD=oauth_proxy
9600
- // in .env, the daemon needs the proxy on :3458 BEFORE it tries its first
9601
- // chat call. The supervisor's start() is idempotent (returns
9602
- // alreadyRunning when already up). Wrapped in try so a proxy boot
9603
- // failure never blocks CTM itself.
9604
- if (process.env.WALLE_AUTH_METHOD === 'oauth_proxy' && !process.env.WALLE_DISABLED) {
10526
+ // OAuth proxy boot-start: when Anthropic is the active Wall-E provider and
10527
+ // its auth method is oauth_proxy, the daemon needs the proxy before its
10528
+ // first chat call. Read both .env and the provider DB row so the setup page's
10529
+ // auth-method radio remains authoritative after restarts.
10530
+ const shouldStartOauthProxy = (() => {
10531
+ if (process.env.WALLE_DISABLED) return false;
10532
+ try {
10533
+ const brain = getWalleBrain();
10534
+ const provider = brain?.getKv?.('walle_provider') || process.env.WALLE_PROVIDER || 'anthropic';
10535
+ if (provider !== 'anthropic') return false;
10536
+ return getProviderRuntimeState('anthropic').authMethod === 'oauth_proxy';
10537
+ } catch {
10538
+ return process.env.WALLE_PROVIDER === 'anthropic' && process.env.WALLE_AUTH_METHOD === 'oauth_proxy';
10539
+ }
10540
+ })();
10541
+ if (shouldStartOauthProxy) {
9605
10542
  try {
9606
10543
  const out = oauthProxySupervisor.start();
9607
10544
  if (out.ok && out.running) {
@@ -9849,8 +10786,14 @@ server.on('listening', () => {
9849
10786
  console.log(' JSONL file watcher started');
9850
10787
 
9851
10788
  // --- Session Stream: structured conversation event stream ---
9852
- const sessionStream = _sessionStream = new SessionStream({ jsonlWatcher, sessions, dbModule });
10789
+ const sessionStream = _sessionStream = new SessionStream({
10790
+ jsonlWatcher,
10791
+ sessions,
10792
+ dbModule,
10793
+ summaryProvider: generateSessionSummaryWithWalleProvider,
10794
+ });
9853
10795
  sessionStream.start();
10796
+ const sessionCapture = _sessionCapture = new SessionCapture({ sessionStream });
9854
10797
  // Link already-active sessions that were created before stream started
9855
10798
  for (const [ctmId, session] of sessions) {
9856
10799
  if (session._claudeSessionId) {
@@ -9861,18 +10804,21 @@ server.on('listening', () => {
9861
10804
  }
9862
10805
  // Forward status changes to WS clients
9863
10806
  sessionStream.on('status', (statusEvt) => {
9864
- broadcastToAll({ type: 'stream-status', ...statusEvt });
10807
+ const projected = sessionCapture.getStatus(statusEvt.sessionId, statusEvt.newStatus || statusEvt.status);
10808
+ broadcastToAll({ type: 'stream-status', ...statusEvt, captureStatus: projected.status, statusEvidence: projected.evidence });
9865
10809
  });
9866
10810
  setInterval(() => {
9867
10811
  if (wss.clients.size === 0) return;
9868
10812
  const now = Date.now();
9869
- for (const st of sessionStream.getAllStatuses()) {
10813
+ for (const st of sessionCapture.getAllStatuses()) {
9870
10814
  broadcastToAll({
9871
10815
  type: 'stream-status',
9872
10816
  sessionId: st.sessionId,
9873
10817
  ctmSessionId: st.ctmSessionId,
9874
10818
  oldStatus: st.status,
9875
10819
  newStatus: st.status,
10820
+ captureStatus: st.captureStatus || st.status,
10821
+ statusEvidence: st.statusEvidence || [],
9876
10822
  reason: 'snapshot',
9877
10823
  timestamp: now,
9878
10824
  lastActivity: st.lastActivity,