talon-agent 1.34.1 → 1.36.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.
@@ -0,0 +1,574 @@
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 active `teleport` target. With
6
+ * no teleport, they run on the daemon host (local spawn / local fs / local
7
+ * ripgrep). With a teleport engaged, they run ON the companion device via the
8
+ * mesh exec/fs channel — so `bash`/`read`/`write`/… transparently operate on
9
+ * the phone. Talon acts as if it were running on whichever node is active.
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 type { SharedActionHandlers } from "./types.js";
35
+
36
+ type Result = { ok: boolean; text: string };
37
+
38
+ /**
39
+ * ripgrep binary, resolved from PATH (env-overridable for tests). NOT a
40
+ * hardcoded absolute path: /usr/bin/rg only exists on some Linux installs,
41
+ * and a missing binary must fall back loudly (or to the pure-JS walker),
42
+ * never masquerade as "no matches".
43
+ */
44
+ function rgBin(): string {
45
+ return process.env.TALON_NATIVE_RG ?? "rg";
46
+ }
47
+ const DEFAULT_EXEC_TIMEOUT_MS = 60_000;
48
+ const MAX_EXEC_TIMEOUT_MS = 300_000;
49
+ const MAX_READ_LINES = 2_000;
50
+ /** Caps for the pure-JS glob/search fallbacks (rg unavailable). */
51
+ const MAX_JS_RESULTS = 2_000;
52
+ const MAX_JS_FILE_BYTES = 2 * 1024 * 1024;
53
+ const SKIP_DIRS_RE = /(^|[\\/])(node_modules|\.git)([\\/]|$)/;
54
+ /** Markers used to recover the post-command working dir from a teleport shell. */
55
+ const CWD_OPEN = "__TALON_CWD__";
56
+ const CWD_CLOSE = "__TALON_CWD_END__";
57
+
58
+ export const nativeHandlers: SharedActionHandlers = {
59
+ teleport: (body) => teleport(body.device),
60
+ teleport_back: () => teleportBack(),
61
+ native_bash: (body) => bash(body.command, body.cwd, body.timeout_sec),
62
+ native_read: (body) => read(body.path, body.offset, body.limit),
63
+ native_write: (body) => write(body.path, body.content),
64
+ native_edit: (body) =>
65
+ edit(body.path, body.old_string, body.new_string, body.replace_all),
66
+ native_glob: (body) => glob(body.pattern, body.path),
67
+ native_search: (body) =>
68
+ search(body.pattern, body.path, body.glob, body.case_insensitive),
69
+ };
70
+
71
+ // ── Teleport control ────────────────────────────────────────────────────────
72
+
73
+ async function teleport(query: unknown): Promise<Result> {
74
+ const svc = getMeshService();
75
+ await svc.list();
76
+ const target = svc.chooseDevice(query);
77
+ if (!target) {
78
+ return {
79
+ ok: false,
80
+ text:
81
+ typeof query === "string" && query.trim()
82
+ ? `No mesh device matches "${query}". Use list_devices to see them.`
83
+ : "No mesh device to teleport to. Register a companion first.",
84
+ };
85
+ }
86
+ if (!target.online) {
87
+ return {
88
+ ok: false,
89
+ text: `${target.name} appears offline — cannot teleport onto a device that isn't connected.`,
90
+ };
91
+ }
92
+ if (target.capabilities && !target.capabilities.includes("exec")) {
93
+ return {
94
+ ok: false,
95
+ text: `${target.name} does not advertise the "exec" capability, so teleport can't run commands on it.`,
96
+ };
97
+ }
98
+ await setTeleport(target.id, target.name);
99
+ return {
100
+ ok: true,
101
+ text: [
102
+ `🛰️ Teleported onto ${target.name}. Native bash/read/write/edit/glob/search now run ON that device.`,
103
+ `Working dir starts at the device default; \`cd\` in bash persists across calls.`,
104
+ `Call teleport_back to return to the daemon host.`,
105
+ ].join(" "),
106
+ };
107
+ }
108
+
109
+ async function teleportBack(): Promise<Result> {
110
+ const prior = await clearTeleport();
111
+ return {
112
+ ok: true,
113
+ text: prior
114
+ ? `↩️ Teleported back from ${prior.deviceName}. Native tools run on the daemon host again.`
115
+ : "Not teleported — native tools already run on the daemon host.",
116
+ };
117
+ }
118
+
119
+ // ── bash ────────────────────────────────────────────────────────────────────
120
+
121
+ async function bash(
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();
130
+ if (active) return bashTeleported(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
+ let stdout = "";
149
+ let stderr = "";
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", (d) => (stdout += d.toString()));
164
+ child.stderr.on("data", (d) => (stderr += d.toString()));
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, stderr),
177
+ });
178
+ });
179
+ });
180
+ }
181
+
182
+ async function bashTeleported(
183
+ deviceId: string,
184
+ cmd: string,
185
+ timeoutMs: number,
186
+ ): Promise<Result> {
187
+ const active = await getTeleport();
188
+ const cwd = active?.cwd;
189
+ // Wrap so the resulting working dir is reported back and persists across
190
+ // calls (a `cd` in `cmd` carries forward), while the real exit code is
191
+ // preserved. printf can't fail in a way that masks the command's status.
192
+ const wrapped =
193
+ `${cwd ? `cd ${shellQuote(cwd)} 2>/dev/null; ` : ""}` +
194
+ `{ ${cmd}\n}; __talon_rc=$?; ` +
195
+ `printf '${CWD_OPEN}%s${CWD_CLOSE}' "$(pwd 2>/dev/null)"; exit $__talon_rc`;
196
+ const dispatched = await getMeshService().dispatchCommand(
197
+ deviceId,
198
+ "exec",
199
+ { cmd: wrapped, timeoutMs },
200
+ timeoutMs + 5_000,
201
+ );
202
+ if ("error" in dispatched) return { ok: false, text: dispatched.error };
203
+ const { target, result } = dispatched;
204
+ const data = result.data ?? {};
205
+ let stdout = typeof data.stdout === "string" ? data.stdout : "";
206
+ const stderr = typeof data.stderr === "string" ? data.stderr : "";
207
+ const exitCode =
208
+ typeof data.exitCode === "number" ? data.exitCode : undefined;
209
+ // Recover + strip the trailing cwd marker.
210
+ const open = stdout.lastIndexOf(CWD_OPEN);
211
+ if (open !== -1) {
212
+ const close = stdout.indexOf(CWD_CLOSE, open);
213
+ if (close !== -1) {
214
+ const newCwd = stdout.slice(open + CWD_OPEN.length, close).trim();
215
+ stdout = stdout.slice(0, open);
216
+ if (newCwd) await setTeleportCwd(newCwd);
217
+ }
218
+ }
219
+ if (!result.ok && exitCode === undefined) {
220
+ return {
221
+ ok: false,
222
+ text: result.message ?? `${target.name} could not run the command.`,
223
+ };
224
+ }
225
+ return {
226
+ ok: exitCode === 0,
227
+ text: renderExec(target.name, `exit ${exitCode ?? "?"}`, stdout, stderr),
228
+ };
229
+ }
230
+
231
+ // ── read / write / edit ─────────────────────────────────────────────────────
232
+
233
+ async function read(
234
+ path: unknown,
235
+ offset: unknown,
236
+ limit: unknown,
237
+ ): Promise<Result> {
238
+ const p = str(path);
239
+ if (!p) return { ok: false, text: "A file path is required." };
240
+ const start = num(offset) ?? 0;
241
+ const max = Math.min(num(limit) ?? MAX_READ_LINES, MAX_READ_LINES);
242
+ const active = await getTeleport();
243
+ let content: string;
244
+ let where: string;
245
+ if (active) {
246
+ const res = await getMeshService().readFileBytes(active.deviceId, p);
247
+ if ("error" in res) return { ok: false, text: res.error };
248
+ content = res.data.toString("utf8");
249
+ where = active.deviceName;
250
+ } else {
251
+ try {
252
+ content = await readFile(p, "utf8");
253
+ } catch (err) {
254
+ return { ok: false, text: `Cannot read ${p}: ${(err as Error).message}` };
255
+ }
256
+ where = "local";
257
+ }
258
+ const lines = content.split("\n");
259
+ const slice = lines.slice(start, start + max);
260
+ const numbered = slice
261
+ .map((line, i) => `${String(start + i + 1).padStart(6)}\t${line}`)
262
+ .join("\n");
263
+ const more =
264
+ lines.length > start + max
265
+ ? `\n… (${lines.length - start - max} more lines; raise limit/offset)`
266
+ : "";
267
+ return {
268
+ ok: true,
269
+ text: `${p} [${where}] — ${lines.length} lines\n${numbered}${more}`,
270
+ };
271
+ }
272
+
273
+ async function write(path: unknown, content: unknown): Promise<Result> {
274
+ const p = str(path);
275
+ if (!p) return { ok: false, text: "A file path is required." };
276
+ const body = typeof content === "string" ? content : "";
277
+ const active = await getTeleport();
278
+ if (active) {
279
+ return getMeshService().writeFileToDevice(active.deviceId, p, body);
280
+ }
281
+ try {
282
+ await mkdir(dirname(p), { recursive: true });
283
+ await writeFile(p, body);
284
+ } catch (err) {
285
+ return { ok: false, text: `Cannot write ${p}: ${(err as Error).message}` };
286
+ }
287
+ return { ok: true, text: `Wrote ${body.length} bytes to ${p} [local].` };
288
+ }
289
+
290
+ async function edit(
291
+ path: unknown,
292
+ oldString: unknown,
293
+ newString: unknown,
294
+ replaceAll: unknown,
295
+ ): Promise<Result> {
296
+ const p = str(path);
297
+ if (!p) return { ok: false, text: "A file path is required." };
298
+ const from = typeof oldString === "string" ? oldString : "";
299
+ const to = typeof newString === "string" ? newString : "";
300
+ if (from === to)
301
+ return { ok: false, text: "old_string and new_string are identical." };
302
+ const active = await getTeleport();
303
+ const svc = getMeshService();
304
+
305
+ let content: string;
306
+ if (active) {
307
+ const res = await svc.readFileBytes(active.deviceId, p);
308
+ if ("error" in res) return { ok: false, text: res.error };
309
+ content = res.data.toString("utf8");
310
+ } else {
311
+ try {
312
+ content = await readFile(p, "utf8");
313
+ } catch (err) {
314
+ return { ok: false, text: `Cannot read ${p}: ${(err as Error).message}` };
315
+ }
316
+ }
317
+
318
+ const count = from ? content.split(from).length - 1 : 0;
319
+ if (count === 0) return { ok: false, text: `old_string not found in ${p}.` };
320
+ if (count > 1 && replaceAll !== true) {
321
+ return {
322
+ ok: false,
323
+ text: `old_string appears ${count}× in ${p}; pass replace_all or make it unique.`,
324
+ };
325
+ }
326
+ const updated =
327
+ replaceAll === true
328
+ ? content.split(from).join(to)
329
+ : content.replace(from, to);
330
+
331
+ if (active) {
332
+ const res = await svc.writeFileToDevice(active.deviceId, p, updated);
333
+ return res.ok
334
+ ? {
335
+ ok: true,
336
+ text: `Edited ${p} [${active.deviceName}] (${count} replacement${count === 1 ? "" : "s"}).`,
337
+ }
338
+ : res;
339
+ }
340
+ try {
341
+ await writeFile(p, updated);
342
+ } catch (err) {
343
+ return { ok: false, text: `Cannot write ${p}: ${(err as Error).message}` };
344
+ }
345
+ return {
346
+ ok: true,
347
+ text: `Edited ${p} [local] (${count} replacement${count === 1 ? "" : "s"}).`,
348
+ };
349
+ }
350
+
351
+ // ── glob / search ───────────────────────────────────────────────────────────
352
+
353
+ async function glob(pattern: unknown, path: unknown): Promise<Result> {
354
+ const pat = str(pattern);
355
+ if (!pat) return { ok: false, text: "A glob pattern is required." };
356
+ const root = str(path) ?? ".";
357
+ const active = await getTeleport();
358
+ if (active) {
359
+ // Prefer rg on the device; fall back to find (basename patterns via
360
+ // -name, path patterns via -path). `command -v` gates the choice so a
361
+ // no-match rg exit (1) isn't misread as "rg missing, run find too".
362
+ const findExpr = pat.includes("/")
363
+ ? `-path ${shellQuote(`*${pat}`)}`
364
+ : `-name ${shellQuote(pat)}`;
365
+ const cmd =
366
+ `if command -v rg >/dev/null 2>&1; ` +
367
+ `then rg --files -g ${shellQuote(pat)} ${shellQuote(root)}; ` +
368
+ `else find ${shellQuote(root)} ${findExpr} 2>/dev/null; fi`;
369
+ return bashTeleported(active.deviceId, cmd, 30_000);
370
+ }
371
+ const res = await runLocal(rgBin(), ["--files", "-g", pat, root]);
372
+ let files: string[];
373
+ if (res.code === 127) {
374
+ // rg not installed — pure-JS fallback rather than lying "no matches".
375
+ files = await globJs(pat, root);
376
+ } else if (res.code > 1 && !res.stdout.trim()) {
377
+ // exit 2 with output = partial results (e.g. permission-denied subdirs);
378
+ // exit 2 with none = a real error worth surfacing.
379
+ return {
380
+ ok: false,
381
+ text: `glob failed: ${res.stderr.trim() || `ripgrep exit ${res.code}`}`,
382
+ };
383
+ } else {
384
+ files = res.stdout.trim().split("\n").filter(Boolean);
385
+ }
386
+ return {
387
+ ok: true,
388
+ text: files.length
389
+ ? `${files.length} match(es):\n${files.slice(0, 200).join("\n")}${files.length > 200 ? `\n… (${files.length - 200} more)` : ""}`
390
+ : `No files match ${pat} under ${root}.`,
391
+ };
392
+ }
393
+
394
+ async function search(
395
+ pattern: unknown,
396
+ path: unknown,
397
+ globPat: unknown,
398
+ caseInsensitive: unknown,
399
+ ): Promise<Result> {
400
+ const pat = str(pattern);
401
+ if (!pat) return { ok: false, text: "A search pattern is required." };
402
+ const root = str(path) ?? ".";
403
+ const g = str(globPat);
404
+ const ci = caseInsensitive === true;
405
+ // `-e` keeps a pattern that starts with "-" from being parsed as a flag
406
+ // (same idiom for rg and grep).
407
+ const flags = [
408
+ "-n",
409
+ "--color=never",
410
+ ...(ci ? ["-i"] : []),
411
+ ...(g ? ["-g", g] : []),
412
+ ];
413
+ const active = await getTeleport();
414
+ if (active) {
415
+ // Prefer rg on the device, fall back to grep (Android toybox has grep
416
+ // but rarely rg). --include is grep's closest analogue of -g.
417
+ const grepFlags = [
418
+ "-rn",
419
+ ...(ci ? ["-i"] : []),
420
+ ...(g ? [`--include=${g}`] : []),
421
+ ];
422
+ const cmd =
423
+ `if command -v rg >/dev/null 2>&1; ` +
424
+ `then rg ${flags.map(shellQuote).join(" ")} -e ${shellQuote(pat)} ${shellQuote(root)}; ` +
425
+ `else grep ${grepFlags.map(shellQuote).join(" ")} -e ${shellQuote(pat)} ${shellQuote(root)} 2>/dev/null; fi`;
426
+ return bashTeleported(active.deviceId, cmd, 30_000);
427
+ }
428
+ const res = await runLocal(rgBin(), [...flags, "-e", pat, root]);
429
+ let lines: string[];
430
+ if (res.code === 127) {
431
+ // rg not installed — pure-JS fallback rather than lying "no matches".
432
+ try {
433
+ lines = await searchJs(pat, root, g, ci);
434
+ } catch (err) {
435
+ return { ok: false, text: `search failed: ${(err as Error).message}` };
436
+ }
437
+ } else if (res.code > 1 && !res.stdout.trim()) {
438
+ // exit 2 with output = partial results; exit 2 with none = real error.
439
+ return {
440
+ ok: false,
441
+ text: `search failed: ${res.stderr.trim() || `ripgrep exit ${res.code}`}`,
442
+ };
443
+ } else {
444
+ const out = res.stdout.trim();
445
+ lines = out ? out.split("\n") : [];
446
+ }
447
+ return {
448
+ ok: true,
449
+ text: lines.length
450
+ ? `${lines.length} match line(s):\n${lines.slice(0, 200).join("\n")}${lines.length > 200 ? `\n… (${lines.length - 200} more)` : ""}`
451
+ : `No matches for ${pat} under ${root}.`,
452
+ };
453
+ }
454
+
455
+ // ── pure-JS glob/search fallbacks (no ripgrep on the host) ──────────────────
456
+
457
+ /**
458
+ * Glob without ripgrep, via node:fs `glob`. Mirrors rg's -g semantics for
459
+ * bare names (a pattern without "/" matches at any depth) and skips
460
+ * node_modules/.git, which rg would exclude via gitignore.
461
+ */
462
+ async function globJs(pat: string, root: string): Promise<string[]> {
463
+ const pattern = pat.includes("/") ? pat : `**/${pat}`;
464
+ const out: string[] = [];
465
+ try {
466
+ for await (const entry of fsGlob(pattern, {
467
+ cwd: root,
468
+ exclude: (e: unknown) => {
469
+ const name =
470
+ typeof e === "string" ? e : ((e as { name?: string }).name ?? "");
471
+ return (
472
+ SKIP_DIRS_RE.test(name) || name === "node_modules" || name === ".git"
473
+ );
474
+ },
475
+ })) {
476
+ out.push(join(root, String(entry)));
477
+ if (out.length >= MAX_JS_RESULTS) break;
478
+ }
479
+ } catch {
480
+ // unreadable root etc. — empty result, caller reports "no matches"
481
+ }
482
+ return out;
483
+ }
484
+
485
+ /** Content search without ripgrep: walk text files and regex-match lines. */
486
+ async function searchJs(
487
+ pat: string,
488
+ root: string,
489
+ globPat: string | undefined,
490
+ caseInsensitive: boolean,
491
+ ): Promise<string[]> {
492
+ const re = new RegExp(pat, caseInsensitive ? "i" : "");
493
+ let files: string[];
494
+ try {
495
+ const st = await fsStat(root);
496
+ files = st.isFile() ? [root] : await globJs(globPat ?? "**/*", root);
497
+ } catch {
498
+ return [];
499
+ }
500
+ const lines: string[] = [];
501
+ for (const f of files) {
502
+ let content: string;
503
+ try {
504
+ const st = await fsStat(f);
505
+ if (!st.isFile() || st.size > MAX_JS_FILE_BYTES) continue;
506
+ content = await readFile(f, "utf8");
507
+ } catch {
508
+ continue;
509
+ }
510
+ if (content.includes("\0")) continue; // binary
511
+ const fileLines = content.split("\n");
512
+ for (let i = 0; i < fileLines.length; i++) {
513
+ if (re.test(fileLines[i])) lines.push(`${f}:${i + 1}:${fileLines[i]}`);
514
+ if (lines.length >= MAX_JS_RESULTS) return lines;
515
+ }
516
+ }
517
+ return lines;
518
+ }
519
+
520
+ // ── helpers ─────────────────────────────────────────────────────────────────
521
+
522
+ function runLocal(
523
+ bin: string,
524
+ args: string[],
525
+ ): Promise<{ code: number; stdout: string; stderr: string }> {
526
+ return new Promise((resolvePromise) => {
527
+ const child = spawn(bin, args, { env: process.env });
528
+ let stdout = "";
529
+ let stderr = "";
530
+ child.stdout.on("data", (d) => (stdout += d.toString()));
531
+ child.stderr.on("data", (d) => (stderr += d.toString()));
532
+ child.on("error", () => resolvePromise({ code: 127, stdout, stderr }));
533
+ child.on("close", (code) =>
534
+ resolvePromise({ code: code ?? 0, stdout, stderr }),
535
+ );
536
+ });
537
+ }
538
+
539
+ function renderExec(
540
+ where: string,
541
+ status: string,
542
+ stdout: string,
543
+ stderr: string,
544
+ ): string {
545
+ const parts = [`[${where}] ${status}`];
546
+ if (stdout.trim())
547
+ parts.push(`--- stdout ---\n${stdout.replace(/\s+$/, "")}`);
548
+ if (stderr.trim())
549
+ parts.push(`--- stderr ---\n${stderr.replace(/\s+$/, "")}`);
550
+ if (!stdout.trim() && !stderr.trim()) parts.push("(no output)");
551
+ return parts.join("\n");
552
+ }
553
+
554
+ function clampTimeout(value: unknown): number {
555
+ const sec = typeof value === "number" ? value : Number(value);
556
+ if (!Number.isFinite(sec) || sec <= 0) return DEFAULT_EXEC_TIMEOUT_MS;
557
+ return Math.min(
558
+ MAX_EXEC_TIMEOUT_MS,
559
+ Math.max(1_000, Math.round(sec * 1_000)),
560
+ );
561
+ }
562
+
563
+ function shellQuote(s: string): string {
564
+ return `'${s.replace(/'/g, `'\\''`)}'`;
565
+ }
566
+
567
+ function str(value: unknown): string | undefined {
568
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
569
+ }
570
+
571
+ function num(value: unknown): number | undefined {
572
+ const n = typeof value === "number" ? value : Number(value);
573
+ return Number.isFinite(n) ? n : undefined;
574
+ }
@@ -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,27 @@
1
+ /**
2
+ * Device mesh — core module barrel.
3
+ *
4
+ * Registry (persistence) + service (policy, locate fan-out, tool surface) +
5
+ * canonical device/location types. Transports and gateway actions import
6
+ * from here.
7
+ */
8
+
9
+ export { MeshRegistry } from "./registry.js";
10
+ export {
11
+ MeshService,
12
+ getMeshService,
13
+ setMeshService,
14
+ type MeshTransport,
15
+ type MeshServiceOptions,
16
+ type MeshToolResult,
17
+ } from "./service.js";
18
+ export {
19
+ sanitizeCapabilities,
20
+ toDeviceInfo,
21
+ toDeviceLocation,
22
+ type DeviceCommand,
23
+ type DeviceCommandResult,
24
+ type DeviceInfo,
25
+ type DeviceLocation,
26
+ type DevicePlatform,
27
+ } from "./types.js";
@@ -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
+ }