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.
- package/README.md +3 -3
- package/package.json +2 -2
- package/template/bin/dev.sh +7 -1
- package/template/bin/setup.js +53 -9
- package/template/bin/sync-images.js +53 -0
- package/template/builder-journal.md +17 -0
- package/template/claude-task-manager/api-prompts.js +98 -13
- package/template/claude-task-manager/api-reviews.js +82 -5
- package/template/claude-task-manager/db.js +32 -5
- package/template/claude-task-manager/docs/session-capture-foundation-design.md +1273 -0
- package/template/claude-task-manager/lib/claude-desktop-sessions.js +696 -0
- package/template/claude-task-manager/lib/coding-agent-models.js +49 -1
- package/template/claude-task-manager/lib/session-capture.js +421 -0
- package/template/claude-task-manager/lib/session-history.js +135 -15
- package/template/claude-task-manager/lib/session-jobs.js +10 -5
- package/template/claude-task-manager/lib/session-stream.js +87 -19
- package/template/claude-task-manager/lib/setup-provider-config.js +115 -0
- package/template/claude-task-manager/lib/walle-ctm-history.js +72 -0
- package/template/claude-task-manager/lib/walle-session-context.js +61 -0
- package/template/claude-task-manager/lib/walle-transcript.js +176 -0
- package/template/claude-task-manager/public/css/setup.css +35 -8
- package/template/claude-task-manager/public/css/walle-session.css +56 -0
- package/template/claude-task-manager/public/css/walle.css +120 -0
- package/template/claude-task-manager/public/index.html +814 -181
- package/template/claude-task-manager/public/js/message-renderer.js +148 -19
- package/template/claude-task-manager/public/js/reviews.js +120 -62
- package/template/claude-task-manager/public/js/setup.js +75 -31
- package/template/claude-task-manager/public/js/stream-view.js +115 -55
- package/template/claude-task-manager/public/js/walle-session.js +84 -2
- package/template/claude-task-manager/public/js/walle.js +308 -54
- package/template/claude-task-manager/server.js +1092 -146
- package/template/claude-task-manager/session-integrity.js +181 -54
- package/template/claude-task-manager/session-utils.js +123 -41
- package/template/claude-task-manager/workers/state-detectors/codex.js +5 -2
- package/template/package.json +1 -1
- package/template/wall-e/adapters/ctm.js +39 -18
- package/template/wall-e/agent-runners/contract.js +17 -0
- package/template/wall-e/agent-runners/index.js +22 -0
- package/template/wall-e/agent-runtime/harness.js +212 -0
- package/template/wall-e/agent-runtime/index.js +8 -0
- package/template/wall-e/agent-runtime/registry.js +67 -0
- package/template/wall-e/agent-runtime/session-store.js +179 -0
- package/template/wall-e/agent-runtime/spawn.js +208 -0
- package/template/wall-e/api-walle.js +174 -7
- package/template/wall-e/brain.js +266 -28
- package/template/wall-e/channels/policy.js +88 -0
- package/template/wall-e/channels/registry.js +15 -1
- package/template/wall-e/channels/reply-dispatcher.js +70 -0
- package/template/wall-e/channels/session-bindings.js +51 -0
- package/template/wall-e/chat/code-review-context.js +29 -0
- package/template/wall-e/chat.js +188 -42
- package/template/wall-e/coding/acp-adapter.js +188 -0
- package/template/wall-e/coding/agent-catalog.js +129 -0
- package/template/wall-e/coding/compaction-service.js +247 -0
- package/template/wall-e/coding/execution-trace.js +3 -0
- package/template/wall-e/coding/instruction-service.js +224 -0
- package/template/wall-e/coding/model-message.js +67 -0
- package/template/wall-e/coding/permission-rules-store.js +111 -0
- package/template/wall-e/coding/permission-service.js +266 -0
- package/template/wall-e/coding/prompt-bundle.js +67 -0
- package/template/wall-e/coding/prompt-runtime.js +243 -0
- package/template/wall-e/coding/provider-transform.js +188 -0
- package/template/wall-e/coding/runtime-mode.js +132 -0
- package/template/wall-e/coding/snapshot-service.js +155 -0
- package/template/wall-e/coding/stream-processor.js +268 -0
- package/template/wall-e/coding/task-tool.js +255 -0
- package/template/wall-e/coding/tool-registry.js +361 -0
- package/template/wall-e/coding/transcript-writer.js +143 -0
- package/template/wall-e/coding/workspace-replay.js +324 -0
- package/template/wall-e/coding-context.js +4 -22
- package/template/wall-e/coding-orchestrator.js +307 -18
- package/template/wall-e/coding-prompts.js +44 -3
- package/template/wall-e/context/context-builder.js +43 -1
- package/template/wall-e/context/topic-matcher.js +1 -1
- package/template/wall-e/eval/agent-runner.js +59 -13
- package/template/wall-e/eval/benchmarks/memory-retrieval.json +155 -57
- package/template/wall-e/eval/benchmarks.js +100 -16
- package/template/wall-e/eval/eval-orchestrator.js +218 -8
- package/template/wall-e/eval/harvester.js +62 -5
- package/template/wall-e/eval/head-to-head.js +23 -2
- package/template/wall-e/eval/humaneval-adapter.js +30 -5
- package/template/wall-e/eval/livecodebench-adapter.js +29 -5
- package/template/wall-e/eval/manifest.js +186 -0
- package/template/wall-e/eval/run-agent-benchmarks.js +66 -2
- package/template/wall-e/eval/session-retrieval-benchmark.js +150 -0
- package/template/wall-e/eval/session-transcripts.js +57 -4
- package/template/wall-e/eval/swebench-adapter.js +109 -3
- package/template/wall-e/evaluation/agent-router.js +53 -1
- package/template/wall-e/evaluation/coding-quorum.js +48 -1
- package/template/wall-e/evaluation/router.js +4 -2
- package/template/wall-e/evaluation/tier-selector.js +11 -1
- package/template/wall-e/extraction/contradiction.js +2 -2
- package/template/wall-e/extraction/indexer.js +2 -1
- package/template/wall-e/extraction/knowledge-extractor.js +2 -2
- package/template/wall-e/hooks/cli.js +92 -0
- package/template/wall-e/hooks/discovery.js +119 -0
- package/template/wall-e/hooks/index.js +7 -0
- package/template/wall-e/hooks/manifest.js +55 -0
- package/template/wall-e/hooks/runtime.js +84 -0
- package/template/wall-e/hooks/session-memory.js +225 -0
- package/template/wall-e/http/auth.js +6 -2
- package/template/wall-e/http/chat-api.js +54 -8
- package/template/wall-e/integrations/claude-plugin/hooks/hooks.json +27 -0
- package/template/wall-e/integrations/claude-plugin/hooks/walle-precompact-hook.sh +5 -0
- package/template/wall-e/integrations/claude-plugin/hooks/walle-stop-hook.sh +5 -0
- package/template/wall-e/integrations/codex-plugin/hooks/walle-hook.sh +7 -0
- package/template/wall-e/integrations/codex-plugin/hooks.json +37 -0
- package/template/wall-e/listening/calendar.js +3 -1
- package/template/wall-e/llm/client.js +64 -10
- package/template/wall-e/llm/google.js +39 -5
- package/template/wall-e/llm/ollama.js +1 -1
- package/template/wall-e/llm/ollama.plugin.json +1 -1
- package/template/wall-e/llm/provider-availability.js +10 -0
- package/template/wall-e/llm/provider-error.js +269 -0
- package/template/wall-e/llm/tool-adapter.js +48 -12
- package/template/wall-e/loops/boot.js +2 -1
- package/template/wall-e/loops/initiative.js +2 -2
- package/template/wall-e/loops/tasks.js +8 -47
- package/template/wall-e/loops/workspace-prompts.js +20 -0
- package/template/wall-e/mcp-server.js +442 -1
- package/template/wall-e/memory/session-ingest-service.js +159 -0
- package/template/wall-e/memory/source-indexer.js +289 -0
- package/template/wall-e/plugins/discovery.js +83 -0
- package/template/wall-e/plugins/manifest-loader.js +50 -10
- package/template/wall-e/plugins/manifest-schema.js +69 -0
- package/template/wall-e/plugins/model-catalog.js +55 -0
- package/template/wall-e/prompts/coding/base.txt +2 -0
- package/template/wall-e/prompts/coding/deepseek.txt +1 -0
- package/template/wall-e/prompts/coding/memory-protocol.md +9 -0
- package/template/wall-e/prompts/coding/plan.txt +1 -0
- package/template/wall-e/runtime/execution-trace.js +220 -0
- package/template/wall-e/security/audit.js +266 -0
- package/template/wall-e/security/ssrf.js +236 -0
- package/template/wall-e/session-files.js +303 -0
- package/template/wall-e/skills/_bundled/slack-backfill/SKILL.md +3 -0
- package/template/wall-e/skills/_bundled/slack-sync/SKILL.md +3 -0
- package/template/wall-e/skills/internal-skill-registry.js +2 -2
- package/template/wall-e/skills/script-skill-runner.js +143 -0
- package/template/wall-e/skills/skill-executor.js +5 -6
- package/template/wall-e/skills/skill-fallback.js +3 -1
- package/template/wall-e/skills/skill-harness-registry.js +7 -8
- package/template/wall-e/skills/skill-planner.js +52 -4
- package/template/wall-e/skills/slack-ingest.js +11 -3
- package/template/wall-e/sources/base.js +90 -0
- package/template/wall-e/sources/builtin.js +33 -0
- package/template/wall-e/sources/claude-code-jsonl.js +78 -0
- package/template/wall-e/sources/codex-jsonl.js +125 -0
- package/template/wall-e/sources/coding-session-utils.js +117 -0
- package/template/wall-e/sources/contract-suite.js +59 -0
- package/template/wall-e/sources/gemini-jsonl.js +85 -0
- package/template/wall-e/sources/index.js +9 -0
- package/template/wall-e/sources/jsonl-utils.js +181 -0
- package/template/wall-e/sources/record-types.js +252 -0
- package/template/wall-e/sources/registry.js +92 -0
- package/template/wall-e/sources/transforms.js +100 -0
- package/template/wall-e/sources/walle-jsonl.js +108 -0
- package/template/wall-e/tools/coding-middleware.js +31 -1
- package/template/wall-e/tools/file-tracker.js +25 -1
- package/template/wall-e/tools/local-tools.js +75 -47
- package/template/wall-e/tools/session-sharing.js +68 -1
- package/template/wall-e/tools/shell-analyzer.js +1 -1
- package/template/wall-e/tools/shell-policy.js +47 -0
- package/template/wall-e/tools/snapshot.js +42 -0
- package/template/wall-e/training/harvester.js +62 -5
- package/template/wall-e/utils/repair.js +253 -1
- package/template/website/index.html +3 -3
- 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 {
|
|
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 =
|
|
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
|
|
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 (
|
|
1768
|
-
else if (
|
|
1769
|
-
else
|
|
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 (
|
|
2062
|
+
if (providerForApiKey === 'openai') {
|
|
1827
2063
|
lines.push(`OPENAI_API_KEY=${apiKey}`);
|
|
1828
2064
|
process.env.OPENAI_API_KEY = apiKey;
|
|
1829
|
-
} else if (
|
|
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 =
|
|
1904
|
-
|
|
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:
|
|
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
|
|
1932
|
-
|
|
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: ' +
|
|
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:
|
|
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)
|
|
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
|
-
|
|
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 &&
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
2228
|
-
const type =
|
|
2229
|
-
|
|
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:
|
|
2564
|
+
res.end(JSON.stringify({ error: selection.error }));
|
|
2232
2565
|
return;
|
|
2233
2566
|
}
|
|
2234
2567
|
|
|
2235
|
-
const
|
|
2568
|
+
const { requestedModel, targetModel } = selection;
|
|
2236
2569
|
if (brain) {
|
|
2237
2570
|
brain.setKv('walle_provider', type);
|
|
2238
|
-
|
|
2239
|
-
|
|
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
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
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
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3735
|
+
const projectPath = decodeProjectEntry(projectEntry);
|
|
3736
|
+
let files;
|
|
3737
|
+
try { files = fs.readdirSync(projectDir); } catch { continue; }
|
|
3348
3738
|
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3811
|
-
|
|
4250
|
+
fileBytesRead = size;
|
|
4251
|
+
_parseJsonlIntoMessages(content, messages);
|
|
3812
4252
|
}
|
|
3813
|
-
bytesRead +=
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
6690
|
-
const
|
|
6691
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
8066
|
-
context: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8088
|
-
|
|
8089
|
-
|
|
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
|
-
|
|
8092
|
-
cost: result.cost,
|
|
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
|
-
|
|
9009
|
+
const cancelEvent = { type: 'cancelled' };
|
|
9010
|
+
broadcastToSession({ type: 'walle-progress', id: session.id, event: cancelEvent });
|
|
9011
|
+
_appendWallePart(session, 'cancelled', {});
|
|
8104
9012
|
} else {
|
|
8105
|
-
broadcastToSession({
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
9769
|
+
res.writeHead(409, WORKTREE_JSON_HEADERS);
|
|
8843
9770
|
return res.end(JSON.stringify(result));
|
|
8844
9771
|
}
|
|
8845
|
-
res.writeHead(result.merged ? 200 : 500,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
9600
|
-
//
|
|
9601
|
-
// chat call.
|
|
9602
|
-
//
|
|
9603
|
-
|
|
9604
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
|
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,
|