talon-agent 1.35.0 → 1.37.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 +3 -3
- package/src/backend/claude-sdk/handler.ts +6 -2
- package/src/backend/claude-sdk/options.ts +41 -1
- package/src/backend/codex/handler/events.ts +1 -1
- package/src/backend/codex/handler/message.ts +1 -0
- package/src/backend/kilo/handler/message.ts +1 -0
- package/src/backend/kilo/handler/turn.ts +1 -0
- package/src/backend/openai-agents/handler/events.ts +1 -1
- package/src/backend/openai-agents/handler/message.ts +5 -1
- package/src/backend/opencode/handler/message.ts +1 -0
- package/src/backend/opencode/handler/turn.ts +1 -0
- package/src/backend/remote-server/events.ts +3 -1
- package/src/backend/shared/metrics.ts +26 -51
- package/src/bootstrap.ts +1 -0
- package/src/core/engine/gateway-actions/index.ts +2 -0
- package/src/core/engine/gateway-actions/mesh.ts +27 -0
- package/src/core/engine/gateway-actions/native.ts +607 -0
- package/src/core/mcp-hub/index.ts +3 -0
- package/src/core/mcp-hub/talon-server.ts +3 -0
- package/src/core/mesh/persist.ts +49 -0
- package/src/core/mesh/registry.ts +10 -18
- package/src/core/mesh/service.ts +754 -42
- package/src/core/mesh/teleport.ts +158 -0
- package/src/core/mesh/transfers.ts +186 -0
- package/src/core/tools/bridge.ts +85 -4
- package/src/core/tools/index.ts +23 -2
- package/src/core/tools/mesh.ts +83 -1
- package/src/core/tools/native.ts +138 -0
- package/src/core/tools/types.ts +2 -1
- package/src/frontend/discord/commands/admin.ts +9 -2
- package/src/frontend/discord/helpers.ts +7 -6
- package/src/frontend/native/chats.ts +2 -2
- package/src/frontend/native/index.ts +2 -0
- package/src/frontend/native/server.ts +40 -1
- package/src/frontend/telegram/commands/admin.ts +10 -2
- package/src/frontend/telegram/helpers/diagnostics.ts +7 -6
- package/src/storage/db.ts +6 -1
- package/src/storage/repositories/sessions-repo.ts +20 -1
- package/src/storage/sessions.ts +348 -4
- package/src/storage/sql/db.sql +5 -0
- package/src/storage/sql/schema.sql +2 -1
- package/src/storage/sql/sessions.sql +3 -3
- package/src/storage/sql/statements.generated.ts +8 -4
- package/src/util/config.ts +10 -0
- package/src/util/exec-output.ts +64 -0
- package/src/util/metrics.ts +148 -60
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native tools — Talon's own shell/filesystem tools, replacing the SDK's
|
|
3
|
+
* built-in Bash/Read/Write/Edit/Glob/Grep when `config.nativeTools` is on.
|
|
4
|
+
*
|
|
5
|
+
* Their defining feature: every one checks the current chat's active
|
|
6
|
+
* `teleport` target. With no teleport for that chat, they run on the daemon
|
|
7
|
+
* host (local spawn / local fs / local ripgrep). With a teleport engaged, they
|
|
8
|
+
* run ON the companion device via the mesh exec/fs channel — so
|
|
9
|
+
* `bash`/`read`/`write`/… transparently operate on the phone for that chat.
|
|
10
|
+
*
|
|
11
|
+
* teleport(device) → native tools target that device
|
|
12
|
+
* teleport_back() → native tools run locally again
|
|
13
|
+
*
|
|
14
|
+
* The teleported path reuses the exec/fs command surface on MeshService; the
|
|
15
|
+
* local path is a thin, well-scoped reimplementation of the built-ins.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import {
|
|
20
|
+
glob as fsGlob,
|
|
21
|
+
mkdir,
|
|
22
|
+
readFile,
|
|
23
|
+
stat as fsStat,
|
|
24
|
+
writeFile,
|
|
25
|
+
} from "node:fs/promises";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import { getMeshService } from "../../mesh/index.js";
|
|
28
|
+
import {
|
|
29
|
+
clearTeleport,
|
|
30
|
+
getTeleport,
|
|
31
|
+
setTeleport,
|
|
32
|
+
setTeleportCwd,
|
|
33
|
+
} from "../../mesh/teleport.js";
|
|
34
|
+
import {
|
|
35
|
+
clampExecOutput,
|
|
36
|
+
createOutputCapture,
|
|
37
|
+
} from "../../../util/exec-output.js";
|
|
38
|
+
import type { SharedActionHandlers } from "./types.js";
|
|
39
|
+
|
|
40
|
+
type Result = { ok: boolean; text: string };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* ripgrep binary, resolved from PATH (env-overridable for tests). NOT a
|
|
44
|
+
* hardcoded absolute path: /usr/bin/rg only exists on some Linux installs,
|
|
45
|
+
* and a missing binary must fall back loudly (or to the pure-JS walker),
|
|
46
|
+
* never masquerade as "no matches".
|
|
47
|
+
*/
|
|
48
|
+
function rgBin(): string {
|
|
49
|
+
return process.env.TALON_NATIVE_RG ?? "rg";
|
|
50
|
+
}
|
|
51
|
+
const DEFAULT_EXEC_TIMEOUT_MS = 60_000;
|
|
52
|
+
const MAX_EXEC_TIMEOUT_MS = 300_000;
|
|
53
|
+
const MAX_READ_LINES = 2_000;
|
|
54
|
+
/** Caps for the pure-JS glob/search fallbacks (rg unavailable). */
|
|
55
|
+
const MAX_JS_RESULTS = 2_000;
|
|
56
|
+
const MAX_JS_FILE_BYTES = 2 * 1024 * 1024;
|
|
57
|
+
const SKIP_DIRS_RE = /(^|[\\/])(node_modules|\.git)([\\/]|$)/;
|
|
58
|
+
/** Markers used to recover the post-command working dir from a teleport shell. */
|
|
59
|
+
const CWD_OPEN = "__TALON_CWD__";
|
|
60
|
+
const CWD_CLOSE = "__TALON_CWD_END__";
|
|
61
|
+
|
|
62
|
+
export const nativeHandlers: SharedActionHandlers = {
|
|
63
|
+
teleport: (body, chatId) => teleport(chatId, body.device),
|
|
64
|
+
teleport_back: (_body, chatId) => teleportBack(chatId),
|
|
65
|
+
native_bash: (body, chatId) =>
|
|
66
|
+
bash(chatId, body.command, body.cwd, body.timeout_sec),
|
|
67
|
+
native_read: (body, chatId) =>
|
|
68
|
+
read(chatId, body.path, body.offset, body.limit),
|
|
69
|
+
native_write: (body, chatId) => write(chatId, body.path, body.content),
|
|
70
|
+
native_edit: (body, chatId) =>
|
|
71
|
+
edit(chatId, body.path, body.old_string, body.new_string, body.replace_all),
|
|
72
|
+
native_glob: (body, chatId) => glob(chatId, body.pattern, body.path),
|
|
73
|
+
native_search: (body, chatId) =>
|
|
74
|
+
search(chatId, body.pattern, body.path, body.glob, body.case_insensitive),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// ── Teleport control ────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
async function teleport(chatId: number, query: unknown): Promise<Result> {
|
|
80
|
+
const svc = getMeshService();
|
|
81
|
+
await svc.list();
|
|
82
|
+
const resolved = svc.resolveDevice(query);
|
|
83
|
+
if ("error" in resolved) return { ok: false, text: resolved.error };
|
|
84
|
+
const target = resolved.target;
|
|
85
|
+
if (!target.online) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
text: `${target.name} appears offline — cannot teleport onto a device that isn't connected.`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (target.capabilities && !target.capabilities.includes("exec")) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
text: `${target.name} does not advertise the "exec" capability, so teleport can't run commands on it.`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
await setTeleport(chatId, target.id, target.name);
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
text: [
|
|
101
|
+
`🛰️ Teleported onto ${target.name}. Native bash/read/write/edit/glob/search now run ON that device.`,
|
|
102
|
+
`Working dir starts at the device default; \`cd\` in bash persists across calls.`,
|
|
103
|
+
`Call teleport_back to return to the daemon host.`,
|
|
104
|
+
].join(" "),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function teleportBack(chatId: number): Promise<Result> {
|
|
109
|
+
const prior = await clearTeleport(chatId);
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
text: prior
|
|
113
|
+
? `↩️ Teleported back from ${prior.deviceName}. Native tools run on the daemon host again.`
|
|
114
|
+
: "Not teleported — native tools already run on the daemon host.",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── bash ────────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
async function bash(
|
|
121
|
+
chatId: number,
|
|
122
|
+
command: unknown,
|
|
123
|
+
cwd: unknown,
|
|
124
|
+
timeoutSec: unknown,
|
|
125
|
+
): Promise<Result> {
|
|
126
|
+
const cmd = typeof command === "string" ? command : "";
|
|
127
|
+
if (!cmd.trim()) return { ok: false, text: "No command given." };
|
|
128
|
+
const timeoutMs = clampTimeout(timeoutSec);
|
|
129
|
+
const active = await getTeleport(chatId);
|
|
130
|
+
if (active) return bashTeleported(chatId, active.deviceId, cmd, timeoutMs);
|
|
131
|
+
return bashLocal(cmd, typeof cwd === "string" ? cwd : undefined, timeoutMs);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function bashLocal(
|
|
135
|
+
cmd: string,
|
|
136
|
+
cwd: string | undefined,
|
|
137
|
+
timeoutMs: number,
|
|
138
|
+
): Promise<Result> {
|
|
139
|
+
return new Promise((resolvePromise) => {
|
|
140
|
+
// detached → own process group on POSIX, so a timeout can kill the whole
|
|
141
|
+
// tree (bash's children included), not just the shell itself.
|
|
142
|
+
const detached = process.platform !== "win32";
|
|
143
|
+
const child = spawn("bash", ["-c", cmd], {
|
|
144
|
+
...(cwd ? { cwd } : {}),
|
|
145
|
+
env: process.env,
|
|
146
|
+
detached,
|
|
147
|
+
});
|
|
148
|
+
const stdout = createOutputCapture();
|
|
149
|
+
const stderr = createOutputCapture();
|
|
150
|
+
let killed = false;
|
|
151
|
+
const timer = setTimeout(() => {
|
|
152
|
+
killed = true;
|
|
153
|
+
if (detached && child.pid) {
|
|
154
|
+
try {
|
|
155
|
+
process.kill(-child.pid, "SIGKILL");
|
|
156
|
+
return;
|
|
157
|
+
} catch {
|
|
158
|
+
// group already gone — fall through to the direct kill
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
child.kill("SIGKILL");
|
|
162
|
+
}, timeoutMs);
|
|
163
|
+
child.stdout.on("data", stdout.push);
|
|
164
|
+
child.stderr.on("data", stderr.push);
|
|
165
|
+
child.on("error", (err) => {
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
resolvePromise({ ok: false, text: `Failed to run: ${err.message}` });
|
|
168
|
+
});
|
|
169
|
+
child.on("close", (code) => {
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
const exit = killed
|
|
172
|
+
? `killed (timeout ${timeoutMs / 1000}s)`
|
|
173
|
+
: `exit ${code ?? 0}`;
|
|
174
|
+
resolvePromise({
|
|
175
|
+
ok: !killed && (code ?? 0) === 0,
|
|
176
|
+
text: renderExec("local", exit, stdout.value(), stderr.value()),
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function bashTeleported(
|
|
183
|
+
chatId: number,
|
|
184
|
+
deviceId: string,
|
|
185
|
+
cmd: string,
|
|
186
|
+
timeoutMs: number,
|
|
187
|
+
): Promise<Result> {
|
|
188
|
+
const active = await getTeleport(chatId);
|
|
189
|
+
const cwd = active?.cwd;
|
|
190
|
+
// Wrap so the resulting working dir is reported back and persists across
|
|
191
|
+
// calls (a `cd` in `cmd` carries forward), while the real exit code is
|
|
192
|
+
// preserved. printf can't fail in a way that masks the command's status.
|
|
193
|
+
const wrapped =
|
|
194
|
+
`${cwd ? `cd ${shellQuote(cwd)} 2>/dev/null; ` : ""}` +
|
|
195
|
+
`{ ${cmd}\n}; __talon_rc=$?; ` +
|
|
196
|
+
`printf '${CWD_OPEN}%s${CWD_CLOSE}' "$(pwd 2>/dev/null)"; exit $__talon_rc`;
|
|
197
|
+
const dispatched = await getMeshService().dispatchCommand(
|
|
198
|
+
deviceId,
|
|
199
|
+
"exec",
|
|
200
|
+
{ cmd: wrapped, timeoutMs },
|
|
201
|
+
timeoutMs + 5_000,
|
|
202
|
+
);
|
|
203
|
+
if ("error" in dispatched) return { ok: false, text: dispatched.error };
|
|
204
|
+
const { target, result } = dispatched;
|
|
205
|
+
const data = result.data ?? {};
|
|
206
|
+
let stdout = typeof data.stdout === "string" ? data.stdout : "";
|
|
207
|
+
const stderr = typeof data.stderr === "string" ? data.stderr : "";
|
|
208
|
+
const via =
|
|
209
|
+
typeof data.via === "string" && data.via ? ` via ${data.via}` : "";
|
|
210
|
+
const exitCode =
|
|
211
|
+
typeof data.exitCode === "number" ? data.exitCode : undefined;
|
|
212
|
+
// Recover + strip the trailing cwd marker.
|
|
213
|
+
const open = stdout.lastIndexOf(CWD_OPEN);
|
|
214
|
+
if (open !== -1) {
|
|
215
|
+
const close = stdout.indexOf(CWD_CLOSE, open);
|
|
216
|
+
if (close !== -1) {
|
|
217
|
+
const newCwd = stdout.slice(open + CWD_OPEN.length, close).trim();
|
|
218
|
+
stdout = stdout.slice(0, open);
|
|
219
|
+
if (newCwd) await setTeleportCwd(chatId, newCwd);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (!result.ok && exitCode === undefined) {
|
|
223
|
+
return {
|
|
224
|
+
ok: false,
|
|
225
|
+
text: result.message ?? `${target.name} could not run the command.`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
ok: exitCode === 0,
|
|
230
|
+
text: renderExec(
|
|
231
|
+
`${target.name}${via}`,
|
|
232
|
+
`exit ${exitCode ?? "?"}`,
|
|
233
|
+
stdout,
|
|
234
|
+
stderr,
|
|
235
|
+
),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── read / write / edit ─────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
async function read(
|
|
242
|
+
chatId: number,
|
|
243
|
+
path: unknown,
|
|
244
|
+
offset: unknown,
|
|
245
|
+
limit: unknown,
|
|
246
|
+
): Promise<Result> {
|
|
247
|
+
const p = str(path);
|
|
248
|
+
if (!p) return { ok: false, text: "A file path is required." };
|
|
249
|
+
const start = num(offset) ?? 0;
|
|
250
|
+
const max = Math.min(num(limit) ?? MAX_READ_LINES, MAX_READ_LINES);
|
|
251
|
+
const active = await getTeleport(chatId);
|
|
252
|
+
let content: string;
|
|
253
|
+
let where: string;
|
|
254
|
+
if (active) {
|
|
255
|
+
const res = await getMeshService().readFileBytes(active.deviceId, p);
|
|
256
|
+
if ("error" in res) return { ok: false, text: res.error };
|
|
257
|
+
content = res.data.toString("utf8");
|
|
258
|
+
where = active.deviceName;
|
|
259
|
+
} else {
|
|
260
|
+
try {
|
|
261
|
+
content = await readFile(p, "utf8");
|
|
262
|
+
} catch (err) {
|
|
263
|
+
return { ok: false, text: `Cannot read ${p}: ${(err as Error).message}` };
|
|
264
|
+
}
|
|
265
|
+
where = "local";
|
|
266
|
+
}
|
|
267
|
+
const lines = content.split("\n");
|
|
268
|
+
const slice = lines.slice(start, start + max);
|
|
269
|
+
const numbered = slice
|
|
270
|
+
.map((line, i) => `${String(start + i + 1).padStart(6)}\t${line}`)
|
|
271
|
+
.join("\n");
|
|
272
|
+
const more =
|
|
273
|
+
lines.length > start + max
|
|
274
|
+
? `\n… (${lines.length - start - max} more lines; raise limit/offset)`
|
|
275
|
+
: "";
|
|
276
|
+
return {
|
|
277
|
+
ok: true,
|
|
278
|
+
text: `${p} [${where}] — ${lines.length} lines\n${numbered}${more}`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function write(
|
|
283
|
+
chatId: number,
|
|
284
|
+
path: unknown,
|
|
285
|
+
content: unknown,
|
|
286
|
+
): Promise<Result> {
|
|
287
|
+
const p = str(path);
|
|
288
|
+
if (!p) return { ok: false, text: "A file path is required." };
|
|
289
|
+
const body = typeof content === "string" ? content : "";
|
|
290
|
+
const active = await getTeleport(chatId);
|
|
291
|
+
if (active) {
|
|
292
|
+
return getMeshService().writeFileToDevice(active.deviceId, p, body);
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
await mkdir(dirname(p), { recursive: true });
|
|
296
|
+
await writeFile(p, body);
|
|
297
|
+
} catch (err) {
|
|
298
|
+
return { ok: false, text: `Cannot write ${p}: ${(err as Error).message}` };
|
|
299
|
+
}
|
|
300
|
+
return { ok: true, text: `Wrote ${body.length} bytes to ${p} [local].` };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function edit(
|
|
304
|
+
chatId: number,
|
|
305
|
+
path: unknown,
|
|
306
|
+
oldString: unknown,
|
|
307
|
+
newString: unknown,
|
|
308
|
+
replaceAll: unknown,
|
|
309
|
+
): Promise<Result> {
|
|
310
|
+
const p = str(path);
|
|
311
|
+
if (!p) return { ok: false, text: "A file path is required." };
|
|
312
|
+
const from = typeof oldString === "string" ? oldString : "";
|
|
313
|
+
const to = typeof newString === "string" ? newString : "";
|
|
314
|
+
if (from === to)
|
|
315
|
+
return { ok: false, text: "old_string and new_string are identical." };
|
|
316
|
+
const active = await getTeleport(chatId);
|
|
317
|
+
const svc = getMeshService();
|
|
318
|
+
|
|
319
|
+
let content: string;
|
|
320
|
+
if (active) {
|
|
321
|
+
const res = await svc.readFileBytes(active.deviceId, p);
|
|
322
|
+
if ("error" in res) return { ok: false, text: res.error };
|
|
323
|
+
content = res.data.toString("utf8");
|
|
324
|
+
} else {
|
|
325
|
+
try {
|
|
326
|
+
content = await readFile(p, "utf8");
|
|
327
|
+
} catch (err) {
|
|
328
|
+
return { ok: false, text: `Cannot read ${p}: ${(err as Error).message}` };
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const count = from ? content.split(from).length - 1 : 0;
|
|
333
|
+
if (count === 0) return { ok: false, text: `old_string not found in ${p}.` };
|
|
334
|
+
if (count > 1 && replaceAll !== true) {
|
|
335
|
+
return {
|
|
336
|
+
ok: false,
|
|
337
|
+
text: `old_string appears ${count}× in ${p}; pass replace_all or make it unique.`,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
const updated =
|
|
341
|
+
replaceAll === true
|
|
342
|
+
? content.split(from).join(to)
|
|
343
|
+
: content.replace(from, to);
|
|
344
|
+
|
|
345
|
+
if (active) {
|
|
346
|
+
const res = await svc.writeFileToDevice(active.deviceId, p, updated);
|
|
347
|
+
return res.ok
|
|
348
|
+
? {
|
|
349
|
+
ok: true,
|
|
350
|
+
text: `Edited ${p} [${active.deviceName}] (${count} replacement${count === 1 ? "" : "s"}).`,
|
|
351
|
+
}
|
|
352
|
+
: res;
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
await writeFile(p, updated);
|
|
356
|
+
} catch (err) {
|
|
357
|
+
return { ok: false, text: `Cannot write ${p}: ${(err as Error).message}` };
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
ok: true,
|
|
361
|
+
text: `Edited ${p} [local] (${count} replacement${count === 1 ? "" : "s"}).`,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── glob / search ───────────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
async function glob(
|
|
368
|
+
chatId: number,
|
|
369
|
+
pattern: unknown,
|
|
370
|
+
path: unknown,
|
|
371
|
+
): Promise<Result> {
|
|
372
|
+
const pat = str(pattern);
|
|
373
|
+
if (!pat) return { ok: false, text: "A glob pattern is required." };
|
|
374
|
+
const root = str(path) ?? ".";
|
|
375
|
+
const active = await getTeleport(chatId);
|
|
376
|
+
if (active) {
|
|
377
|
+
// Prefer rg on the device; fall back to find (basename patterns via
|
|
378
|
+
// -name, path patterns via -path). `command -v` gates the choice so a
|
|
379
|
+
// no-match rg exit (1) isn't misread as "rg missing, run find too".
|
|
380
|
+
const findExpr = pat.includes("/")
|
|
381
|
+
? `-path ${shellQuote(`*${pat}`)}`
|
|
382
|
+
: `-name ${shellQuote(pat)}`;
|
|
383
|
+
const cmd =
|
|
384
|
+
`if command -v rg >/dev/null 2>&1; ` +
|
|
385
|
+
`then rg --files -g ${shellQuote(pat)} ${shellQuote(root)}; ` +
|
|
386
|
+
`else find ${shellQuote(root)} ${findExpr} 2>/dev/null; fi`;
|
|
387
|
+
return bashTeleported(chatId, active.deviceId, cmd, 30_000);
|
|
388
|
+
}
|
|
389
|
+
const res = await runLocal(rgBin(), ["--files", "-g", pat, root]);
|
|
390
|
+
let files: string[];
|
|
391
|
+
if (res.code === 127) {
|
|
392
|
+
// rg not installed — pure-JS fallback rather than lying "no matches".
|
|
393
|
+
files = await globJs(pat, root);
|
|
394
|
+
} else if (res.code > 1 && !res.stdout.trim()) {
|
|
395
|
+
// exit 2 with output = partial results (e.g. permission-denied subdirs);
|
|
396
|
+
// exit 2 with none = a real error worth surfacing.
|
|
397
|
+
return {
|
|
398
|
+
ok: false,
|
|
399
|
+
text: `glob failed: ${res.stderr.trim() || `ripgrep exit ${res.code}`}`,
|
|
400
|
+
};
|
|
401
|
+
} else {
|
|
402
|
+
files = res.stdout.trim().split("\n").filter(Boolean);
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
ok: true,
|
|
406
|
+
text: files.length
|
|
407
|
+
? `${files.length} match(es):\n${files.slice(0, 200).join("\n")}${files.length > 200 ? `\n… (${files.length - 200} more)` : ""}`
|
|
408
|
+
: `No files match ${pat} under ${root}.`,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function search(
|
|
413
|
+
chatId: number,
|
|
414
|
+
pattern: unknown,
|
|
415
|
+
path: unknown,
|
|
416
|
+
globPat: unknown,
|
|
417
|
+
caseInsensitive: unknown,
|
|
418
|
+
): Promise<Result> {
|
|
419
|
+
const pat = str(pattern);
|
|
420
|
+
if (!pat) return { ok: false, text: "A search pattern is required." };
|
|
421
|
+
const root = str(path) ?? ".";
|
|
422
|
+
const g = str(globPat);
|
|
423
|
+
const ci = caseInsensitive === true;
|
|
424
|
+
// `-e` keeps a pattern that starts with "-" from being parsed as a flag
|
|
425
|
+
// (same idiom for rg and grep).
|
|
426
|
+
const flags = [
|
|
427
|
+
"-n",
|
|
428
|
+
"--color=never",
|
|
429
|
+
...(ci ? ["-i"] : []),
|
|
430
|
+
...(g ? ["-g", g] : []),
|
|
431
|
+
];
|
|
432
|
+
const active = await getTeleport(chatId);
|
|
433
|
+
if (active) {
|
|
434
|
+
// Prefer rg on the device, fall back to grep (Android toybox has grep
|
|
435
|
+
// but rarely rg). --include is grep's closest analogue of -g.
|
|
436
|
+
const grepFlags = [
|
|
437
|
+
"-rn",
|
|
438
|
+
...(ci ? ["-i"] : []),
|
|
439
|
+
...(g ? [`--include=${g}`] : []),
|
|
440
|
+
];
|
|
441
|
+
const cmd =
|
|
442
|
+
`if command -v rg >/dev/null 2>&1; ` +
|
|
443
|
+
`then rg ${flags.map(shellQuote).join(" ")} -e ${shellQuote(pat)} ${shellQuote(root)}; ` +
|
|
444
|
+
`else grep ${grepFlags.map(shellQuote).join(" ")} -e ${shellQuote(pat)} ${shellQuote(root)} 2>/dev/null; fi`;
|
|
445
|
+
return bashTeleported(chatId, active.deviceId, cmd, 30_000);
|
|
446
|
+
}
|
|
447
|
+
const res = await runLocal(rgBin(), [...flags, "-e", pat, root]);
|
|
448
|
+
let lines: string[];
|
|
449
|
+
if (res.code === 127) {
|
|
450
|
+
// rg not installed — pure-JS fallback rather than lying "no matches".
|
|
451
|
+
try {
|
|
452
|
+
lines = await searchJs(pat, root, g, ci);
|
|
453
|
+
} catch (err) {
|
|
454
|
+
return { ok: false, text: `search failed: ${(err as Error).message}` };
|
|
455
|
+
}
|
|
456
|
+
} else if (res.code > 1 && !res.stdout.trim()) {
|
|
457
|
+
// exit 2 with output = partial results; exit 2 with none = real error.
|
|
458
|
+
return {
|
|
459
|
+
ok: false,
|
|
460
|
+
text: `search failed: ${res.stderr.trim() || `ripgrep exit ${res.code}`}`,
|
|
461
|
+
};
|
|
462
|
+
} else {
|
|
463
|
+
const out = res.stdout.trim();
|
|
464
|
+
lines = out ? out.split("\n") : [];
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
ok: true,
|
|
468
|
+
text: lines.length
|
|
469
|
+
? `${lines.length} match line(s):\n${lines.slice(0, 200).join("\n")}${lines.length > 200 ? `\n… (${lines.length - 200} more)` : ""}`
|
|
470
|
+
: `No matches for ${pat} under ${root}.`,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ── pure-JS glob/search fallbacks (no ripgrep on the host) ──────────────────
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Glob without ripgrep, via node:fs `glob`. Mirrors rg's -g semantics for
|
|
478
|
+
* bare names (a pattern without "/" matches at any depth) and skips
|
|
479
|
+
* node_modules/.git, which rg would exclude via gitignore.
|
|
480
|
+
*/
|
|
481
|
+
async function globJs(pat: string, root: string): Promise<string[]> {
|
|
482
|
+
const pattern = pat.includes("/") ? pat : `**/${pat}`;
|
|
483
|
+
const out: string[] = [];
|
|
484
|
+
try {
|
|
485
|
+
for await (const entry of fsGlob(pattern, {
|
|
486
|
+
cwd: root,
|
|
487
|
+
exclude: (e: unknown) => {
|
|
488
|
+
const name =
|
|
489
|
+
typeof e === "string" ? e : ((e as { name?: string }).name ?? "");
|
|
490
|
+
return (
|
|
491
|
+
SKIP_DIRS_RE.test(name) || name === "node_modules" || name === ".git"
|
|
492
|
+
);
|
|
493
|
+
},
|
|
494
|
+
})) {
|
|
495
|
+
out.push(join(root, String(entry)));
|
|
496
|
+
if (out.length >= MAX_JS_RESULTS) break;
|
|
497
|
+
}
|
|
498
|
+
} catch {
|
|
499
|
+
// unreadable root etc. — empty result, caller reports "no matches"
|
|
500
|
+
}
|
|
501
|
+
return out;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Content search without ripgrep: walk text files and regex-match lines. */
|
|
505
|
+
async function searchJs(
|
|
506
|
+
pat: string,
|
|
507
|
+
root: string,
|
|
508
|
+
globPat: string | undefined,
|
|
509
|
+
caseInsensitive: boolean,
|
|
510
|
+
): Promise<string[]> {
|
|
511
|
+
const re = new RegExp(pat, caseInsensitive ? "i" : "");
|
|
512
|
+
let files: string[];
|
|
513
|
+
try {
|
|
514
|
+
const st = await fsStat(root);
|
|
515
|
+
files = st.isFile() ? [root] : await globJs(globPat ?? "**/*", root);
|
|
516
|
+
} catch {
|
|
517
|
+
return [];
|
|
518
|
+
}
|
|
519
|
+
const lines: string[] = [];
|
|
520
|
+
for (const f of files) {
|
|
521
|
+
let content: string;
|
|
522
|
+
try {
|
|
523
|
+
const st = await fsStat(f);
|
|
524
|
+
if (!st.isFile() || st.size > MAX_JS_FILE_BYTES) continue;
|
|
525
|
+
content = await readFile(f, "utf8");
|
|
526
|
+
} catch {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (content.includes("\0")) continue; // binary
|
|
530
|
+
const fileLines = content.split("\n");
|
|
531
|
+
for (let i = 0; i < fileLines.length; i++) {
|
|
532
|
+
if (re.test(fileLines[i])) lines.push(`${f}:${i + 1}:${fileLines[i]}`);
|
|
533
|
+
if (lines.length >= MAX_JS_RESULTS) return lines;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return lines;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
540
|
+
|
|
541
|
+
function runLocal(
|
|
542
|
+
bin: string,
|
|
543
|
+
args: string[],
|
|
544
|
+
): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
545
|
+
return new Promise((resolvePromise) => {
|
|
546
|
+
const child = spawn(bin, args, { env: process.env });
|
|
547
|
+
const stdout = createOutputCapture();
|
|
548
|
+
const stderr = createOutputCapture();
|
|
549
|
+
child.stdout.on("data", stdout.push);
|
|
550
|
+
child.stderr.on("data", stderr.push);
|
|
551
|
+
child.on("error", () =>
|
|
552
|
+
resolvePromise({
|
|
553
|
+
code: 127,
|
|
554
|
+
stdout: stdout.value(),
|
|
555
|
+
stderr: stderr.value(),
|
|
556
|
+
}),
|
|
557
|
+
);
|
|
558
|
+
child.on("close", (code) =>
|
|
559
|
+
resolvePromise({
|
|
560
|
+
code: code ?? 0,
|
|
561
|
+
stdout: stdout.value(),
|
|
562
|
+
stderr: stderr.value(),
|
|
563
|
+
}),
|
|
564
|
+
);
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function renderExec(
|
|
569
|
+
where: string,
|
|
570
|
+
status: string,
|
|
571
|
+
stdout: string,
|
|
572
|
+
stderr: string,
|
|
573
|
+
): string {
|
|
574
|
+
const parts = [`[${where}] ${status}`];
|
|
575
|
+
if (stdout.trim())
|
|
576
|
+
parts.push(
|
|
577
|
+
`--- stdout ---\n${clampExecOutput(stdout.replace(/\s+$/, ""))}`,
|
|
578
|
+
);
|
|
579
|
+
if (stderr.trim())
|
|
580
|
+
parts.push(
|
|
581
|
+
`--- stderr ---\n${clampExecOutput(stderr.replace(/\s+$/, ""))}`,
|
|
582
|
+
);
|
|
583
|
+
if (!stdout.trim() && !stderr.trim()) parts.push("(no output)");
|
|
584
|
+
return parts.join("\n");
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function clampTimeout(value: unknown): number {
|
|
588
|
+
const sec = typeof value === "number" ? value : Number(value);
|
|
589
|
+
if (!Number.isFinite(sec) || sec <= 0) return DEFAULT_EXEC_TIMEOUT_MS;
|
|
590
|
+
return Math.min(
|
|
591
|
+
MAX_EXEC_TIMEOUT_MS,
|
|
592
|
+
Math.max(1_000, Math.round(sec * 1_000)),
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function shellQuote(s: string): string {
|
|
597
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function str(value: unknown): string | undefined {
|
|
601
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function num(value: unknown): number | undefined {
|
|
605
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
606
|
+
return Number.isFinite(n) ? n : undefined;
|
|
607
|
+
}
|
|
@@ -60,6 +60,8 @@ export type HubConfig = {
|
|
|
60
60
|
disabledTools?: readonly string[];
|
|
61
61
|
disabledToolTags?: readonly string[];
|
|
62
62
|
braveApiKey?: string;
|
|
63
|
+
/** Surface the native tool set (bash/read/write/… + teleport). */
|
|
64
|
+
nativeTools?: boolean;
|
|
63
65
|
};
|
|
64
66
|
|
|
65
67
|
let hubConfig: HubConfig = {};
|
|
@@ -164,6 +166,7 @@ function buildServerFor(target: HubTarget, bridgeUrl: string) {
|
|
|
164
166
|
bridgeUrl,
|
|
165
167
|
disabledTools: hubConfig.disabledTools,
|
|
166
168
|
disabledToolTags: hubConfig.disabledToolTags,
|
|
169
|
+
includeNativeTools: hubConfig.nativeTools,
|
|
167
170
|
});
|
|
168
171
|
}
|
|
169
172
|
// brave-search is chat-agnostic (one shared child); plugins read
|
|
@@ -34,6 +34,8 @@ export type TalonServerOptions = {
|
|
|
34
34
|
bridgeUrl: string;
|
|
35
35
|
disabledTools?: readonly string[];
|
|
36
36
|
disabledToolTags?: readonly string[];
|
|
37
|
+
/** Expose the native tool set (replaces the SDK built-ins). */
|
|
38
|
+
includeNativeTools?: boolean;
|
|
37
39
|
};
|
|
38
40
|
|
|
39
41
|
/** Build a Talon tool MCP server bound to one (frontend, chatId) pair. */
|
|
@@ -53,6 +55,7 @@ export function buildTalonToolServer(options: TalonServerOptions): McpServer {
|
|
|
53
55
|
frontend: options.frontend,
|
|
54
56
|
excludeTags,
|
|
55
57
|
excludeNames,
|
|
58
|
+
includeNativeTools: options.includeNativeTools,
|
|
56
59
|
});
|
|
57
60
|
if (excludeTags.length > 0 && !tools.some((t) => t.name === "end_turn")) {
|
|
58
61
|
const endTurn = composeTools({ frontend: options.frontend }).find(
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared 0600 JSON persistence for mesh sidecars.
|
|
3
|
+
*
|
|
4
|
+
* Writes are atomic (tmp file + rename — a rename on the same filesystem is
|
|
5
|
+
* atomic, so a crash mid-write can never leave a truncated file that a reader
|
|
6
|
+
* would silently drop) and serialized per path (concurrent writers can't
|
|
7
|
+
* interleave, and never race the temp file).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
12
|
+
import { dirname } from "node:path";
|
|
13
|
+
|
|
14
|
+
/** Per-path write chain: new writes queue onto the tail promise. */
|
|
15
|
+
const writeQueues = new Map<string, Promise<void>>();
|
|
16
|
+
|
|
17
|
+
/** Read a JSON array file, returning [] on any missing/corrupt/non-array. */
|
|
18
|
+
export async function readArray<T>(path: string): Promise<T[]> {
|
|
19
|
+
try {
|
|
20
|
+
const raw = await readFile(path, "utf8");
|
|
21
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
22
|
+
return Array.isArray(parsed) ? (parsed as T[]) : [];
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Persist JSON atomically with 0600 perms, serialized per path. */
|
|
29
|
+
export async function writePrivateJson(
|
|
30
|
+
path: string,
|
|
31
|
+
value: unknown,
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
const prior = writeQueues.get(path) ?? Promise.resolve();
|
|
34
|
+
const next = prior.catch(() => {}).then(() => atomicWriteJson(path, value));
|
|
35
|
+
writeQueues.set(
|
|
36
|
+
path,
|
|
37
|
+
next.finally(() => {
|
|
38
|
+
if (writeQueues.get(path) === next) writeQueues.delete(path);
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
return next;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function atomicWriteJson(path: string, value: unknown): Promise<void> {
|
|
45
|
+
await mkdir(dirname(path), { recursive: true });
|
|
46
|
+
const tmp = `${path}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
47
|
+
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
48
|
+
await rename(tmp, path);
|
|
49
|
+
}
|