talon-agent 3.36.0 → 4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.36.0",
3
+ "version": "4.0.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { resolve } from "node:path";
8
+ import { logWarn } from "../../util/log.js";
8
9
  import { wrapMcpServer } from "../../util/mcp-launcher.js";
9
10
  import { isBunRuntime } from "../../util/runtime.js";
10
11
  import { registry, reloadState } from "./registry.js";
@@ -51,6 +52,18 @@ export function getPluginMcpServers(
51
52
  for (const { plugin, envVars } of registry.all) {
52
53
  // Skip plugins not in the allow-list when filtering
53
54
  if (only !== undefined && !only.includes(plugin.name)) continue;
55
+
56
+ // Let the plugin re-materialize on-disk state the child will read. Runs
57
+ // on every build so a deleted file heals within a turn; never fatal.
58
+ try {
59
+ if (plugin.mcpServer || plugin.mcpServerPath) plugin.prepareMcpSpawn?.();
60
+ } catch (err) {
61
+ logWarn(
62
+ "plugin",
63
+ `${plugin.name}: prepareMcpSpawn failed: ${err instanceof Error ? err.message : String(err)}`,
64
+ );
65
+ }
66
+
54
67
  const baseEnv = buildBridgeEnv(bridgeUrl, chatId, envVars);
55
68
 
56
69
  if (plugin.mcpServer) {
@@ -119,6 +119,17 @@ export const _deps = {
119
119
  // dynamic import() rejects bare drive-letter paths (e.g. C:\...).
120
120
  // Leave node: specifiers, relative paths, and URLs unchanged.
121
121
  const isAbsFilePath = /^[a-zA-Z]:[/\\]/.test(path) || path.startsWith("/");
122
- return import(isAbsFilePath ? pathToFileURL(path).href : path);
122
+ if (!isAbsFilePath) return import(path);
123
+
124
+ // Cache-bust with the reload timestamp. ESM caches modules by resolved
125
+ // URL forever, so without a changing query a hot reload re-imports the
126
+ // *old* module object and the plugin's own source edits are invisible
127
+ // until a full restart — silently, since the stale module still loads
128
+ // fine (observed 2026-09-16: a rewritten plugin kept reporting its
129
+ // previous version through three reloads). The timestamp only moves per
130
+ // reload, so every module in one load cycle shares a key.
131
+ const url = pathToFileURL(path);
132
+ url.searchParams.set("talonReloadAt", reloadState.lastReloadAt);
133
+ return import(url.href);
123
134
  },
124
135
  };
@@ -85,6 +85,18 @@ export interface TalonPlugin {
85
85
  readonly args: readonly string[];
86
86
  };
87
87
 
88
+ /**
89
+ * Materialize anything the MCP child needs on disk before it is spawned.
90
+ *
91
+ * MCP children are spawned lazily and respawned on demand for the lifetime
92
+ * of the daemon, so state written once at load time can go missing long
93
+ * before the last spawn. This hook runs every time the MCP server map is
94
+ * built, making such state self-healing instead of broken-until-restart.
95
+ * Must be synchronous, cheap, and idempotent; throwing is swallowed and
96
+ * logged (a failed prepare must not take the whole server map down).
97
+ */
98
+ prepareMcpSpawn?(): void;
99
+
88
100
  /**
89
101
  * Map plugin config to env vars for the MCP subprocess and action handlers.
90
102
  * Called once at load time. Values are set on process.env for the main
@@ -10,6 +10,16 @@ import { logWarn } from "../../util/log.js";
10
10
  /** Platforms expire typing indicators after ~5s; refresh under that. */
11
11
  const TYPING_REFRESH_MS = 4000;
12
12
 
13
+ /**
14
+ * Consecutive failures after which the loop gives up for the rest of the
15
+ * turn. A chat that has gone away (deleted, kicked, migrated) fails every
16
+ * single refresh, and a long turn then logs one warning every 4 seconds for
17
+ * a cosmetic indicator — 55 of them in 30 seconds on 2026-09-15, all
18
+ * "sendChatAction failed (400: chat not found)". Three strikes is enough to
19
+ * tell a transient blip from a chat that is simply gone.
20
+ */
21
+ const MAX_CONSECUTIVE_FAILURES = 3;
22
+
13
23
  export type SendTyping = (
14
24
  numericChatId: number,
15
25
  stringId?: string,
@@ -22,15 +32,36 @@ export function startTypingLoop(
22
32
  stringId: string,
23
33
  intervalMs = TYPING_REFRESH_MS,
24
34
  ): () => void {
35
+ let timer: ReturnType<typeof setInterval> | undefined;
36
+ let consecutiveFailures = 0;
37
+
38
+ const stop = () => {
39
+ if (timer !== undefined) clearInterval(timer);
40
+ timer = undefined;
41
+ };
42
+
25
43
  const send = (label: string) => {
26
- sendTyping(numericChatId, stringId).catch((err: unknown) => {
27
- logWarn(
28
- "dispatcher",
29
- `${label} failed: ${err instanceof Error ? err.message : String(err)}`,
30
- );
31
- });
44
+ sendTyping(numericChatId, stringId).then(
45
+ () => {
46
+ consecutiveFailures = 0;
47
+ },
48
+ (err: unknown) => {
49
+ consecutiveFailures++;
50
+ const reason = err instanceof Error ? err.message : String(err);
51
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
52
+ stop();
53
+ logWarn(
54
+ "dispatcher",
55
+ `${label} failed ${consecutiveFailures}x in a row — no typing indicator for the rest of this turn: ${reason}`,
56
+ );
57
+ return;
58
+ }
59
+ logWarn("dispatcher", `${label} failed: ${reason}`);
60
+ },
61
+ );
32
62
  };
63
+
33
64
  send("sendTyping");
34
- const timer = setInterval(() => send("sendTyping interval"), intervalMs);
35
- return () => clearInterval(timer);
65
+ timer = setInterval(() => send("sendTyping interval"), intervalMs);
66
+ return stop;
36
67
  }
@@ -39,6 +39,7 @@ import type { BridgeServerHandlers, SendOptions } from "./server.js";
39
39
  import type { ClientAttachment } from "./protocol.js";
40
40
  import { configSnapshot, applyConfigUpdate } from "./settings.js";
41
41
  import { bridgeStatus, broadcastStatus } from "./status.js";
42
+ import { logWarn } from "../../util/log.js";
42
43
  import { interruptTurn, isBusy, liveTurnEvents, startTurn } from "./turn.js";
43
44
 
44
45
  /**
@@ -58,14 +59,30 @@ function resolveAttachments(
58
59
  refs.push({ url: opts.imagePath, path: opts.attachmentPath });
59
60
  }
60
61
  const resolved: ClientAttachment[] = [];
62
+ const dropped: string[] = [];
61
63
  for (const ref of refs) {
62
64
  const found = resolveUpload(runtime, ref);
65
+ if (!found) {
66
+ dropped.push(ref.url ?? ref.path ?? "(empty reference)");
67
+ continue;
68
+ }
63
69
  // Same file referenced twice (a re-send of a queued message, say) stays
64
70
  // one attachment.
65
- if (found && !resolved.some((a) => a.path === found.path)) {
71
+ if (!resolved.some((a) => a.path === found.path)) {
66
72
  resolved.push(found);
67
73
  }
68
74
  }
75
+ // A dropped reference is how "I attached a file and the agent never saw it"
76
+ // happens, and it used to be entirely silent. The usual cause is a media id
77
+ // minted by a previous daemon run — `runtime.uploads` is per-run, so a
78
+ // client that staged a file before a restart and sent it after points at
79
+ // nothing.
80
+ if (dropped.length) {
81
+ logWarn(
82
+ "native",
83
+ `dropped ${dropped.length} unknown attachment reference(s) from /send: ${dropped.join(", ")} — re-upload the file (ids do not survive a daemon restart)`,
84
+ );
85
+ }
69
86
  return resolved;
70
87
  }
71
88
 
@@ -6,6 +6,7 @@ import {
6
6
  asString,
7
7
  deviceIdParam,
8
8
  } from "./params.js";
9
+ import { log, logWarn } from "../../../util/log.js";
9
10
 
10
11
  export function chatRoutes(
11
12
  host: RouteHost,
@@ -110,8 +111,18 @@ export function chatRoutes(
110
111
  const filename = url.searchParams.get("filename") ?? "upload";
111
112
  const contentType =
112
113
  req.headers["content-type"] ?? "application/octet-stream";
114
+ const startedAt = Date.now();
113
115
  try {
114
116
  const attachment = await h.upload(filename, contentType, req);
117
+ // Uploads are the one client action whose failure used to be visible
118
+ // only in the app: the route answered in JSON and logged nothing, so
119
+ // "attaching a file doesn't work — check the logs" had nothing to
120
+ // read. Both outcomes are logged now, with the size and duration
121
+ // that distinguish a rejected upload from a dropped connection.
122
+ log(
123
+ "native",
124
+ `upload ok: ${attachment.name} (${attachment.size} bytes, ${attachment.mimeType}) in ${Date.now() - startedAt}ms`,
125
+ );
115
126
  // `imagePath` mirrors the URL for clients written against the
116
127
  // single-image upload response.
117
128
  json(res, 200, { ok: true, ...attachment, imagePath: attachment.url });
@@ -124,6 +135,10 @@ export function chatRoutes(
124
135
  : /empty/i.test(error)
125
136
  ? 400
126
137
  : 500;
138
+ logWarn(
139
+ "native",
140
+ `upload failed (${status}) for ${filename} after ${Date.now() - startedAt}ms: ${error}`,
141
+ );
127
142
  json(res, status, { ok: false, error });
128
143
  }
129
144
  },
@@ -30,10 +30,10 @@
30
30
  * other.
31
31
  */
32
32
 
33
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
34
- import { join, resolve } from "node:path";
35
- import { tmpdir } from "node:os";
33
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
34
+ import { dirname, resolve } from "node:path";
36
35
  import type { TalonPlugin } from "../../core/plugin/types.js";
36
+ import { files } from "../../util/paths.js";
37
37
  import { log } from "../../util/log.js";
38
38
 
39
39
  export function createPlaywrightPlugin(config: {
@@ -66,6 +66,11 @@ export function createPlaywrightPlugin(config: {
66
66
 
67
67
  const args: string[] = [];
68
68
 
69
+ // Endpoint mode writes a config file the MCP child reads at every spawn;
70
+ // `writeMcpConfig` is re-run before each spawn via `prepareMcpSpawn` so a
71
+ // deleted config heals instead of failing every browser tool until restart.
72
+ let writeMcpConfig: (() => void) | undefined;
73
+
69
74
  if (endpoint) {
70
75
  // Connect to the existing browser (e.g. the Camoufox websocket server)
71
76
  // via a generated MCP config file: `browser.remoteEndpoint` is the
@@ -79,11 +84,12 @@ export function createPlaywrightPlugin(config: {
79
84
  remoteEndpoint: endpoint,
80
85
  },
81
86
  };
82
- const configPath = join(
83
- tmpdir(),
84
- `talon-playwright-mcp-${process.pid}.json`,
85
- );
86
- writeFileSync(configPath, JSON.stringify(mcpConfig));
87
+ const configPath = files.playwrightMcpConfig;
88
+ writeMcpConfig = () => {
89
+ mkdirSync(dirname(configPath), { recursive: true });
90
+ writeFileSync(configPath, JSON.stringify(mcpConfig));
91
+ };
92
+ writeMcpConfig();
87
93
  args.push("--config", configPath);
88
94
  } else {
89
95
  args.push("--no-sandbox");
@@ -107,6 +113,10 @@ export function createPlaywrightPlugin(config: {
107
113
  args: [mcpBin, ...args],
108
114
  },
109
115
 
116
+ prepareMcpSpawn() {
117
+ writeMcpConfig?.();
118
+ },
119
+
110
120
  validateConfig() {
111
121
  const errors: string[] = [];
112
122
 
package/src/util/paths.ts CHANGED
@@ -162,4 +162,15 @@ export const files = {
162
162
  * for what the data means.
163
163
  */
164
164
  codexOauthIncompat: resolve(TALON_ROOT, "data", "codex-oauth-incompat.json"),
165
+ /**
166
+ * Generated @playwright/mcp config (endpoint mode): ~/.talon/data/playwright-mcp.json
167
+ *
168
+ * Deliberately NOT in os.tmpdir(): the MCP child re-reads this file on every
169
+ * spawn, hours or days after the daemon wrote it, and a /tmp sweeper
170
+ * deleting it takes every browser tool down until the daemon restarts
171
+ * (2026-09-15: an hourly disk cleaner removed 676 `/tmp/talon-*` entries,
172
+ * and browser tools failed with ENOENT for the next 24h). The name is
173
+ * pid-free so each run overwrites the last instead of orphaning a file.
174
+ */
175
+ playwrightMcpConfig: resolve(TALON_ROOT, "data", "playwright-mcp.json"),
165
176
  } as const;