talon-agent 1.4.0 → 1.5.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.
@@ -41,34 +41,15 @@ export function initAgent(
41
41
  delete process.env.CLAUDECODE;
42
42
  }
43
43
 
44
- // ── Main handler ─────────────────────────────────────────────────────────────
45
-
46
- export async function handleMessage(
47
- params: QueryParams,
48
- _retried = false,
49
- ): Promise<QueryResult> {
50
- if (!config)
51
- throw new Error("Agent not initialized. Call initAgent() first.");
44
+ /** Update the system prompt on the live config. Used by plugin hot-reload
45
+ * so the next message picks up new plugin tool descriptions. */
46
+ export function updateSystemPrompt(prompt: string): void {
47
+ if (config) config.systemPrompt = prompt;
48
+ }
52
49
 
53
- const {
54
- chatId,
55
- text,
56
- senderName,
57
- isGroup,
58
- onTextBlock,
59
- onStreamDelta,
60
- onToolUse,
61
- } = params;
62
- const session = getSession(chatId);
63
- const t0 = Date.now();
50
+ // ── Shared options builder ───────────────────────────────────────────────────
64
51
 
65
- // Rebuild system prompt on first turn of a new/reset session so identity,
66
- // memory, and workspace listing are fresh
67
- if (session.turns === 0) {
68
- rebuildSystemPrompt(config, getPluginPromptAdditions());
69
- }
70
-
71
- // Per-chat settings override global config
52
+ function buildSdkOptions(chatId: string) {
72
53
  const chatSettings = getChatSettings(chatId);
73
54
  const activeModel = chatSettings.model ?? config.model;
74
55
  const activeEffort = chatSettings.effort ?? "adaptive";
@@ -90,13 +71,18 @@ export async function handleMessage(
90
71
  thinking: { type: "adaptive" as const },
91
72
  };
92
73
 
74
+ const supports1m =
75
+ !activeModel.includes("haiku") && !activeModel.includes("[1m]");
76
+ const sdkModel = supports1m ? `${activeModel}[1m]` : activeModel;
77
+
78
+ const session = getSession(chatId);
79
+
93
80
  const options = {
94
- model: activeModel,
81
+ model: sdkModel,
95
82
  systemPrompt: config.systemPrompt,
96
83
  cwd: config.workspace,
97
84
  permissionMode: "bypassPermissions" as const,
98
85
  allowDangerouslySkipPermissions: true,
99
- betas: ["context-1m-2025-08-07"],
100
86
  ...(config.claudeBinary
101
87
  ? { pathToClaudeCodeExecutable: config.claudeBinary }
102
88
  : {}),
@@ -114,16 +100,11 @@ export async function handleMessage(
114
100
  "TaskOutput",
115
101
  "TaskStop",
116
102
  "AskUserQuestion",
117
- // Always disable Claude Code built-in web tools — fetch_url is always
118
- // available, and Brave Search MCP replaces WebSearch when configured.
119
103
  "WebSearch",
120
104
  "WebFetch",
121
105
  ],
122
106
  ...thinkingConfig,
123
107
  mcpServers: {
124
- // Register unified MCP tools server — one per messaging frontend.
125
- // Terminal frontend relies on Claude Code built-in tools (Read, Write,
126
- // Bash, etc.) and doesn't need a custom MCP tools server.
127
108
  ...(() => {
128
109
  const allFrontends = Array.isArray(config.frontend)
129
110
  ? config.frontend
@@ -134,12 +115,10 @@ export async function handleMessage(
134
115
  string,
135
116
  { command: string; args: string[]; env: Record<string, string> }
136
117
  > = {};
137
- // Resolve tsx from the package root (3 levels up from src/backend/claude-sdk/)
138
118
  const tsxImport = resolve(
139
119
  import.meta.dirname ?? ".",
140
120
  "../../../node_modules/tsx/dist/esm/index.mjs",
141
121
  );
142
- // Unified MCP server in core/tools/
143
122
  const mcpServerPath = resolve(
144
123
  import.meta.dirname ?? ".",
145
124
  "../../core/tools/mcp-server.ts",
@@ -163,7 +142,6 @@ export async function handleMessage(
163
142
  }
164
143
  return servers;
165
144
  })(),
166
- // Brave Search MCP server — provides brave_web_search and brave_local_search
167
145
  ...(config.braveApiKey
168
146
  ? {
169
147
  "brave-search": {
@@ -181,6 +159,107 @@ export async function handleMessage(
181
159
  ...(session.sessionId ? { resume: session.sessionId } : {}),
182
160
  };
183
161
 
162
+ return { options, activeModel, session };
163
+ }
164
+
165
+ // ── Session warm-up ─────────────────────────────────────────────────────────
166
+
167
+ /**
168
+ * Cold-start a session by spawning an SDK subprocess in streaming input mode,
169
+ * calling getContextUsage() to populate contextWindow and baseline contextTokens,
170
+ * then tearing it down. Fire-and-forget — does not block the caller.
171
+ */
172
+ export async function warmSession(chatId: string): Promise<void> {
173
+ if (!config) return;
174
+ const abort = new AbortController();
175
+ try {
176
+ rebuildSystemPrompt(config, getPluginPromptAdditions());
177
+ const { options } = buildSdkOptions(chatId);
178
+
179
+ // Streaming input mode: pass an async iterable that never yields a user message
180
+ const neverYield = async function* (): AsyncGenerator<never> {
181
+ await new Promise<never>((_, reject) => {
182
+ abort.signal.addEventListener("abort", () =>
183
+ reject(new Error("aborted")),
184
+ );
185
+ });
186
+ };
187
+
188
+ const q = query({
189
+ prompt: neverYield(),
190
+ options: {
191
+ ...options,
192
+ abortController: abort,
193
+ } as Parameters<typeof query>[0]["options"],
194
+ });
195
+
196
+ // Drain the stream in the background so the SDK's internal message loop
197
+ // doesn't stall — control responses are processed in readMessages() which
198
+ // needs the inputStream consumer to not back-pressure.
199
+ const drainPromise = (async () => {
200
+ try {
201
+ for await (const _ of q) {
202
+ // discard SDK messages; we only care about the control response
203
+ }
204
+ } catch {
205
+ // expected: abort causes the stream to end with an error
206
+ }
207
+ })();
208
+
209
+ // Race getContextUsage against a timeout so /reset doesn't hang
210
+ const timeout = new Promise<never>((_, reject) =>
211
+ setTimeout(() => reject(new Error("warm-up timed out")), 15_000),
212
+ );
213
+ const ctx = await Promise.race([q.getContextUsage(), timeout]);
214
+ const session = getSession(chatId);
215
+ if (ctx.maxTokens > 0) session.usage.contextWindow = ctx.maxTokens;
216
+ if (ctx.totalTokens > 0) session.usage.contextTokens = ctx.totalTokens;
217
+ log(
218
+ "agent",
219
+ `[${chatId}] warm-up: context ${ctx.totalTokens}/${ctx.maxTokens} (${ctx.percentage.toFixed(1)}%) model=${ctx.model}`,
220
+ );
221
+
222
+ abort.abort();
223
+ await drainPromise;
224
+ } catch (err) {
225
+ abort.abort();
226
+ // Non-fatal — /status will just show 0 until first real message
227
+ logWarn(
228
+ "agent",
229
+ `[${chatId}] warm-up failed: ${err instanceof Error ? err.message : err}`,
230
+ );
231
+ }
232
+ }
233
+
234
+ // ── Main handler ─────────────────────────────────────────────────────────────
235
+
236
+ export async function handleMessage(
237
+ params: QueryParams,
238
+ _retried = false,
239
+ ): Promise<QueryResult> {
240
+ if (!config)
241
+ throw new Error("Agent not initialized. Call initAgent() first.");
242
+
243
+ const {
244
+ chatId,
245
+ text,
246
+ senderName,
247
+ isGroup,
248
+ onTextBlock,
249
+ onStreamDelta,
250
+ onToolUse,
251
+ } = params;
252
+ const session = getSession(chatId);
253
+ const t0 = Date.now();
254
+
255
+ // Rebuild system prompt on first turn of a new/reset session so identity,
256
+ // memory, and workspace listing are fresh
257
+ if (session.turns === 0) {
258
+ rebuildSystemPrompt(config, getPluginPromptAdditions());
259
+ }
260
+
261
+ const { options, activeModel } = buildSdkOptions(chatId);
262
+
184
263
  const msgIdHint = params.messageId ? ` [msg_id:${params.messageId}]` : "";
185
264
  const nowTag = `[${formatFullDatetime()}]`;
186
265
 
@@ -199,11 +278,16 @@ export async function handleMessage(
199
278
  let currentBlockText = "";
200
279
  let allResponseText = "";
201
280
  let newSessionId: string | undefined;
202
- let inputTokens = 0;
203
- let outputTokens = 0;
204
- let cacheRead = 0;
205
- let cacheWrite = 0;
206
281
  let toolCalls = 0;
282
+ // Populated from SDK result message
283
+ let contextTokens = 0; // actual context fill from last iteration
284
+ let contextWindow: number | undefined;
285
+ let numApiCalls = 0;
286
+ // Cumulative token counts from SDK modelUsage (aggregated across models)
287
+ let sdkInputTokens = 0;
288
+ let sdkOutputTokens = 0;
289
+ let sdkCacheRead = 0;
290
+ let sdkCacheWrite = 0;
207
291
 
208
292
  // Streaming throttle
209
293
  let lastStreamUpdate = 0;
@@ -298,15 +382,64 @@ export async function handleMessage(
298
382
  }
299
383
  }
300
384
 
301
- // Final result
385
+ // Final result — read all data from SDK result fields
302
386
  if (type === "result") {
303
- const usage = msg.usage as Record<string, number> | undefined;
304
- if (usage) {
305
- inputTokens = usage.input_tokens ?? 0;
306
- outputTokens = usage.output_tokens ?? 0;
307
- cacheRead = usage.cache_read_input_tokens ?? 0;
308
- cacheWrite = usage.cache_creation_input_tokens ?? 0;
387
+ numApiCalls =
388
+ ((msg as Record<string, unknown>).num_turns as number) ?? 0;
389
+
390
+ // Context fill from last API iteration (only available in raw usage)
391
+ const usage = msg.usage as
392
+ | {
393
+ iterations?: Array<{
394
+ input_tokens: number;
395
+ cache_read_input_tokens: number;
396
+ cache_creation_input_tokens: number;
397
+ }>;
398
+ }
399
+ | undefined;
400
+ if (
401
+ usage &&
402
+ Array.isArray(usage.iterations) &&
403
+ usage.iterations.length > 0
404
+ ) {
405
+ const last = usage.iterations[usage.iterations.length - 1];
406
+ contextTokens =
407
+ (last.input_tokens ?? 0) +
408
+ (last.cache_read_input_tokens ?? 0) +
409
+ (last.cache_creation_input_tokens ?? 0);
410
+ }
411
+
412
+ // Token counts, context window from SDK modelUsage (aggregated per model)
413
+ type MU = {
414
+ inputTokens?: number;
415
+ outputTokens?: number;
416
+ cacheReadInputTokens?: number;
417
+ cacheCreationInputTokens?: number;
418
+ contextWindow?: number;
419
+ };
420
+ const modelUsage = (msg as Record<string, unknown>).modelUsage as
421
+ | Record<string, MU>
422
+ | undefined;
423
+ if (modelUsage) {
424
+ for (const mu of Object.values(modelUsage)) {
425
+ sdkInputTokens += mu.inputTokens ?? 0;
426
+ sdkOutputTokens += mu.outputTokens ?? 0;
427
+ sdkCacheRead += mu.cacheReadInputTokens ?? 0;
428
+ sdkCacheWrite += mu.cacheCreationInputTokens ?? 0;
429
+ if (
430
+ mu.contextWindow &&
431
+ mu.contextWindow > 0 &&
432
+ contextWindow === undefined
433
+ ) {
434
+ contextWindow = mu.contextWindow;
435
+ }
436
+ }
309
437
  }
438
+ log(
439
+ "agent",
440
+ `SDK result: modelUsage=${JSON.stringify(modelUsage)}, contextWindow=${contextWindow}, contextTokens=${contextTokens}, numApiCalls=${numApiCalls}`,
441
+ );
442
+
310
443
  // If we still have unsent text and no streaming captured it
311
444
  if (
312
445
  !allResponseText &&
@@ -368,12 +501,15 @@ export async function handleMessage(
368
501
  if (newSessionId) setSessionId(chatId, newSessionId);
369
502
  incrementTurns(chatId);
370
503
  recordUsage(chatId, {
371
- inputTokens,
372
- outputTokens,
373
- cacheRead,
374
- cacheWrite,
504
+ inputTokens: sdkInputTokens,
505
+ outputTokens: sdkOutputTokens,
506
+ cacheRead: sdkCacheRead,
507
+ cacheWrite: sdkCacheWrite,
375
508
  durationMs,
376
509
  model: activeModel,
510
+ contextTokens,
511
+ contextWindow,
512
+ numApiCalls,
377
513
  });
378
514
 
379
515
  // Set a descriptive session name from the first message
@@ -393,21 +529,21 @@ export async function handleMessage(
393
529
  // The remaining currentBlockText is the final response text
394
530
  allResponseText += currentBlockText;
395
531
 
396
- const totalPrompt = inputTokens + cacheRead + cacheWrite;
532
+ const totalPrompt = sdkInputTokens + sdkCacheRead + sdkCacheWrite;
397
533
  const cacheHitPct =
398
- totalPrompt > 0 ? Math.round((cacheRead / totalPrompt) * 100) : 0;
534
+ totalPrompt > 0 ? Math.round((sdkCacheRead / totalPrompt) * 100) : 0;
399
535
 
400
536
  log(
401
537
  "agent",
402
- `[${chatId}] -> (${durationMs}ms, in=${inputTokens} out=${outputTokens} cache=${cacheHitPct}%` +
538
+ `[${chatId}] -> (${durationMs}ms, in=${sdkInputTokens} out=${sdkOutputTokens} cache=${cacheHitPct}%` +
403
539
  `${toolCalls > 0 ? ` tools=${toolCalls}` : ""})`,
404
540
  );
405
541
  traceMessage(chatId, "out", allResponseText, {
406
542
  durationMs,
407
- inputTokens,
408
- outputTokens,
409
- cacheRead,
410
- cacheWrite,
543
+ inputTokens: sdkInputTokens,
544
+ outputTokens: sdkOutputTokens,
545
+ cacheRead: sdkCacheRead,
546
+ cacheWrite: sdkCacheWrite,
411
547
  toolCalls,
412
548
  model: activeModel,
413
549
  });
@@ -415,9 +551,9 @@ export async function handleMessage(
415
551
  return {
416
552
  text: allResponseText.trim(),
417
553
  durationMs,
418
- inputTokens,
419
- outputTokens,
420
- cacheRead,
421
- cacheWrite,
554
+ inputTokens: sdkInputTokens,
555
+ outputTokens: sdkOutputTokens,
556
+ cacheRead: sdkCacheRead,
557
+ cacheWrite: sdkCacheWrite,
422
558
  };
423
559
  }
package/src/bootstrap.ts CHANGED
@@ -65,7 +65,7 @@ export async function bootstrap(
65
65
  config.mempalace?.enabled === true ||
66
66
  config.playwright?.enabled === true;
67
67
  if (hasPlugins) {
68
- const { loadPlugins, getPluginPromptAdditions, registerPlugin } =
68
+ const { loadPlugins, loadBuiltinPlugins, getPluginPromptAdditions } =
69
69
  await import("./core/plugin.js");
70
70
 
71
71
  // External plugins
@@ -76,108 +76,8 @@ export async function bootstrap(
76
76
  await loadPlugins(config.plugins, frontends);
77
77
  }
78
78
 
79
- // Built-in: GitHub
80
- if (config.github?.enabled) {
81
- const { createGitHubPlugin } = await import("./plugins/github/index.js");
82
- const { getPlugin } = await import("./core/plugin.js");
83
- const githubConfig = config.github as unknown as Record<string, unknown>;
84
- const gh = createGitHubPlugin({ token: config.github.token });
85
- registerPlugin(gh, githubConfig);
86
-
87
- if (getPlugin("github")) {
88
- try {
89
- const GITHUB_INIT_TIMEOUT_MS = 15_000;
90
- await Promise.race([
91
- gh.init?.(githubConfig),
92
- new Promise((_, reject) =>
93
- setTimeout(
94
- () => reject(new Error("GitHub init timed out after 15s")),
95
- GITHUB_INIT_TIMEOUT_MS,
96
- ),
97
- ),
98
- ]);
99
- } catch (err) {
100
- log(
101
- "github",
102
- `Init warning: ${err instanceof Error ? err.message : err}`,
103
- );
104
- }
105
- }
106
- }
107
-
108
- // Built-in: MemPalace
109
- if (config.mempalace?.enabled) {
110
- const { createMempalacePlugin } =
111
- await import("./plugins/mempalace/index.js");
112
- const { getPlugin } = await import("./core/plugin.js");
113
- const { dirs, files: pathFiles } = await import("./util/paths.js");
114
- const pythonPath =
115
- config.mempalace.pythonPath ?? pathFiles.mempalacePython;
116
- const palacePath = config.mempalace.palacePath ?? dirs.palace;
117
- const mempalaceConfig = config.mempalace as unknown as Record<
118
- string,
119
- unknown
120
- >;
121
- const mp = createMempalacePlugin({ pythonPath, palacePath });
122
- registerPlugin(mp, mempalaceConfig);
123
-
124
- // Only call init if registration succeeded (validation passed)
125
- if (getPlugin("mempalace")) {
126
- try {
127
- const MEMPALACE_INIT_TIMEOUT_MS = 30_000;
128
- await Promise.race([
129
- mp.init?.(mempalaceConfig),
130
- new Promise((_, reject) =>
131
- setTimeout(
132
- () => reject(new Error("MemPalace init timed out after 30s")),
133
- MEMPALACE_INIT_TIMEOUT_MS,
134
- ),
135
- ),
136
- ]);
137
- } catch (err) {
138
- log(
139
- "mempalace",
140
- `Init warning: ${err instanceof Error ? err.message : err}`,
141
- );
142
- }
143
- }
144
- }
145
-
146
- // Built-in: Playwright
147
- if (config.playwright?.enabled) {
148
- const { createPlaywrightPlugin } =
149
- await import("./plugins/playwright/index.js");
150
- const { getPlugin } = await import("./core/plugin.js");
151
- const playwrightConfig = config.playwright as unknown as Record<
152
- string,
153
- unknown
154
- >;
155
- const pw = createPlaywrightPlugin({
156
- browser: config.playwright.browser,
157
- headless: config.playwright.headless,
158
- });
159
- registerPlugin(pw, playwrightConfig);
160
-
161
- if (getPlugin("playwright")) {
162
- try {
163
- const PW_INIT_TIMEOUT_MS = 15_000;
164
- await Promise.race([
165
- pw.init?.(playwrightConfig),
166
- new Promise((_, reject) =>
167
- setTimeout(
168
- () => reject(new Error("Playwright init timed out after 15s")),
169
- PW_INIT_TIMEOUT_MS,
170
- ),
171
- ),
172
- ]);
173
- } catch (err) {
174
- log(
175
- "playwright",
176
- `Init warning: ${err instanceof Error ? err.message : err}`,
177
- );
178
- }
179
- }
180
- }
79
+ // Built-in plugins (GitHub, MemPalace, Playwright) — shared with hot-reload
80
+ await loadBuiltinPlugins(config);
181
81
 
182
82
  rebuildSystemPrompt(config, getPluginPromptAdditions());
183
83
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared gateway actions — platform-agnostic handlers that work with any frontend.
3
3
  *
4
- * Handles: cron CRUD, fetch_url, in-memory history queries.
4
+ * Handles: cron CRUD, fetch_url, plugin reload, in-memory history queries.
5
5
  * Returns null if the action isn't recognized (so the gateway delegates to the frontend).
6
6
  */
7
7
 
@@ -310,6 +310,47 @@ export async function handleSharedAction(
310
310
  return { ok: true, text: `Deleted cron job "${job.name}" (${jobId})` };
311
311
  }
312
312
 
313
+ // ── Plugin hot-reload ──────────────────────────────────────────────
314
+ case "reload_plugins": {
315
+ try {
316
+ const { reloadPlugins, getPluginPromptAdditions } =
317
+ await import("./plugin.js");
318
+ const { rebuildSystemPrompt } = await import("../util/config.js");
319
+
320
+ // reloadPlugins reads + validates config internally — no double read.
321
+ // Frontends are derived from config if not explicitly provided.
322
+ const { names, config: freshConfig } = await reloadPlugins();
323
+
324
+ // Rebuild system prompt on the freshConfig, then update the backend's
325
+ // live config reference so subsequent messages use the new prompt
326
+ rebuildSystemPrompt(freshConfig, getPluginPromptAdditions());
327
+ try {
328
+ const { updateSystemPrompt } =
329
+ await import("../backend/claude-sdk/index.js");
330
+ updateSystemPrompt(freshConfig.systemPrompt);
331
+ } catch (err) {
332
+ // Non-fatal — OpenCode backend doesn't expose updateSystemPrompt
333
+ log(
334
+ "gateway",
335
+ `reload_plugins: could not update backend prompt: ${err instanceof Error ? err.message : err}`,
336
+ );
337
+ }
338
+
339
+ log("gateway", `reload_plugins: ${names.length} plugins loaded`);
340
+ return {
341
+ ok: true,
342
+ text:
343
+ `Plugins reloaded successfully.\n` +
344
+ `Loaded (${names.length}): ${names.length > 0 ? names.join(", ") : "(none)"}`,
345
+ };
346
+ } catch (err) {
347
+ return {
348
+ ok: false,
349
+ error: `Plugin reload failed: ${err instanceof Error ? err.message : err}`,
350
+ };
351
+ }
352
+ }
353
+
313
354
  default:
314
355
  return null; // not a shared action — delegate to frontend
315
356
  }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Runs at a configurable interval (default: 60 minutes).
5
5
  * The agent reads instructions from ~/.talon/workspace/heartbeat-instructions.md
6
- * and executes them using filesystem-only tools (no Telegram/MCP access).
6
+ * and executes them using filesystem tools and all loaded MCP plugins.
7
7
  *
8
8
  * Modeled after dream.ts but more general-purpose.
9
9
  */
@@ -17,6 +17,7 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
17
17
  import { files as pathFiles, dirs } from "../util/paths.js";
18
18
  import { log, logError, logWarn } from "../util/log.js";
19
19
  import { toYMD } from "../util/time.js";
20
+ import { getPluginMcpServers } from "./plugin.js";
20
21
 
21
22
  // ── Types ────────────────────────────────────────────────────────────────────
22
23
 
@@ -282,15 +283,15 @@ async function runHeartbeatAgent(
282
283
  const options = {
283
284
  model,
284
285
  systemPrompt:
285
- "You are a background heartbeat agent for Talon. Use only filesystem tools. Follow the user-defined instructions precisely. Be efficient — you have limited time.",
286
+ "You are a background heartbeat agent for Talon. You have access to filesystem tools and all registered MCP plugins. Follow the user-defined instructions precisely. Be efficient — you have limited time.",
286
287
  cwd: workspace,
287
288
  permissionMode: "bypassPermissions" as const,
288
289
  allowDangerouslySkipPermissions: true,
289
290
  ...(configRef.claudeBinary
290
291
  ? { pathToClaudeCodeExecutable: configRef.claudeBinary }
291
292
  : {}),
292
- // No MCP servers filesystem tools only
293
- mcpServers: {},
293
+ // Load all registered plugin MCP servers (excludes frontend-specific tools like telegram)
294
+ mcpServers: getPluginMcpServers("", "heartbeat"),
294
295
  disallowedTools: [
295
296
  "EnterPlanMode",
296
297
  "ExitPlanMode",
@@ -315,10 +316,12 @@ async function runHeartbeatAgent(
315
316
  // running lock is not released while the subprocess is still active.
316
317
  let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
317
318
  const timeoutPromise = new Promise<never>((_, reject) => {
318
- timeoutHandle = setTimeout(
319
+ const t = setTimeout(
319
320
  () => reject(new Error("Heartbeat agent timed out")),
320
321
  HEARTBEAT_TIMEOUT_MS,
321
322
  );
323
+ t.unref(); // Don't prevent Node.js from exiting cleanly during shutdown
324
+ timeoutHandle = t;
322
325
  });
323
326
 
324
327
  const agentPromise = (async () => {