talon-agent 1.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.
Files changed (89) hide show
  1. package/README.md +137 -0
  2. package/bin/talon.js +5 -0
  3. package/package.json +86 -0
  4. package/prompts/base.md +13 -0
  5. package/prompts/custom.md.example +22 -0
  6. package/prompts/dream.md +41 -0
  7. package/prompts/identity.md +45 -0
  8. package/prompts/teams.md +52 -0
  9. package/prompts/telegram.md +89 -0
  10. package/prompts/terminal.md +13 -0
  11. package/src/__tests__/chat-id.test.ts +91 -0
  12. package/src/__tests__/chat-settings.test.ts +337 -0
  13. package/src/__tests__/config.test.ts +546 -0
  14. package/src/__tests__/cron-store.test.ts +440 -0
  15. package/src/__tests__/daily-log.test.ts +146 -0
  16. package/src/__tests__/dispatcher.test.ts +383 -0
  17. package/src/__tests__/errors.test.ts +240 -0
  18. package/src/__tests__/fuzz.test.ts +302 -0
  19. package/src/__tests__/gateway-actions.test.ts +1453 -0
  20. package/src/__tests__/gateway-context.test.ts +102 -0
  21. package/src/__tests__/gateway-http.test.ts +245 -0
  22. package/src/__tests__/handlers.test.ts +351 -0
  23. package/src/__tests__/history-persistence.test.ts +172 -0
  24. package/src/__tests__/history.test.ts +659 -0
  25. package/src/__tests__/integration.test.ts +189 -0
  26. package/src/__tests__/log.test.ts +110 -0
  27. package/src/__tests__/media-index.test.ts +277 -0
  28. package/src/__tests__/plugin.test.ts +317 -0
  29. package/src/__tests__/prompt-builder.test.ts +71 -0
  30. package/src/__tests__/sessions.test.ts +594 -0
  31. package/src/__tests__/teams-frontend.test.ts +239 -0
  32. package/src/__tests__/telegram.test.ts +177 -0
  33. package/src/__tests__/terminal-commands.test.ts +367 -0
  34. package/src/__tests__/terminal-frontend.test.ts +141 -0
  35. package/src/__tests__/terminal-renderer.test.ts +278 -0
  36. package/src/__tests__/watchdog.test.ts +287 -0
  37. package/src/__tests__/workspace.test.ts +184 -0
  38. package/src/backend/claude-sdk/index.ts +438 -0
  39. package/src/backend/claude-sdk/tools.ts +605 -0
  40. package/src/backend/opencode/index.ts +252 -0
  41. package/src/bootstrap.ts +134 -0
  42. package/src/cli.ts +611 -0
  43. package/src/core/cron.ts +148 -0
  44. package/src/core/dispatcher.ts +126 -0
  45. package/src/core/dream.ts +295 -0
  46. package/src/core/errors.ts +206 -0
  47. package/src/core/gateway-actions.ts +267 -0
  48. package/src/core/gateway.ts +258 -0
  49. package/src/core/plugin.ts +432 -0
  50. package/src/core/prompt-builder.ts +43 -0
  51. package/src/core/pulse.ts +175 -0
  52. package/src/core/types.ts +85 -0
  53. package/src/frontend/teams/actions.ts +101 -0
  54. package/src/frontend/teams/formatting.ts +220 -0
  55. package/src/frontend/teams/graph.ts +297 -0
  56. package/src/frontend/teams/index.ts +308 -0
  57. package/src/frontend/teams/proxy-fetch.ts +28 -0
  58. package/src/frontend/teams/tools.ts +177 -0
  59. package/src/frontend/telegram/actions.ts +437 -0
  60. package/src/frontend/telegram/admin.ts +178 -0
  61. package/src/frontend/telegram/callbacks.ts +251 -0
  62. package/src/frontend/telegram/commands.ts +543 -0
  63. package/src/frontend/telegram/formatting.ts +101 -0
  64. package/src/frontend/telegram/handlers.ts +1008 -0
  65. package/src/frontend/telegram/helpers.ts +105 -0
  66. package/src/frontend/telegram/index.ts +130 -0
  67. package/src/frontend/telegram/middleware.ts +177 -0
  68. package/src/frontend/telegram/userbot.ts +546 -0
  69. package/src/frontend/terminal/commands.ts +303 -0
  70. package/src/frontend/terminal/index.ts +282 -0
  71. package/src/frontend/terminal/input.ts +297 -0
  72. package/src/frontend/terminal/renderer.ts +248 -0
  73. package/src/index.ts +144 -0
  74. package/src/login.ts +89 -0
  75. package/src/storage/chat-settings.ts +218 -0
  76. package/src/storage/cron-store.ts +165 -0
  77. package/src/storage/daily-log.ts +97 -0
  78. package/src/storage/history.ts +278 -0
  79. package/src/storage/media-index.ts +116 -0
  80. package/src/storage/sessions.ts +328 -0
  81. package/src/util/chat-id.ts +21 -0
  82. package/src/util/config.ts +244 -0
  83. package/src/util/log.ts +122 -0
  84. package/src/util/paths.ts +80 -0
  85. package/src/util/time.ts +86 -0
  86. package/src/util/trace.ts +35 -0
  87. package/src/util/watchdog.ts +108 -0
  88. package/src/util/workspace.ts +208 -0
  89. package/tsconfig.json +13 -0
