@inetafrica/open-claudia 1.20.0 → 2.0.0

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/core/runner.js ADDED
@@ -0,0 +1,683 @@
1
+ // Backend runner — Claude Code, Cursor Agent, OpenAI Codex. Owns
2
+ // argv construction, subprocess lifecycle, stream parsing, and the
3
+ // progress UI. All IO routes through core/io.js so the same runner
4
+ // works on every channel.
5
+
6
+ const { spawn } = require("child_process");
7
+ const {
8
+ CLAUDE_PATH, resolvedCursorPath, resolvedCodexPath,
9
+ AUTO_COMPACT_TOKENS, MAX_PROCESS_TIMEOUT, botSubprocessEnv,
10
+ } = require("./config");
11
+ const { currentState, saveState, recordSession, userOwnsClaudeSession } = require("./state");
12
+ const { chatContext, currentChannelId, currentAdapter } = require("./context");
13
+ const { buildSystemPrompt } = require("./system-prompt");
14
+ const { redactSensitive } = require("./redact");
15
+ const { send, editMessage, sendVoice, splitMessage } = require("./io");
16
+ const { textToVoice, TTS_CMD } = require("./media");
17
+ const { killProcessTree } = require("./process-tree");
18
+ const {
19
+ appendProjectTranscript, transcriptProjectInfo,
20
+ promptWithTranscriptPointer, stripTranscriptPointerForStorage,
21
+ } = require("./transcripts");
22
+ const { getClaudeOAuthToken, claudeAuthRecoveryMessage, isClaudeAuthErrorText, claudeUsageLimitMessage, isClaudeUsageLimitText, runClaudeAuthStatusDiagnostic, claudeSubprocessEnv } = require("./auth-flow");
23
+
24
+ function parseStreamEvents(data) {
25
+ const events = [];
26
+ for (const line of data.split("\n").filter((l) => l.trim())) {
27
+ try { events.push(JSON.parse(line)); } catch (e) { /* partial */ }
28
+ }
29
+ return events;
30
+ }
31
+
32
+ function getActiveBinary() {
33
+ const { settings } = currentState();
34
+ if (settings.backend === "cursor") return resolvedCursorPath;
35
+ if (settings.backend === "codex") return resolvedCodexPath;
36
+ return CLAUDE_PATH;
37
+ }
38
+
39
+ function getActiveSessionId() {
40
+ const state = currentState();
41
+ if (state.settings.backend === "cursor") return state.cursorSessionId;
42
+ if (state.settings.backend === "codex") return state.codexSessionId;
43
+ return state.lastSessionId;
44
+ }
45
+
46
+ function getActiveSessionKey(state = currentState()) {
47
+ if (state.settings.backend === "cursor") return "cursorSessionId";
48
+ if (state.settings.backend === "codex") return "codexSessionId";
49
+ return "lastSessionId";
50
+ }
51
+
52
+ function shouldAutoCompact(state = currentState(), opts = {}) {
53
+ if (opts.skipAutoCompact || opts.fresh || opts.continueSession || state.isCompacting) return false;
54
+ if (!state[getActiveSessionKey(state)]) return false;
55
+ const threshold = Number.isFinite(AUTO_COMPACT_TOKENS) ? AUTO_COMPACT_TOKENS : 280000;
56
+ return (state.sessionUsage?.lastInputTokens || 0) >= threshold;
57
+ }
58
+
59
+ function buildClaudeArgs(prompt, opts = {}) {
60
+ const state = currentState();
61
+ const { settings } = state;
62
+ if (settings.backend === "cursor") return buildCursorArgs(prompt, opts);
63
+ if (settings.backend === "codex") return buildCodexArgs(prompt, opts);
64
+ const args = ["-p", "--verbose", "--output-format", "stream-json",
65
+ "--append-system-prompt", buildSystemPrompt()];
66
+ const transcriptInfo = transcriptProjectInfo(state);
67
+ if (transcriptInfo) args.push("--add-dir", transcriptInfo.transcriptsDir);
68
+ if (opts.resumeSessionId) args.push("--resume", opts.resumeSessionId);
69
+ else if (opts.continueSession) args.push("--continue");
70
+ else if (state.lastSessionId && !opts.fresh) {
71
+ if (userOwnsClaudeSession(state.userId, state.lastSessionId)) {
72
+ args.push("--resume", state.lastSessionId);
73
+ } else {
74
+ console.warn(`[session-guard] dropping stale lastSessionId=${state.lastSessionId.slice(0, 8)} for ${state.userId}; not present in sessions.json — starting fresh`);
75
+ state.lastSessionId = null;
76
+ }
77
+ }
78
+ if (settings.model) args.push("--model", settings.model);
79
+ if (settings.effort) args.push("--effort", settings.effort);
80
+ if (settings.budget) args.push("--max-budget-usd", String(settings.budget));
81
+ if (settings.permissionMode) args.push("--permission-mode", settings.permissionMode);
82
+ else args.push("--dangerously-skip-permissions");
83
+ if (settings.worktree) args.push("--worktree");
84
+ args.push(prompt);
85
+ return args;
86
+ }
87
+
88
+ function buildCursorArgs(prompt, opts = {}) {
89
+ const state = currentState();
90
+ const { settings } = state;
91
+ const args = ["--print", "--trust", "--output-format", "stream-json"];
92
+ if (opts.resumeSessionId) args.push("--resume", opts.resumeSessionId);
93
+ else if (opts.continueSession) args.push("--continue");
94
+ else if (state.cursorSessionId && !opts.fresh) args.push("--resume", state.cursorSessionId);
95
+ if (settings.model) args.push("--model", settings.model);
96
+ if (settings.permissionMode === "plan") args.push("--mode", "plan");
97
+ else if (settings.permissionMode === "ask") args.push("--mode", "ask");
98
+ if (settings.worktree) args.push("--worktree");
99
+ args.push(prompt);
100
+ return args;
101
+ }
102
+
103
+ function buildCodexArgs(prompt, opts = {}) {
104
+ const state = currentState();
105
+ const { settings, codexSessionId } = state;
106
+ const args = [];
107
+ const resumeId = opts.resumeSessionId || ((!opts.fresh && !opts.continueSession) ? codexSessionId : null);
108
+ if (opts.continueSession && codexSessionId) args.push("exec", "resume", codexSessionId);
109
+ else if (resumeId) args.push("exec", "resume", resumeId);
110
+ else args.push("exec");
111
+ args.push("--json", "--skip-git-repo-check");
112
+ const transcriptInfo = transcriptProjectInfo(state);
113
+ if (transcriptInfo) args.push("--add-dir", transcriptInfo.transcriptsDir);
114
+ if (settings.permissionMode === "plan") args.push("--sandbox", "read-only");
115
+ else args.push("--dangerously-bypass-approvals-and-sandbox");
116
+ if (settings.model) args.push("--model", settings.model);
117
+ args.push(promptWithTranscriptPointer(prompt, state));
118
+ return args;
119
+ }
120
+
121
+ function preflightClaudeAuthMessage() {
122
+ const state = currentState();
123
+ if (state.settings.backend !== "claude") return null;
124
+ if (getClaudeOAuthToken().value) return null;
125
+ try {
126
+ const { execSync } = require("child_process");
127
+ const output = execSync(`"${CLAUDE_PATH}" auth status`, {
128
+ cwd: process.env.HOME || require("os").homedir(),
129
+ env: claudeSubprocessEnv(),
130
+ encoding: "utf8",
131
+ timeout: 10000,
132
+ stdio: ["ignore", "pipe", "pipe"],
133
+ });
134
+ if (isClaudeAuthErrorText(output)) return claudeAuthRecoveryMessage(output.trim().slice(-500));
135
+ return null;
136
+ } catch (e) {
137
+ const output = `${e.stdout || ""}\n${e.stderr || ""}\n${e.message || ""}`;
138
+ if (isClaudeAuthErrorText(output)) return claudeAuthRecoveryMessage(output.trim().slice(-500));
139
+ return null;
140
+ }
141
+ }
142
+
143
+ function claudeEmptyFailureMessage(code, stderrText = "") {
144
+ const stderr = redactSensitive(String(stderrText || "").trim());
145
+ if (isClaudeUsageLimitText(stderr)) return claudeUsageLimitMessage(stderr.slice(-1200));
146
+ if (isClaudeAuthErrorText(stderr)) return claudeAuthRecoveryMessage(stderr.slice(-1200));
147
+ const authStatus = runClaudeAuthStatusDiagnostic();
148
+ if (isClaudeAuthErrorText(authStatus)) return claudeAuthRecoveryMessage(authStatus.slice(-1200));
149
+ if (isClaudeUsageLimitText(authStatus)) return claudeUsageLimitMessage(authStatus.slice(-1200));
150
+ return [
151
+ `Claude exited with code ${code} but produced no assistant output.`,
152
+ stderr ? `\nStderr:\n${stderr.slice(-1200)}` : "\nStderr: (empty)",
153
+ authStatus ? `\nAuth status:\n${redactSensitive(authStatus).slice(-1200)}` : "",
154
+ "",
155
+ "Useful next steps:",
156
+ "• /auth_status — verify Claude auth",
157
+ "• /model sonnet — switch away from Opus if usage-limited",
158
+ "• /setup_token — create a launchd-safe OAuth token if Keychain is the issue",
159
+ ].filter(Boolean).join("\n");
160
+ }
161
+
162
+ function compactSummaryPrompt() {
163
+ return [
164
+ "Summarize this conversation for a fresh compacted continuation.",
165
+ "Include only durable context needed to continue effectively:",
166
+ "- current user goal and constraints",
167
+ "- important decisions and preferences",
168
+ "- files/repos touched and current code state",
169
+ "- commands/tests already run and results",
170
+ "- open TODOs, blockers, and exact next step",
171
+ "Do not include secrets, raw tokens, or irrelevant chat transcript.",
172
+ ].join("\n");
173
+ }
174
+
175
+ function compactSeedPrompt(summary) {
176
+ return [
177
+ "This is a compacted continuation of a previous Open Claudia session.",
178
+ "Treat the following summary as the prior conversation context. Do not repeat it back unless asked.",
179
+ "Continue from this state in future turns.",
180
+ "",
181
+ "Compacted summary:",
182
+ summary,
183
+ ].join("\n");
184
+ }
185
+
186
+ function formatProgress(text, tools, currentTool, elapsed, toolDetail) {
187
+ const mins = Math.floor(elapsed / 60);
188
+ const secs = elapsed % 60;
189
+ const time = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
190
+ const parts = [];
191
+ let status = currentTool ? `Working: ${currentTool}` : (tools.length > 0 ? "Processing..." : "Thinking...");
192
+ if (currentTool && toolDetail) status += ` — ${toolDetail}`;
193
+ parts.push(`${status} (${time})`);
194
+ if (tools.length > 1) parts.push(`Steps: ${[...new Set(tools)].join(" > ")}`);
195
+ if (text) parts.push(text.length > 800 ? "..." + text.slice(-800) : text);
196
+ return parts.join("\n\n");
197
+ }
198
+
199
+ async function runClaudeCapture(prompt, cwd, opts = {}) {
200
+ const state = currentState();
201
+ const store = chatContext.getStore();
202
+ if (state.runningProcess) throw new Error("Another task is already running.");
203
+ const authPreflight = preflightClaudeAuthMessage();
204
+ if (authPreflight) throw new Error(authPreflight);
205
+
206
+ return new Promise((resolve, reject) => {
207
+ let assistantText = "";
208
+ let stderrBuffer = "";
209
+ let streamBuffer = "";
210
+ let sessionId = null;
211
+ appendProjectTranscript(opts.transcriptRole || "system-note", stripTranscriptPointerForStorage(prompt), {
212
+ capture: true,
213
+ label: opts.label || null,
214
+ fresh: !!opts.fresh,
215
+ continueSession: !!opts.continueSession,
216
+ resumeSessionId: opts.resumeSessionId || null,
217
+ }, state, getActiveSessionId);
218
+ const args = buildClaudeArgs(prompt, { ...opts, skipAutoCompact: true });
219
+ const proc = spawn(getActiveBinary(), args, {
220
+ cwd,
221
+ env: claudeSubprocessEnv(),
222
+ stdio: ["ignore", "pipe", "pipe"],
223
+ detached: process.platform !== "win32",
224
+ });
225
+ state.runningProcess = proc;
226
+ const timeout = setTimeout(() => {
227
+ if (state.runningProcess === proc) {
228
+ killProcessTree(proc.pid, "SIGTERM");
229
+ setTimeout(() => killProcessTree(proc.pid, "SIGKILL"), 5000);
230
+ }
231
+ }, MAX_PROCESS_TIMEOUT);
232
+
233
+ proc.stdout.on("data", (data) => {
234
+ streamBuffer += data.toString();
235
+ const events = parseStreamEvents(streamBuffer);
236
+ const lastNewline = streamBuffer.lastIndexOf("\n");
237
+ streamBuffer = lastNewline >= 0 ? streamBuffer.slice(lastNewline + 1) : streamBuffer;
238
+ for (const evt of events) {
239
+ if (evt.type === "assistant" && evt.message?.content) {
240
+ for (const block of evt.message.content) {
241
+ if (block.type === "text") assistantText += block.text;
242
+ }
243
+ }
244
+ if (evt.type === "item.completed" && evt.item?.type === "agent_message" && typeof evt.item.text === "string") {
245
+ assistantText += (assistantText ? "\n" : "") + evt.item.text;
246
+ }
247
+ if (evt.type === "thread.started" && evt.thread_id) {
248
+ state.codexSessionId = evt.thread_id;
249
+ sessionId = evt.thread_id;
250
+ saveState();
251
+ }
252
+ if (evt.type === "result" && evt.session_id) {
253
+ if (state.settings.backend === "cursor") state.cursorSessionId = evt.session_id;
254
+ else state.lastSessionId = evt.session_id;
255
+ sessionId = evt.session_id;
256
+ if (evt.usage) {
257
+ const u = state.sessionUsage;
258
+ u.turns += 1;
259
+ u.inputTokens += evt.usage.input_tokens || 0;
260
+ u.outputTokens += evt.usage.output_tokens || 0;
261
+ u.cacheReadTokens += evt.usage.cache_read_input_tokens || 0;
262
+ u.cacheCreationTokens += evt.usage.cache_creation_input_tokens || 0;
263
+ u.lastInputTokens = (evt.usage.input_tokens || 0) +
264
+ (evt.usage.cache_read_input_tokens || 0) +
265
+ (evt.usage.cache_creation_input_tokens || 0);
266
+ }
267
+ if (typeof evt.total_cost_usd === "number") state.sessionUsage.costUsd += evt.total_cost_usd;
268
+ saveState();
269
+ }
270
+ if (evt.type === "result" && evt.result) assistantText = evt.result;
271
+ }
272
+ });
273
+ proc.stderr.on("data", (d) => { stderrBuffer += d.toString(); });
274
+ proc.on("close", (code) => chatContext.run(store, () => {
275
+ if (state.runningProcess === proc) state.runningProcess = null;
276
+ clearTimeout(timeout);
277
+ if (code !== 0 && code !== null) {
278
+ const failureText = claudeEmptyFailureMessage(code, stderrBuffer);
279
+ appendProjectTranscript("system-note", failureText, { capture: true, label: opts.label || null, exitCode: code, kind: "backend-error" }, state, getActiveSessionId);
280
+ reject(new Error(failureText));
281
+ return;
282
+ }
283
+ const cleanText = redactSensitive(assistantText.trim());
284
+ appendProjectTranscript("assistant", cleanText, { capture: true, label: opts.label || null, exitCode: code }, state, getActiveSessionId);
285
+ resolve({ text: cleanText, sessionId });
286
+ }));
287
+ proc.on("error", (err) => chatContext.run(store, () => {
288
+ if (state.runningProcess === proc) state.runningProcess = null;
289
+ clearTimeout(timeout);
290
+ reject(err);
291
+ }));
292
+ });
293
+ }
294
+
295
+ async function compactActiveSession(cwd, opts = {}) {
296
+ const state = currentState();
297
+ const sessionKey = getActiveSessionKey(state);
298
+ const oldSessionId = state[sessionKey];
299
+ if (!oldSessionId) return { compacted: false, reason: "No conversation." };
300
+ if (state.isCompacting) return { compacted: false, reason: "Compaction already in progress." };
301
+
302
+ state.isCompacting = true;
303
+ try {
304
+ if (opts.notify) await send(opts.message || "Context is getting large, compacting first so this stays fast…");
305
+ const summaryRun = await runClaudeCapture(compactSummaryPrompt(), cwd, { resumeSessionId: oldSessionId, skipAutoCompact: true, label: "compact-summary" });
306
+ const summary = summaryRun.text || "No prior context was returned by the summarizer.";
307
+ state[sessionKey] = null;
308
+ state.sessionUsage = { turns: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0, lastInputTokens: 0 };
309
+ state.isFirstMessage = true;
310
+ saveState();
311
+
312
+ const seedRun = await runClaudeCapture(compactSeedPrompt(summary), cwd, { fresh: true, skipAutoCompact: true, label: "compact-seed" });
313
+ const newSessionId = seedRun.sessionId || state[sessionKey];
314
+ if (newSessionId) state[sessionKey] = newSessionId;
315
+ state.isFirstMessage = false;
316
+ state.lastCompactedAt = Date.now();
317
+ state.sessionUsage = { turns: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0, lastInputTokens: 0 };
318
+ saveState();
319
+
320
+ if (newSessionId && state.currentSession) {
321
+ const title = `Compacted ${new Date().toLocaleDateString()}`;
322
+ recordSession(state.userId, state.currentSession.name, newSessionId, title);
323
+ }
324
+ return { compacted: true, oldSessionId, newSessionId, summary };
325
+ } finally {
326
+ state.isCompacting = false;
327
+ saveState();
328
+ }
329
+ }
330
+
331
+ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
332
+ const state = currentState();
333
+ const store = chatContext.getStore();
334
+ const channelId = currentChannelId();
335
+ const adapter = currentAdapter();
336
+ const { settings } = state;
337
+
338
+ if (state.runningProcess) {
339
+ state.messageQueue.push({ prompt, replyToMsgId, opts });
340
+ await send(state.isCompacting ? "Compacting context, will pick this up next…" : "Queued.", { replyTo: replyToMsgId });
341
+ return;
342
+ }
343
+
344
+ const authPreflight = preflightClaudeAuthMessage();
345
+ if (authPreflight) {
346
+ await send(authPreflight, { replyTo: replyToMsgId });
347
+ return;
348
+ }
349
+
350
+ if (shouldAutoCompact(state, opts)) {
351
+ try {
352
+ await compactActiveSession(cwd, {
353
+ notify: true,
354
+ message: "Context is getting large, compacting first so this stays fast…",
355
+ });
356
+ } catch (e) {
357
+ await send(`Compaction failed: ${redactSensitive(e.message)}\nContinuing in the existing session.`, { replyTo: replyToMsgId });
358
+ }
359
+ }
360
+
361
+ appendProjectTranscript("user", prompt, {
362
+ sourceMessageId: replyToMsgId || null,
363
+ fresh: !!opts.fresh,
364
+ continueSession: !!opts.continueSession,
365
+ resumeSessionId: opts.resumeSessionId || null,
366
+ }, state, getActiveSessionId);
367
+
368
+ if (adapter && channelId) adapter.typing(channelId).catch(() => {});
369
+ state.statusMessageId = null;
370
+ state.streamBuffer = "";
371
+ let assistantText = "";
372
+ let toolUses = [];
373
+ let currentTool = null;
374
+ let currentToolDetail = "";
375
+
376
+ const args = buildClaudeArgs(prompt, opts);
377
+ const binaryPath = getActiveBinary();
378
+ const proc = spawn(binaryPath, args, {
379
+ cwd,
380
+ env: claudeSubprocessEnv(),
381
+ stdio: ["ignore", "pipe", "pipe"],
382
+ detached: process.platform !== "win32",
383
+ });
384
+
385
+ state.runningProcess = proc;
386
+ const startTime = Date.now();
387
+ let longRunningNotified = false;
388
+
389
+ const processTimeout = setTimeout(() => {
390
+ if (state.runningProcess === proc) {
391
+ killProcessTree(proc.pid, "SIGTERM");
392
+ setTimeout(() => killProcessTree(proc.pid, "SIGKILL"), 5000);
393
+ chatContext.run(store, () => send(`Task timed out after ${MAX_PROCESS_TIMEOUT / 60000} minutes. Stopped.`));
394
+ }
395
+ }, MAX_PROCESS_TIMEOUT);
396
+
397
+ let lastUpdate = "";
398
+ const scheduleUpdate = () => {
399
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
400
+ const interval = elapsed > 120 ? 5000 : 2000;
401
+ state.streamInterval = setTimeout(() => chatContext.run(store, updateProgress), interval);
402
+ };
403
+ const updateProgress = async () => {
404
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
405
+ try {
406
+ if (adapter && channelId) adapter.typing(channelId).catch(() => {});
407
+ const display = formatProgress(assistantText, toolUses, currentTool, elapsed, currentToolDetail);
408
+ if (display && display !== lastUpdate) {
409
+ if (!state.statusMessageId && assistantText) {
410
+ state.statusMessageId = await send(display.length > 4000 ? display.slice(-4000) : display, { replyTo: replyToMsgId });
411
+ } else if (state.statusMessageId) {
412
+ await editMessage(state.statusMessageId, display.length > 4000 ? display.slice(-4000) : display);
413
+ }
414
+ lastUpdate = display;
415
+ }
416
+ if (elapsed > 300 && !longRunningNotified) {
417
+ longRunningNotified = true;
418
+ await send(`Still working (${Math.floor(elapsed / 60)}min)... Send /stop to cancel.`);
419
+ }
420
+ } catch (e) {
421
+ console.error("Progress update error:", e.message);
422
+ }
423
+ if (state.runningProcess) scheduleUpdate();
424
+ };
425
+ scheduleUpdate();
426
+
427
+ proc.stdout.on("data", (data) => {
428
+ state.streamBuffer += data.toString();
429
+ const events = parseStreamEvents(state.streamBuffer);
430
+ const lastNewline = state.streamBuffer.lastIndexOf("\n");
431
+ state.streamBuffer = lastNewline >= 0 ? state.streamBuffer.slice(lastNewline + 1) : state.streamBuffer;
432
+ for (const evt of events) {
433
+ if (evt.type === "assistant" && evt.message?.content) {
434
+ for (const block of evt.message.content) {
435
+ if (block.type === "text") assistantText += block.text;
436
+ else if (block.type === "tool_use") {
437
+ currentTool = block.name;
438
+ toolUses.push(block.name);
439
+ const input = block.input || {};
440
+ if (block.name === "Bash" && input.command) currentToolDetail = input.command.slice(0, 80);
441
+ else if (block.name === "Read" && input.file_path) currentToolDetail = input.file_path.split("/").slice(-2).join("/");
442
+ else if (block.name === "Edit" && input.file_path) currentToolDetail = input.file_path.split("/").slice(-2).join("/");
443
+ else if (block.name === "Write" && input.file_path) currentToolDetail = input.file_path.split("/").slice(-2).join("/");
444
+ else if (block.name === "Grep" && input.pattern) currentToolDetail = input.pattern.slice(0, 40);
445
+ else if (block.name === "Glob" && input.pattern) currentToolDetail = input.pattern;
446
+ else currentToolDetail = "";
447
+ }
448
+ }
449
+ }
450
+ if (evt.type === "tool_call" && evt.subtype === "started" && evt.tool_call) {
451
+ const tc = evt.tool_call;
452
+ if (tc.shellToolCall) {
453
+ const a = tc.shellToolCall.args || {};
454
+ currentTool = "Shell"; toolUses.push("Shell");
455
+ currentToolDetail = (a.description || a.command || "").slice(0, 80);
456
+ } else if (tc.readToolCall) {
457
+ currentTool = "Read"; toolUses.push("Read");
458
+ currentToolDetail = (tc.readToolCall.args?.path || "").split("/").slice(-2).join("/");
459
+ } else if (tc.editToolCall) {
460
+ currentTool = "Edit"; toolUses.push("Edit");
461
+ currentToolDetail = (tc.editToolCall.args?.filePath || "").split("/").slice(-2).join("/");
462
+ } else if (tc.writeToolCall) {
463
+ currentTool = "Write"; toolUses.push("Write");
464
+ currentToolDetail = (tc.writeToolCall.args?.filePath || "").split("/").slice(-2).join("/");
465
+ } else if (tc.grepToolCall) {
466
+ currentTool = "Grep"; toolUses.push("Grep");
467
+ currentToolDetail = (tc.grepToolCall.args?.pattern || "").slice(0, 40);
468
+ } else if (tc.globToolCall) {
469
+ currentTool = "Glob"; toolUses.push("Glob");
470
+ currentToolDetail = (tc.globToolCall.args?.globPattern || "").slice(0, 40);
471
+ } else if (tc.createPlanToolCall) {
472
+ currentTool = "Plan"; toolUses.push("Plan");
473
+ const plan = tc.createPlanToolCall.args || {};
474
+ let planText = "";
475
+ if (plan.name) planText += `**${plan.name}**\n\n`;
476
+ if (plan.plan) planText += plan.plan + "\n";
477
+ if (plan.todos && plan.todos.length) {
478
+ planText += "\n**Tasks:**\n";
479
+ for (const todo of plan.todos) {
480
+ planText += `• ${todo.content || todo.id}\n`;
481
+ }
482
+ }
483
+ if (planText) assistantText = planText;
484
+ currentToolDetail = plan.name || "creating plan";
485
+ } else {
486
+ const toolKey = Object.keys(tc)[0] || "unknown";
487
+ currentTool = toolKey.replace("ToolCall", ""); toolUses.push(currentTool);
488
+ currentToolDetail = "";
489
+ }
490
+ }
491
+ if (evt.type === "thread.started" && evt.thread_id) {
492
+ state.codexSessionId = evt.thread_id;
493
+ saveState();
494
+ }
495
+ if (evt.type === "item.started" && evt.item) {
496
+ const it = evt.item;
497
+ if (it.type === "command_execution" && it.command) {
498
+ currentTool = "Shell"; toolUses.push("Shell");
499
+ currentToolDetail = String(it.command).slice(0, 80);
500
+ } else if (it.type === "file_change" && (it.path || it.file_path)) {
501
+ currentTool = "Edit"; toolUses.push("Edit");
502
+ const p = it.path || it.file_path;
503
+ currentToolDetail = String(p).split("/").slice(-2).join("/");
504
+ } else if (it.type === "web_search" && it.query) {
505
+ currentTool = "Web"; toolUses.push("Web");
506
+ currentToolDetail = String(it.query).slice(0, 60);
507
+ } else if (it.type) {
508
+ currentTool = it.type; toolUses.push(it.type);
509
+ currentToolDetail = "";
510
+ }
511
+ }
512
+ if (evt.type === "item.completed" && evt.item) {
513
+ const it = evt.item;
514
+ if (it.type === "agent_message" && typeof it.text === "string") {
515
+ assistantText += (assistantText ? "\n" : "") + it.text;
516
+ }
517
+ }
518
+ if (evt.type === "turn.completed" && evt.usage && settings.backend === "codex") {
519
+ const u = state.sessionUsage;
520
+ u.turns += 1;
521
+ const input = evt.usage.input_tokens || 0;
522
+ const cached = evt.usage.cached_input_tokens || 0;
523
+ const output = evt.usage.output_tokens || 0;
524
+ u.inputTokens += input;
525
+ u.outputTokens += output;
526
+ u.cacheReadTokens += cached;
527
+ u.lastInputTokens = input + cached;
528
+ saveState();
529
+ }
530
+ if (evt.type === "result" && evt.session_id) {
531
+ if (settings.backend === "cursor") { state.cursorSessionId = evt.session_id; }
532
+ else { state.lastSessionId = evt.session_id; }
533
+ if (evt.usage) {
534
+ const u = state.sessionUsage;
535
+ u.turns += 1;
536
+ u.inputTokens += evt.usage.input_tokens || 0;
537
+ u.outputTokens += evt.usage.output_tokens || 0;
538
+ u.cacheReadTokens += evt.usage.cache_read_input_tokens || 0;
539
+ u.cacheCreationTokens += evt.usage.cache_creation_input_tokens || 0;
540
+ u.lastInputTokens = (evt.usage.input_tokens || 0) +
541
+ (evt.usage.cache_read_input_tokens || 0) +
542
+ (evt.usage.cache_creation_input_tokens || 0);
543
+ }
544
+ if (typeof evt.total_cost_usd === "number") state.sessionUsage.costUsd += evt.total_cost_usd;
545
+ saveState();
546
+ }
547
+ if (evt.type === "result" && evt.result) assistantText = evt.result;
548
+ }
549
+ });
550
+
551
+ let stderrBuffer = "";
552
+ proc.stderr.on("data", (d) => {
553
+ const chunk = d.toString();
554
+ stderrBuffer += chunk;
555
+ console.error("STDERR:", redactSensitive(chunk));
556
+ });
557
+
558
+ proc.on("close", (code) => chatContext.run(store, async () => {
559
+ state.runningProcess = null;
560
+ clearTimeout(state.streamInterval); state.streamInterval = null;
561
+ clearTimeout(processTimeout);
562
+
563
+ if (settings.backend === "claude" && isClaudeAuthErrorText(stderrBuffer)) {
564
+ const authText = claudeAuthRecoveryMessage(stderrBuffer.trim().slice(-800));
565
+ appendProjectTranscript("system-note", authText, { kind: "auth-error" }, state, getActiveSessionId);
566
+ await send(authText, { replyTo: replyToMsgId });
567
+ return;
568
+ }
569
+ if (settings.backend === "cursor" && isClaudeAuthErrorText(stderrBuffer)) {
570
+ const authText = "Cursor authentication error. Run `agent login` on this machine, then retry.";
571
+ appendProjectTranscript("system-note", authText, { kind: "auth-error" }, state, getActiveSessionId);
572
+ await send(authText, { replyTo: replyToMsgId });
573
+ return;
574
+ }
575
+ if (settings.backend === "codex" && /not (?:logged in|authenticated)|please (?:log|sign) in|401 unauthorized|invalid api key/i.test(stderrBuffer)) {
576
+ const authText = "Codex authentication error. Try /codex_auth_status, then /codex_login or /codex_setup_token.";
577
+ appendProjectTranscript("system-note", authText, { kind: "auth-error" }, state, getActiveSessionId);
578
+ await send(authText, { replyTo: replyToMsgId });
579
+ return;
580
+ }
581
+
582
+ try {
583
+ if (code !== 0 && code !== null && !assistantText.trim()) {
584
+ const failureText = claudeEmptyFailureMessage(code, stderrBuffer);
585
+ appendProjectTranscript("system-note", failureText, { exitCode: code, kind: "backend-error" }, state, getActiveSessionId);
586
+ await send(failureText, { replyTo: replyToMsgId });
587
+ return;
588
+ }
589
+
590
+ const finalText = redactSensitive(assistantText || "(no output)");
591
+ appendProjectTranscript("assistant", finalText, {
592
+ exitCode: code,
593
+ sourceMessageId: state.statusMessageId || null,
594
+ }, state, getActiveSessionId);
595
+ const chunks = splitMessage(finalText);
596
+ const firstChunk = chunks[0];
597
+
598
+ if (state.statusMessageId && chunks.length === 1) {
599
+ await editMessage(state.statusMessageId, firstChunk);
600
+ } else {
601
+ const sent = await send(firstChunk, { replyTo: replyToMsgId });
602
+ if (!sent) await send(firstChunk);
603
+ for (let i = 1; i < chunks.length; i++) {
604
+ await send(chunks[i]);
605
+ }
606
+ }
607
+ if (code !== 0 && code !== null) await send(`Exit code: ${code}`);
608
+
609
+ if (state.lastInputWasVoice && TTS_CMD) {
610
+ state.lastInputWasVoice = false;
611
+ const voicePath = textToVoice(finalText);
612
+ if (voicePath) await sendVoice(voicePath);
613
+ }
614
+ } catch (e) {
615
+ console.error("Final message delivery failed:", e.message);
616
+ await send("Task completed but failed to deliver the response. Send /continue to see the result.");
617
+ }
618
+ if (settings.budget) settings.budget = null;
619
+ state.statusMessageId = null;
620
+
621
+ if (state.lastSessionId && state.currentSession) {
622
+ const title = state.isFirstMessage ? (prompt.length > 60 ? prompt.slice(0, 57) + "..." : prompt) : null;
623
+ recordSession(state.userId, state.currentSession.name, state.lastSessionId, title);
624
+ state.isFirstMessage = false;
625
+ }
626
+
627
+ if (state.messageQueue.length === 0 && state.currentSession && shouldAutoCompact(state)) {
628
+ try {
629
+ await compactActiveSession(state.currentSession.dir, { notify: false });
630
+ } catch (e) {
631
+ console.warn(`[proactive-compact] failed: ${redactSensitive(e.message)}`);
632
+ }
633
+ }
634
+
635
+ if (state.messageQueue.length > 0 && state.currentSession) {
636
+ const next = state.messageQueue.shift();
637
+ await runClaude(next.prompt, state.currentSession.dir, next.replyToMsgId, next.opts);
638
+ }
639
+ }));
640
+
641
+ proc.on("error", (err) => chatContext.run(store, async () => {
642
+ state.runningProcess = null;
643
+ clearTimeout(state.streamInterval);
644
+ clearTimeout(processTimeout);
645
+ await send(`Error: ${err.message}`);
646
+ state.statusMessageId = null;
647
+ }));
648
+ }
649
+
650
+ async function runClaudeSilent(prompt, cwd, label) {
651
+ return new Promise((resolve) => {
652
+ const args = ["-p", "--output-format", "text", "--verbose",
653
+ "--append-system-prompt", buildSystemPrompt(),
654
+ "--dangerously-skip-permissions", prompt];
655
+ const proc = spawn(CLAUDE_PATH, args, {
656
+ cwd, env: claudeSubprocessEnv(),
657
+ stdio: ["ignore", "pipe", "pipe"],
658
+ });
659
+ let output = "";
660
+ proc.stdout.on("data", (d) => { output += d.toString(); });
661
+ proc.on("close", async () => {
662
+ const chunks = splitMessage(`Cron: ${label}\n\n${redactSensitive(output.trim() || "(no output)")}`);
663
+ for (const c of chunks) await send(c);
664
+ resolve();
665
+ });
666
+ proc.on("error", async (err) => { await send(`Cron "${label}" failed: ${err.message}`); resolve(); });
667
+ });
668
+ }
669
+
670
+ module.exports = {
671
+ runClaude,
672
+ runClaudeCapture,
673
+ runClaudeSilent,
674
+ compactActiveSession,
675
+ buildSystemPrompt,
676
+ getActiveSessionId,
677
+ getActiveBinary,
678
+ getActiveSessionKey,
679
+ shouldAutoCompact,
680
+ formatProgress,
681
+ preflightClaudeAuthMessage,
682
+ claudeEmptyFailureMessage,
683
+ };