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 +1 -1
- package/src/core/plugin/mcp.ts +13 -0
- package/src/core/plugin/registry.ts +12 -1
- package/src/core/plugin/types.ts +12 -0
- package/src/core/weaver/typing-loop.ts +39 -8
- package/src/frontend/native/handlers.ts +18 -1
- package/src/frontend/native/routes/chats.ts +15 -0
- package/src/plugins/playwright/index.ts +18 -8
- package/src/util/paths.ts +11 -0
package/package.json
CHANGED
package/src/core/plugin/mcp.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/src/core/plugin/types.ts
CHANGED
|
@@ -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).
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
35
|
-
return
|
|
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 (
|
|
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 {
|
|
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 =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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;
|