@@ -0,0 +1,184 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { mkdirSync, writeFileSync, rmSync, existsSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+
6
+ // Mock log to prevent pino initialization issues
7
+ vi.mock("../util/log.js", () => ({
8
+ log: vi.fn(),
9
+ logError: vi.fn(),
10
+ logWarn: vi.fn(),
11
+ logDebug: vi.fn(),
12
+ }));
13
+
14
+ import {
15
+ initWorkspace,
16
+ getWorkspaceDiskUsage,
17
+ cleanupUploads,
18
+ startUploadCleanup,
19
+ stopUploadCleanup,
20
+ } from "../util/workspace.js";
21
+
22
+ const TEST_ROOT = join(tmpdir(), `talon-ws-test-${Date.now()}`);
23
+
24
+ beforeEach(() => {
25
+ if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true });
26
+ });
27
+
28
+ afterEach(() => {
29
+ stopUploadCleanup();
30
+ if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true });
31
+ });
32
+
33
+ describe("initWorkspace", () => {
34
+ it("creates root directory if missing", () => {
35
+ initWorkspace(TEST_ROOT);
36
+ expect(existsSync(TEST_ROOT)).toBe(true);
37
+ });
38
+
39
+ it("is idempotent", () => {
40
+ mkdirSync(TEST_ROOT, { recursive: true });
41
+ expect(() => initWorkspace(TEST_ROOT)).not.toThrow();
42
+ });
43
+ });
44
+
45
+ describe("getWorkspaceDiskUsage", () => {
46
+ it("returns 0 for empty directory", () => {
47
+ mkdirSync(TEST_ROOT, { recursive: true });
48
+ expect(getWorkspaceDiskUsage(TEST_ROOT)).toBe(0);
49
+ });
50
+
51
+ it("sums file sizes recursively", () => {
52
+ mkdirSync(TEST_ROOT, { recursive: true });
53
+ const sub = join(TEST_ROOT, "sub");
54
+ mkdirSync(sub);
55
+ writeFileSync(join(TEST_ROOT, "a.txt"), "hello");
56
+ writeFileSync(join(sub, "b.txt"), "world!");
57
+ expect(getWorkspaceDiskUsage(TEST_ROOT)).toBe(11);
58
+ });
59
+
60
+ it("returns 0 for non-existent directory", () => {
61
+ expect(getWorkspaceDiskUsage(join(TEST_ROOT, "nope"))).toBe(0);
62
+ });
63
+
64
+ it("handles deeply nested directories", () => {
65
+ mkdirSync(TEST_ROOT, { recursive: true });
66
+ const deep = join(TEST_ROOT, "a", "b", "c");
67
+ mkdirSync(deep, { recursive: true });
68
+ writeFileSync(join(deep, "deep.txt"), "deep content here");
69
+ expect(getWorkspaceDiskUsage(TEST_ROOT)).toBe(17);
70
+ });
71
+
72
+ it("ignores symlink or special entries gracefully", () => {
73
+ mkdirSync(TEST_ROOT, { recursive: true });
74
+ writeFileSync(join(TEST_ROOT, "normal.txt"), "hi");
75
+ // Should not throw
76
+ expect(getWorkspaceDiskUsage(TEST_ROOT)).toBeGreaterThan(0);
77
+ });
78
+ });
79
+
80
+ describe("cleanupUploads", () => {
81
+ it("returns 0 if uploads dir doesn't exist", () => {
82
+ mkdirSync(TEST_ROOT, { recursive: true });
83
+ expect(cleanupUploads(TEST_ROOT)).toBe(0);
84
+ });
85
+
86
+ it("deletes files older than maxAgeMs", async () => {
87
+ const uploadsDir = join(TEST_ROOT, "uploads");
88
+ mkdirSync(uploadsDir, { recursive: true });
89
+ writeFileSync(join(uploadsDir, "old.jpg"), "data");
90
+
91
+ // Wait 10ms so the file has age > 0, then cleanup with maxAge=1
92
+ await new Promise((r) => setTimeout(r, 10));
93
+ expect(cleanupUploads(TEST_ROOT, 1)).toBe(1);
94
+ expect(readdirSync(uploadsDir)).toHaveLength(0);
95
+ });
96
+
97
+ it("keeps recent files", () => {
98
+ const uploadsDir = join(TEST_ROOT, "uploads");
99
+ mkdirSync(uploadsDir, { recursive: true });
100
+ writeFileSync(join(uploadsDir, "new.jpg"), "fresh");
101
+
102
+ // 1 hour window — just-created file survives
103
+ expect(cleanupUploads(TEST_ROOT, 3_600_000)).toBe(0);
104
+ expect(readdirSync(uploadsDir)).toHaveLength(1);
105
+ });
106
+
107
+ it("skips subdirectories in uploads", () => {
108
+ const uploadsDir = join(TEST_ROOT, "uploads");
109
+ mkdirSync(uploadsDir, { recursive: true });
110
+ mkdirSync(join(uploadsDir, "subdir"));
111
+ writeFileSync(join(uploadsDir, "file.jpg"), "data");
112
+
113
+ // subdir should be skipped (isFile check)
114
+ expect(cleanupUploads(TEST_ROOT, 3_600_000)).toBe(0);
115
+ });
116
+
117
+ it("uses default maxAgeMs when not specified", async () => {
118
+ const uploadsDir = join(TEST_ROOT, "uploads");
119
+ mkdirSync(uploadsDir, { recursive: true });
120
+ writeFileSync(join(uploadsDir, "recent.jpg"), "data");
121
+
122
+ // Default maxAge is 7 days, so a recent file should survive
123
+ expect(cleanupUploads(TEST_ROOT)).toBe(0);
124
+ expect(readdirSync(uploadsDir)).toHaveLength(1);
125
+ });
126
+
127
+ it("deletes multiple old files", async () => {
128
+ const uploadsDir = join(TEST_ROOT, "uploads");
129
+ mkdirSync(uploadsDir, { recursive: true });
130
+ writeFileSync(join(uploadsDir, "a.jpg"), "aaa");
131
+ writeFileSync(join(uploadsDir, "b.jpg"), "bbb");
132
+ writeFileSync(join(uploadsDir, "c.jpg"), "ccc");
133
+
134
+ await new Promise((r) => setTimeout(r, 10));
135
+ expect(cleanupUploads(TEST_ROOT, 1)).toBe(3);
136
+ expect(readdirSync(uploadsDir)).toHaveLength(0);
137
+ });
138
+ });
139
+
140
+ describe("startUploadCleanup / stopUploadCleanup", () => {
141
+ it("starts periodic cleanup without throwing", () => {
142
+ mkdirSync(TEST_ROOT, { recursive: true });
143
+ expect(() => startUploadCleanup(TEST_ROOT)).not.toThrow();
144
+ });
145
+
146
+ it("is idempotent (calling twice does not create multiple timers)", () => {
147
+ mkdirSync(TEST_ROOT, { recursive: true });
148
+ startUploadCleanup(TEST_ROOT);
149
+ startUploadCleanup(TEST_ROOT); // second call should be no-op
150
+ stopUploadCleanup();
151
+ });
152
+
153
+ it("stopUploadCleanup is safe when not started", () => {
154
+ expect(() => stopUploadCleanup()).not.toThrow();
155
+ });
156
+
157
+ it("can restart after stopping", () => {
158
+ mkdirSync(TEST_ROOT, { recursive: true });
159
+ startUploadCleanup(TEST_ROOT);
160
+ stopUploadCleanup();
161
+ expect(() => startUploadCleanup(TEST_ROOT)).not.toThrow();
162
+ stopUploadCleanup();
163
+ });
164
+
165
+ it("runs immediate cleanup on start", async () => {
166
+ const uploadsDir = join(TEST_ROOT, "uploads");
167
+ mkdirSync(uploadsDir, { recursive: true });
168
+ writeFileSync(join(uploadsDir, "old.jpg"), "data");
169
+
170
+ await new Promise((r) => setTimeout(r, 10));
171
+ // startUploadCleanup calls cleanupUploads immediately but with default 7-day maxAge
172
+ // so a fresh file won't be deleted. Just verify it doesn't throw.
173
+ startUploadCleanup(TEST_ROOT);
174
+ expect(existsSync(join(uploadsDir, "old.jpg"))).toBe(true);
175
+ stopUploadCleanup();
176
+ });
177
+
178
+ it("stopUploadCleanup clears the timer (calling twice is safe)", () => {
179
+ mkdirSync(TEST_ROOT, { recursive: true });
180
+ startUploadCleanup(TEST_ROOT);
181
+ stopUploadCleanup();
182
+ expect(() => stopUploadCleanup()).not.toThrow();
183
+ });
184
+ });
@@ -0,0 +1,438 @@
1
+ import { query } from "@anthropic-ai/claude-agent-sdk";
2
+ import type { TalonConfig } from "../../util/config.js";
3
+ import {
4
+ getSession,
5
+ incrementTurns,
6
+ recordUsage,
7
+ resetSession,
8
+ setSessionId,
9
+ setSessionName,
10
+ } from "../../storage/sessions.js";
11
+ import { getChatSettings, setChatModel } from "../../storage/chat-settings.js";
12
+ import { getRecentHistory } from "../../storage/history.js";
13
+ import { resolve } from "node:path";
14
+ import { classify } from "../../core/errors.js";
15
+ import {
16
+ getPluginMcpServers,
17
+ getPluginPromptAdditions,
18
+ } from "../../core/plugin.js";
19
+ import { rebuildSystemPrompt } from "../../util/config.js";
20
+ import { log, logError, logWarn } from "../../util/log.js";
21
+ import { traceMessage } from "../../util/trace.js";
22
+ import { formatSmartTimestamp, formatFullDatetime } from "../../util/time.js";
23
+
24
+ import type { QueryParams, QueryResult } from "../../core/types.js";
25
+
26
+ // ── State ────────────────────────────────────────────────────────────────────
27
+
28
+ let config: TalonConfig;
29
+ let bridgePortFn: () => number = () => 19876;
30
+
31
+ export function initAgent(
32
+ cfg: TalonConfig,
33
+ getBridgePort?: () => number,
34
+ ): void {
35
+ config = cfg;
36
+ if (getBridgePort) bridgePortFn = getBridgePort;
37
+
38
+ // The Agent SDK spawns an embedded Claude Code subprocess.
39
+ // If CLAUDECODE is set (e.g. running from a Claude Code terminal),
40
+ // the subprocess refuses to start with a nested-session error that
41
+ // gets swallowed — causing an infinite hang on Windows.
42
+ delete process.env.CLAUDECODE;
43
+ }
44
+
45
+ // ── Main handler ─────────────────────────────────────────────────────────────
46
+
47
+ export async function handleMessage(
48
+ params: QueryParams,
49
+ _retried = false,
50
+ ): Promise<QueryResult> {
51
+ if (!config)
52
+ throw new Error("Agent not initialized. Call initAgent() first.");
53
+
54
+ const {
55
+ chatId,
56
+ text,
57
+ senderName,
58
+ isGroup,
59
+ onTextBlock,
60
+ onStreamDelta,
61
+ onToolUse,
62
+ } = params;
63
+ const session = getSession(chatId);
64
+ const t0 = Date.now();
65
+
66
+ // Rebuild system prompt on first turn of a new/reset session so identity,
67
+ // memory, and workspace listing are fresh
68
+ if (session.turns === 0) {
69
+ rebuildSystemPrompt(config, getPluginPromptAdditions());
70
+ }
71
+
72
+ // Per-chat settings override global config
73
+ const chatSettings = getChatSettings(chatId);
74
+ const activeModel = chatSettings.model ?? config.model;
75
+ const activeEffort = chatSettings.effort ?? "adaptive";
76
+
77
+ const EFFORT_MAP: Record<
78
+ string,
79
+ {
80
+ thinking: { type: "adaptive" | "disabled" };
81
+ effort?: "low" | "medium" | "high" | "max";
82
+ }
83
+ > = {
84
+ off: { thinking: { type: "disabled" } },
85
+ low: { thinking: { type: "adaptive" }, effort: "low" },
86
+ medium: { thinking: { type: "adaptive" }, effort: "medium" },
87
+ high: { thinking: { type: "adaptive" }, effort: "high" },
88
+ max: { thinking: { type: "adaptive" }, effort: "max" },
89
+ };
90
+ const thinkingConfig = EFFORT_MAP[activeEffort] ?? {
91
+ thinking: { type: "adaptive" as const },
92
+ };
93
+
94
+ const options = {
95
+ model: activeModel,
96
+ systemPrompt: config.systemPrompt,
97
+ cwd: config.workspace,
98
+ permissionMode: "bypassPermissions" as const,
99
+ allowDangerouslySkipPermissions: true,
100
+ betas: ["context-1m-2025-08-07"],
101
+ ...(config.claudeBinary
102
+ ? { pathToClaudeCodeExecutable: config.claudeBinary }
103
+ : {}),
104
+ disallowedTools: [
105
+ "EnterPlanMode",
106
+ "ExitPlanMode",
107
+ "EnterWorktree",
108
+ "ExitWorktree",
109
+ "TodoWrite",
110
+ "TodoRead",
111
+ "TaskCreate",
112
+ "TaskUpdate",
113
+ "TaskGet",
114
+ "TaskList",
115
+ "TaskOutput",
116
+ "TaskStop",
117
+ "AskUserQuestion",
118
+ ],
119
+ ...thinkingConfig,
120
+ mcpServers: {
121
+ // Register frontend-specific MCP tools based on active frontend
122
+ ...(() => {
123
+ const frontends = Array.isArray(config.frontend)
124
+ ? config.frontend
125
+ : [config.frontend];
126
+ const bridgeUrl = `http://127.0.0.1:${bridgePortFn()}`;
127
+ const mcpEnv = { TALON_BRIDGE_URL: bridgeUrl, TALON_CHAT_ID: chatId };
128
+ const servers: Record<
129
+ string,
130
+ { command: string; args: string[]; env: Record<string, string> }
131
+ > = {};
132
+ // Resolve tsx from Talon's node_modules (cwd may be ~/.talon/workspace/ which has no node_modules)
133
+ // Resolve tsx from the package root (3 levels up from src/backend/claude-sdk/)
134
+ const tsxImport = resolve(
135
+ import.meta.dirname ?? ".",
136
+ "../../../node_modules/tsx/dist/esm/index.mjs",
137
+ );
138
+
139
+ if (frontends.includes("telegram")) {
140
+ servers["telegram-tools"] = {
141
+ command: process.platform === "win32" ? "npx" : "node",
142
+ args:
143
+ process.platform === "win32"
144
+ ? ["tsx", resolve(import.meta.dirname ?? ".", "tools.ts")]
145
+ : [
146
+ "--import",
147
+ tsxImport,
148
+ resolve(import.meta.dirname ?? ".", "tools.ts"),
149
+ ],
150
+ env: mcpEnv,
151
+ };
152
+ }
153
+ if (frontends.includes("teams")) {
154
+ servers["teams-tools"] = {
155
+ command: process.platform === "win32" ? "npx" : "node",
156
+ args:
157
+ process.platform === "win32"
158
+ ? [
159
+ "tsx",
160
+ resolve(
161
+ import.meta.dirname ?? ".",
162
+ "../../frontend/teams/tools.ts",
163
+ ),
164
+ ]
165
+ : [
166
+ "--import",
167
+ tsxImport,
168
+ resolve(
169
+ import.meta.dirname ?? ".",
170
+ "../../frontend/teams/tools.ts",
171
+ ),
172
+ ],
173
+ env: mcpEnv,
174
+ };
175
+ }
176
+ return servers;
177
+ })(),
178
+ ...getPluginMcpServers(`http://127.0.0.1:${bridgePortFn()}`, chatId),
179
+ },
180
+ ...(session.sessionId ? { resume: session.sessionId } : {}),
181
+ };
182
+
183
+ const msgIdHint = params.messageId ? ` [msg_id:${params.messageId}]` : "";
184
+ const nowTag = `[${formatFullDatetime()}]`;
185
+
186
+ // Session continuity: when resuming a session that has history but no active
187
+ // SDK session (after restart or /resume), prepend recent messages for context.
188
+ let continuityPrefix = "";
189
+ if (!session.sessionId && session.turns > 0) {
190
+ const recentMsgs = getRecentHistory(chatId, 10);
191
+ if (recentMsgs.length > 0) {
192
+ const contextLines = recentMsgs
193
+ .map((m) => {
194
+ const time = formatSmartTimestamp(m.timestamp);
195
+ return `[${time}] ${m.senderName}: ${m.text.slice(0, 300)}`;
196
+ })
197
+ .join("\n");
198
+ continuityPrefix = `[Session resumed — recent conversation context:\n${contextLines}]\n\n`;
199
+ }
200
+ }
201
+
202
+ const prompt = isGroup
203
+ ? `${continuityPrefix}${nowTag} [${senderName}]${msgIdHint}: ${text}`
204
+ : `${continuityPrefix}${nowTag}${msgIdHint} ${text}`;
205
+ log("agent", `[${chatId}] <- (${text.length} chars)`);
206
+ traceMessage(chatId, "in", text, { senderName, isGroup });
207
+
208
+ // SDK types are not fully exported; cast options at the boundary
209
+ const qi = query({
210
+ prompt,
211
+ options: options as Parameters<typeof query>[0]["options"],
212
+ });
213
+
214
+ let currentBlockText = "";
215
+ let allResponseText = "";
216
+ let newSessionId: string | undefined;
217
+ let inputTokens = 0;
218
+ let outputTokens = 0;
219
+ let cacheRead = 0;
220
+ let cacheWrite = 0;
221
+ let toolCalls = 0;
222
+
223
+ // Streaming throttle
224
+ let lastStreamUpdate = 0;
225
+ const STREAM_INTERVAL = 1000;
226
+
227
+ try {
228
+ for await (const message of qi) {
229
+ const msg = message as Record<string, unknown>;
230
+ const type = msg.type as string;
231
+
232
+ // Session ID capture
233
+ if (
234
+ type === "system" &&
235
+ msg.subtype === "init" &&
236
+ typeof msg.session_id === "string"
237
+ ) {
238
+ newSessionId = msg.session_id;
239
+ }
240
+
241
+ // Stream text deltas and thinking deltas
242
+ if (type === "stream_event" && onStreamDelta) {
243
+ const event = msg.event as Record<string, unknown> | undefined;
244
+ if (event?.type === "content_block_delta") {
245
+ const delta = event.delta as Record<string, unknown> | undefined;
246
+ if (
247
+ delta?.type === "thinking_delta" &&
248
+ typeof delta.thinking === "string"
249
+ ) {
250
+ // Thinking phase: notify but don't accumulate text
251
+ const now = Date.now();
252
+ if (now - lastStreamUpdate >= STREAM_INTERVAL) {
253
+ lastStreamUpdate = now;
254
+ onStreamDelta(currentBlockText, "thinking");
255
+ }
256
+ } else if (
257
+ delta?.type === "text_delta" &&
258
+ typeof delta.text === "string"
259
+ ) {
260
+ currentBlockText += delta.text;
261
+ const now = Date.now();
262
+ if (now - lastStreamUpdate >= STREAM_INTERVAL) {
263
+ lastStreamUpdate = now;
264
+ onStreamDelta(currentBlockText, "text");
265
+ }
266
+ }
267
+ }
268
+ }
269
+
270
+ // Complete assistant message — may contain multiple text blocks
271
+ // and tool_use blocks. Each text block before a tool_use is a
272
+ // "progress message" that should be sent immediately.
273
+ if (type === "assistant") {
274
+ const content = (msg.message as { content?: unknown[] })?.content;
275
+ if (Array.isArray(content)) {
276
+ let blockText = "";
277
+ for (const block of content) {
278
+ const b = block as { type: string; text?: string; name?: string };
279
+ if (b.type === "text" && b.text) {
280
+ blockText += b.text;
281
+ }
282
+ if (b.type === "tool_use") {
283
+ toolCalls++;
284
+ const tb = block as {
285
+ type: string;
286
+ name?: string;
287
+ input?: Record<string, unknown>;
288
+ };
289
+ if (onToolUse && tb.name) {
290
+ try {
291
+ onToolUse(tb.name, tb.input ?? {});
292
+ } catch {
293
+ /* non-fatal */
294
+ }
295
+ }
296
+ // If there's text before this tool call, send it as a progress message
297
+ if (blockText.trim() && onTextBlock) {
298
+ try {
299
+ await onTextBlock(blockText.trim());
300
+ } catch {
301
+ /* non-fatal — don't abort the stream loop */
302
+ }
303
+ allResponseText += blockText;
304
+ blockText = "";
305
+ currentBlockText = "";
306
+ }
307
+ }
308
+ }
309
+ // Remaining text after all tool calls (or if no tool calls)
310
+ if (blockText.trim()) {
311
+ currentBlockText = blockText;
312
+ }
313
+ }
314
+ }
315
+
316
+ // Final result
317
+ if (type === "result") {
318
+ const usage = msg.usage as Record<string, number> | undefined;
319
+ if (usage) {
320
+ inputTokens = usage.input_tokens ?? 0;
321
+ outputTokens = usage.output_tokens ?? 0;
322
+ cacheRead = usage.cache_read_input_tokens ?? 0;
323
+ cacheWrite = usage.cache_creation_input_tokens ?? 0;
324
+ }
325
+ // If we still have unsent text and no streaming captured it
326
+ if (
327
+ !allResponseText &&
328
+ !currentBlockText &&
329
+ typeof msg.result === "string"
330
+ ) {
331
+ currentBlockText = msg.result;
332
+ }
333
+ }
334
+ }
335
+ } catch (err) {
336
+ const classified = classify(err);
337
+ if (classified.reason === "session_expired" && !_retried) {
338
+ logWarn(
339
+ "agent",
340
+ `[${chatId}] Stale session, retrying with fresh session`,
341
+ );
342
+ resetSession(chatId);
343
+ return handleMessage(params, true);
344
+ }
345
+ // Context length exceeded — reset session and retry (SDK auto-compaction should prevent
346
+ // this, but handle it as a safety net for edge cases)
347
+ if (classified.reason === "context_length" && !_retried) {
348
+ logWarn(
349
+ "agent",
350
+ `[${chatId}] Context length exceeded, resetting session and retrying`,
351
+ );
352
+ resetSession(chatId);
353
+ return handleMessage(params, true);
354
+ }
355
+ // Model fallback: if overloaded/timeout, retry with a faster model
356
+ if (!_retried && classified.retryable) {
357
+ const fallbackModel = activeModel.includes("opus")
358
+ ? "claude-sonnet-4-6"
359
+ : activeModel.includes("sonnet")
360
+ ? "claude-haiku-4-5"
361
+ : null;
362
+ if (fallbackModel) {
363
+ logWarn(
364
+ "agent",
365
+ `[${chatId}] ${classified.reason}, falling back to ${fallbackModel.replace("claude-", "")}`,
366
+ );
367
+ resetSession(chatId);
368
+ const originalModel = getChatSettings(chatId).model;
369
+ setChatModel(chatId, fallbackModel);
370
+ try {
371
+ return await handleMessage(params, true);
372
+ } finally {
373
+ setChatModel(chatId, originalModel);
374
+ }
375
+ }
376
+ }
377
+ logError("agent", `[${chatId}] SDK error: ${classified.message}`);
378
+ throw classified;
379
+ }
380
+
381
+ // Persist session and usage
382
+ const durationMs = Date.now() - t0;
383
+ if (newSessionId) setSessionId(chatId, newSessionId);
384
+ incrementTurns(chatId);
385
+ recordUsage(chatId, {
386
+ inputTokens,
387
+ outputTokens,
388
+ cacheRead,
389
+ cacheWrite,
390
+ durationMs,
391
+ model: activeModel,
392
+ });
393
+
394
+ // Set a descriptive session name from the first message
395
+ if (session.turns === 0 && text) {
396
+ // Strip metadata prefixes like [DM from ...] or [Name]:
397
+ const cleanText = text
398
+ .replace(/^\[.*?\]\s*/g, "")
399
+ .replace(/\[msg_id:\d+\]\s*/g, "")
400
+ .trim();
401
+ if (cleanText) {
402
+ const name =
403
+ cleanText.length > 30 ? cleanText.slice(0, 30) + "..." : cleanText;
404
+ setSessionName(chatId, name);
405
+ }
406
+ }
407
+
408
+ // The remaining currentBlockText is the final response text
409
+ allResponseText += currentBlockText;
410
+
411
+ const totalPrompt = inputTokens + cacheRead + cacheWrite;
412
+ const cacheHitPct =
413
+ totalPrompt > 0 ? Math.round((cacheRead / totalPrompt) * 100) : 0;
414
+
415
+ log(
416
+ "agent",
417
+ `[${chatId}] -> (${durationMs}ms, in=${inputTokens} out=${outputTokens} cache=${cacheHitPct}%` +
418
+ `${toolCalls > 0 ? ` tools=${toolCalls}` : ""})`,
419
+ );
420
+ traceMessage(chatId, "out", allResponseText, {
421
+ durationMs,
422
+ inputTokens,
423
+ outputTokens,
424
+ cacheRead,
425
+ cacheWrite,
426
+ toolCalls,
427
+ model: activeModel,
428
+ });
429
+
430
+ return {
431
+ text: allResponseText.trim(),
432
+ durationMs,
433
+ inputTokens,
434
+ outputTokens,
435
+ cacheRead,
436
+ cacheWrite,
437
+ };
438
+ }