talon-agent 5.0.0 → 5.1.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.
Files changed (136) hide show
  1. package/package.json +2 -2
  2. package/src/app.ts +3 -3
  3. package/src/backend/claude-sdk/factory.ts +21 -41
  4. package/src/backend/claude-sdk/handler.ts +191 -69
  5. package/src/backend/claude-sdk/host/in-process.ts +147 -0
  6. package/src/backend/claude-sdk/one-shot.ts +17 -2
  7. package/src/backend/claude-sdk/stream.ts +9 -0
  8. package/src/backend/codex/mcp-config.ts +1 -1
  9. package/src/backend/codex/one-shot.ts +18 -4
  10. package/src/backend/openai-agents/mcp-pool.ts +1 -1
  11. package/src/backend/remote-server/one-shot.ts +16 -3
  12. package/src/backend/runtime/index.ts +2 -2
  13. package/src/backend/runtime/one-shot-hooks.ts +45 -0
  14. package/src/backend/runtime/turn/delivery.ts +1 -1
  15. package/src/backend/runtime/turn/turn-phases.ts +1 -1
  16. package/src/bootstrap.ts +2 -2
  17. package/src/cli.ts +5 -2
  18. package/src/core/agent-runtime/README.md +15 -0
  19. package/src/core/agent-runtime/agent-host.ts +388 -0
  20. package/src/core/agent-runtime/capabilities.ts +3 -0
  21. package/src/core/background/cron/scheduler.ts +1 -1
  22. package/src/core/background/triggers/command.ts +1 -1
  23. package/src/{util → core/config}/harden.ts +1 -1
  24. package/src/core/config/index.ts +1 -1
  25. package/src/core/daemon/pidfile.ts +1 -1
  26. package/src/core/daemon/resource-sampler.ts +2 -2
  27. package/src/{util → core/daemon}/respawn.ts +1 -1
  28. package/src/core/doctor/index.ts +1 -1
  29. package/src/core/engine/gateway-actions/fetch-url.ts +1 -1
  30. package/src/core/engine/gateway-actions/index.ts +1 -1
  31. package/src/core/engine/gateway-actions/memory.ts +2 -2
  32. package/src/core/engine/gateway-actions/native/exec-background.ts +129 -0
  33. package/src/core/engine/gateway-actions/native/exec-remote.ts +85 -0
  34. package/src/core/engine/gateway-actions/native/exec.ts +182 -0
  35. package/src/core/engine/gateway-actions/native/index.ts +46 -0
  36. package/src/core/engine/gateway-actions/native/params.ts +46 -0
  37. package/src/core/engine/gateway-actions/native/read.ts +182 -0
  38. package/src/core/engine/gateway-actions/native/results.ts +34 -0
  39. package/src/core/engine/gateway-actions/native/search.ts +212 -0
  40. package/src/core/engine/gateway-actions/native/shell.ts +39 -0
  41. package/src/core/engine/gateway-actions/native/teleport.ts +55 -0
  42. package/src/core/engine/gateway-actions/native/write.ts +170 -0
  43. package/src/core/frontend-runtime/builtins.ts +2 -2
  44. package/src/core/mcp-hub/children.ts +21 -5
  45. package/src/core/mcp-hub/index.ts +1 -1
  46. package/src/core/mcp-hub/talon-server.ts +1 -1
  47. package/src/core/memory/taps.ts +1 -1
  48. package/src/core/plugin/mcp.ts +1 -1
  49. package/src/core/scripts/lua.ts +1 -1
  50. package/src/core/tools/{chat.ts → chat/chat.ts} +1 -1
  51. package/src/core/tools/{cross-send.ts → chat/cross-send.ts} +1 -1
  52. package/src/core/tools/{history.ts → chat/history.ts} +2 -2
  53. package/src/core/tools/{media.ts → chat/media.ts} +1 -1
  54. package/src/core/tools/{members.ts → chat/members.ts} +2 -2
  55. package/src/core/tools/{messaging.ts → chat/messaging.ts} +2 -2
  56. package/src/core/tools/{moderation.ts → chat/moderation.ts} +2 -2
  57. package/src/core/tools/{stickers.ts → chat/stickers.ts} +2 -2
  58. package/src/core/tools/{whatsapp.ts → chat/whatsapp.ts} +1 -1
  59. package/src/core/tools/{memory.ts → content/memory.ts} +2 -2
  60. package/src/core/tools/{web.ts → content/web.ts} +1 -1
  61. package/src/core/tools/index.ts +20 -20
  62. package/src/core/tools/{admin.ts → ops/admin.ts} +1 -1
  63. package/src/core/tools/{bridge.ts → ops/bridge.ts} +2 -2
  64. package/src/core/tools/{goals.ts → ops/goals.ts} +2 -2
  65. package/src/core/tools/{mesh.ts → ops/mesh.ts} +1 -1
  66. package/src/core/tools/{models.ts → ops/models.ts} +1 -1
  67. package/src/core/tools/{native.ts → ops/native.ts} +1 -1
  68. package/src/core/tools/{scheduling.ts → ops/scheduling.ts} +1 -1
  69. package/src/core/tools/{scripts.ts → ops/scripts.ts} +1 -1
  70. package/src/core/tools/{skills.ts → ops/skills.ts} +1 -1
  71. package/src/core/tools/{triggers.ts → ops/triggers.ts} +1 -1
  72. package/src/core/types.ts +11 -0
  73. package/src/{util → core/vfs}/workspace.ts +2 -2
  74. package/src/frontend/discord/callbacks/components/effort.ts +1 -1
  75. package/src/frontend/discord/callbacks/components/index.ts +1 -1
  76. package/src/frontend/discord/callbacks/components/settings.ts +1 -1
  77. package/src/frontend/discord/commands/admin.ts +1 -1
  78. package/src/frontend/discord/commands/info.ts +1 -1
  79. package/src/frontend/discord/commands/interaction.ts +1 -1
  80. package/src/frontend/discord/commands/session.ts +3 -3
  81. package/src/frontend/discord/commands/settings.ts +1 -1
  82. package/src/frontend/discord/handlers/messages.ts +1 -1
  83. package/src/frontend/discord/handlers/state.ts +1 -1
  84. package/src/frontend/discord/middleware.ts +1 -1
  85. package/src/frontend/discord/ready.ts +1 -1
  86. package/src/frontend/discord/render.ts +41 -259
  87. package/src/frontend/native/chats/chats.ts +1 -1
  88. package/src/frontend/native/surface/models.ts +1 -1
  89. package/src/frontend/native/turn/context.ts +1 -1
  90. package/src/frontend/native/turn/emit.ts +1 -1
  91. package/src/frontend/{shared → presentation}/format.ts +4 -3
  92. package/src/frontend/{shared → presentation}/reasoning-levels.ts +12 -0
  93. package/src/frontend/presentation/reports.ts +493 -0
  94. package/src/frontend/{shared → presentation}/session-status.ts +1 -1
  95. package/src/frontend/teams/commands.ts +2 -2
  96. package/src/frontend/teams/turn.ts +1 -1
  97. package/src/frontend/telegram/admin/health.ts +1 -1
  98. package/src/frontend/telegram/admin/sessions.ts +1 -1
  99. package/src/frontend/telegram/callbacks/effort.ts +2 -2
  100. package/src/frontend/telegram/callbacks/metrics.ts +1 -1
  101. package/src/frontend/telegram/callbacks/model/views.ts +1 -1
  102. package/src/frontend/telegram/callbacks/query.ts +1 -1
  103. package/src/frontend/telegram/callbacks/settings.ts +2 -2
  104. package/src/frontend/telegram/commands/admin.ts +4 -4
  105. package/src/frontend/telegram/commands/info.ts +2 -2
  106. package/src/frontend/telegram/commands/session.ts +4 -4
  107. package/src/frontend/telegram/commands/settings.ts +4 -2
  108. package/src/frontend/telegram/handlers/state.ts +1 -1
  109. package/src/frontend/telegram/model-menu.ts +1 -1
  110. package/src/frontend/telegram/render/html.ts +30 -0
  111. package/src/frontend/telegram/{helpers → render}/menu.ts +46 -19
  112. package/src/frontend/telegram/render/reports.ts +182 -0
  113. package/src/frontend/terminal/builtins/context.ts +2 -2
  114. package/src/frontend/terminal/builtins/session.ts +1 -1
  115. package/src/frontend/terminal/builtins/status.ts +2 -2
  116. package/src/frontend/terminal/index.ts +2 -2
  117. package/src/frontend/terminal/renderer.ts +1 -1
  118. package/src/frontend/whatsapp/commands.ts +5 -5
  119. package/src/frontend/whatsapp/registry.ts +1 -1
  120. package/src/index.ts +5 -2
  121. package/src/util/runtime.ts +1 -1
  122. package/src/core/engine/gateway-actions/native.ts +0 -1035
  123. package/src/frontend/telegram/helpers/diagnostics.ts +0 -428
  124. package/src/frontend/telegram/helpers/format.ts +0 -42
  125. package/src/frontend/telegram/helpers/index.ts +0 -13
  126. package/src/util/cleanup-registry.ts +0 -36
  127. /package/src/{util → core/daemon}/boot-timer.ts +0 -0
  128. /package/src/{util → core/frontend-runtime}/chat-id.ts +0 -0
  129. /package/src/{util/mcp-launcher.ts → core/mcp-hub/launcher.ts} +0 -0
  130. /package/src/{util → core/tools/content}/web-content.ts +0 -0
  131. /package/src/core/tools/{mcp-env.ts → ops/mcp-env.ts} +0 -0
  132. /package/src/{util → core/weaver}/session-name.ts +0 -0
  133. /package/src/frontend/{shared → presentation}/access.ts +0 -0
  134. /package/src/frontend/{shared → presentation}/model-commands.ts +0 -0
  135. /package/src/frontend/{shared → presentation}/plan-usage-report.ts +0 -0
  136. /package/src/frontend/{shared → presentation}/status-context.ts +0 -0
@@ -0,0 +1,212 @@
1
+ /**
2
+ * `native_glob` / `native_search` — find files by pattern, find text inside
3
+ * them.
4
+ *
5
+ * ripgrep is the fast path on both hosts; when it isn't installed the local
6
+ * runs fall back to a pure-JS walker rather than lying "no matches", and a
7
+ * teleported run falls back to the device's find/grep.
8
+ */
9
+
10
+ import { glob as fsGlob, readFile, stat as fsStat } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import { getTeleport } from "../../../mesh/teleport.js";
13
+ import { bashTeleported } from "./exec-remote.js";
14
+ import { resolvePathParam, str } from "./params.js";
15
+ import type { Result } from "./results.js";
16
+ import { runLocal, shellQuote } from "./shell.js";
17
+ import type { SharedActionHandlers } from "../types.js";
18
+
19
+ /**
20
+ * ripgrep binary, resolved from PATH (env-overridable for tests). NOT a
21
+ * hardcoded absolute path: /usr/bin/rg only exists on some Linux installs,
22
+ * and a missing binary must fall back loudly (or to the pure-JS walker),
23
+ * never masquerade as "no matches".
24
+ */
25
+ function rgBin(): string {
26
+ return process.env.TALON_NATIVE_RG ?? "rg";
27
+ }
28
+ /** Caps for the pure-JS glob/search fallbacks (rg unavailable). */
29
+ const MAX_JS_RESULTS = 2_000;
30
+ const MAX_JS_FILE_BYTES = 2 * 1024 * 1024;
31
+ const SKIP_DIRS_RE = /(^|[\\/])(node_modules|\.git)([\\/]|$)/;
32
+
33
+ export const searchHandlers: SharedActionHandlers = {
34
+ native_glob: (body, chatId) => glob(chatId, body.pattern, body.path),
35
+ native_search: (body, chatId) =>
36
+ search(chatId, body.pattern, body.path, body.glob, body.case_insensitive),
37
+ };
38
+
39
+ async function glob(
40
+ chatId: number,
41
+ pattern: unknown,
42
+ path: unknown,
43
+ ): Promise<Result> {
44
+ const pat = str(pattern);
45
+ if (!pat) return { ok: false, text: "A glob pattern is required." };
46
+ const active = await getTeleport(chatId);
47
+ const root = resolvePathParam(str(path) ?? ".", active?.deviceName);
48
+ if (active) {
49
+ // Prefer rg on the device; fall back to find (basename patterns via
50
+ // -name, path patterns via -path). `command -v` gates the choice so a
51
+ // no-match rg exit (1) isn't misread as "rg missing, run find too".
52
+ const findExpr = pat.includes("/")
53
+ ? `-type f -path ${shellQuote(`*${pat}`)}`
54
+ : `-type f -name ${shellQuote(pat)}`;
55
+ const cmd =
56
+ `if command -v rg >/dev/null 2>&1; ` +
57
+ `then rg --files -g ${shellQuote(pat)} ${shellQuote(root)}; ` +
58
+ `else find ${shellQuote(root)} ${findExpr} 2>/dev/null; fi`;
59
+ return bashTeleported(chatId, active.deviceId, cmd, 30_000);
60
+ }
61
+ const res = await runLocal(rgBin(), ["--files", "-g", pat, root]);
62
+ let files: string[];
63
+ if (res.code === 127) {
64
+ // rg not installed — pure-JS fallback rather than lying "no matches".
65
+ files = await globJs(pat, root);
66
+ } else if (res.code > 1 && !res.stdout.trim()) {
67
+ // exit 2 with output = partial results (e.g. permission-denied subdirs);
68
+ // exit 2 with none = a real error worth surfacing.
69
+ return {
70
+ ok: false,
71
+ text: `glob failed: ${res.stderr.trim() || `ripgrep exit ${res.code}`}`,
72
+ };
73
+ } else {
74
+ files = res.stdout.trim().split("\n").filter(Boolean);
75
+ }
76
+ // rg's parallel directory walk (and the JS fallback's) emit in
77
+ // nondeterministic order — sort so identical calls render identically.
78
+ files.sort();
79
+ return {
80
+ ok: true,
81
+ text: files.length
82
+ ? `${files.length} match(es):\n${files.slice(0, 200).join("\n")}${files.length > 200 ? `\n… (${files.length - 200} more)` : ""}`
83
+ : `No files match ${pat} under ${root}.`,
84
+ };
85
+ }
86
+
87
+ async function search(
88
+ chatId: number,
89
+ pattern: unknown,
90
+ path: unknown,
91
+ globPat: unknown,
92
+ caseInsensitive: unknown,
93
+ ): Promise<Result> {
94
+ const pat = str(pattern);
95
+ if (!pat) return { ok: false, text: "A search pattern is required." };
96
+ const active = await getTeleport(chatId);
97
+ const root = resolvePathParam(str(path) ?? ".", active?.deviceName);
98
+ const g = str(globPat);
99
+ const ci = caseInsensitive === true;
100
+ // `-e` keeps a pattern that starts with "-" from being parsed as a flag
101
+ // (same idiom for rg and grep).
102
+ const flags = [
103
+ "-n",
104
+ "--color=never",
105
+ ...(ci ? ["-i"] : []),
106
+ ...(g ? ["-g", g] : []),
107
+ ];
108
+ if (active) {
109
+ // Prefer rg on the device, fall back to grep (Android toybox has grep
110
+ // but rarely rg). --include is grep's closest analogue of -g.
111
+ const grepFlags = [
112
+ "-rn",
113
+ ...(ci ? ["-i"] : []),
114
+ ...(g ? [`--include=${g}`] : []),
115
+ ];
116
+ const cmd =
117
+ `if command -v rg >/dev/null 2>&1; ` +
118
+ `then rg ${flags.map(shellQuote).join(" ")} -e ${shellQuote(pat)} ${shellQuote(root)}; ` +
119
+ `else grep ${grepFlags.map(shellQuote).join(" ")} -e ${shellQuote(pat)} ${shellQuote(root)} 2>/dev/null; fi`;
120
+ return bashTeleported(chatId, active.deviceId, cmd, 30_000);
121
+ }
122
+ const res = await runLocal(rgBin(), [...flags, "-e", pat, root]);
123
+ let lines: string[];
124
+ if (res.code === 127) {
125
+ // rg not installed — pure-JS fallback rather than lying "no matches".
126
+ try {
127
+ lines = await searchJs(pat, root, g, ci);
128
+ } catch (err) {
129
+ return { ok: false, text: `search failed: ${(err as Error).message}` };
130
+ }
131
+ } else if (res.code > 1 && !res.stdout.trim()) {
132
+ // exit 2 with output = partial results; exit 2 with none = real error.
133
+ return {
134
+ ok: false,
135
+ text: `search failed: ${res.stderr.trim() || `ripgrep exit ${res.code}`}`,
136
+ };
137
+ } else {
138
+ const out = res.stdout.trim();
139
+ lines = out ? out.split("\n") : [];
140
+ }
141
+ return {
142
+ ok: true,
143
+ text: lines.length
144
+ ? `${lines.length} match line(s):\n${lines.slice(0, 200).join("\n")}${lines.length > 200 ? `\n… (${lines.length - 200} more)` : ""}`
145
+ : `No matches for ${pat} under ${root}.`,
146
+ };
147
+ }
148
+
149
+ // ── pure-JS glob/search fallbacks (no ripgrep on the host) ──────────────────
150
+
151
+ /**
152
+ * Glob without ripgrep, via node:fs `glob`. Mirrors rg's -g semantics for
153
+ * bare names (a pattern without "/" matches at any depth) and skips
154
+ * node_modules/.git, which rg would exclude via gitignore.
155
+ */
156
+ async function globJs(pat: string, root: string): Promise<string[]> {
157
+ const pattern = pat.includes("/") ? pat : `**/${pat}`;
158
+ const out: string[] = [];
159
+ try {
160
+ for await (const entry of fsGlob(pattern, {
161
+ cwd: root,
162
+ exclude: (e: unknown) => {
163
+ const name =
164
+ typeof e === "string" ? e : ((e as { name?: string }).name ?? "");
165
+ return (
166
+ SKIP_DIRS_RE.test(name) || name === "node_modules" || name === ".git"
167
+ );
168
+ },
169
+ })) {
170
+ out.push(join(root, String(entry)));
171
+ if (out.length >= MAX_JS_RESULTS) break;
172
+ }
173
+ } catch {
174
+ // unreadable root etc. — empty result, caller reports "no matches"
175
+ }
176
+ return out;
177
+ }
178
+
179
+ /** Content search without ripgrep: walk text files and regex-match lines. */
180
+ async function searchJs(
181
+ pat: string,
182
+ root: string,
183
+ globPat: string | undefined,
184
+ caseInsensitive: boolean,
185
+ ): Promise<string[]> {
186
+ const re = new RegExp(pat, caseInsensitive ? "i" : "");
187
+ let files: string[];
188
+ try {
189
+ const st = await fsStat(root);
190
+ files = st.isFile() ? [root] : await globJs(globPat ?? "**/*", root);
191
+ } catch {
192
+ return [];
193
+ }
194
+ const lines: string[] = [];
195
+ for (const f of files) {
196
+ let content: string;
197
+ try {
198
+ const st = await fsStat(f);
199
+ if (!st.isFile() || st.size > MAX_JS_FILE_BYTES) continue;
200
+ content = await readFile(f, "utf8");
201
+ } catch {
202
+ continue;
203
+ }
204
+ if (content.includes("\0")) continue; // binary
205
+ const fileLines = content.split("\n");
206
+ for (let i = 0; i < fileLines.length; i++) {
207
+ if (re.test(fileLines[i])) lines.push(`${f}:${i + 1}:${fileLines[i]}`);
208
+ if (lines.length >= MAX_JS_RESULTS) return lines;
209
+ }
210
+ }
211
+ return lines;
212
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Talking to a shell: POSIX single-quoting for a command Talon builds, and a
3
+ * plain local spawn that collects exit code + stdout + stderr (used for the
4
+ * ripgrep invocations behind glob/search).
5
+ */
6
+
7
+ import { spawn } from "node:child_process";
8
+ import { createOutputCapture } from "../../../../util/exec-output.js";
9
+
10
+ export function shellQuote(s: string): string {
11
+ return `'${s.replace(/'/g, `'\\''`)}'`;
12
+ }
13
+
14
+ export function runLocal(
15
+ bin: string,
16
+ args: string[],
17
+ ): Promise<{ code: number; stdout: string; stderr: string }> {
18
+ return new Promise((resolvePromise) => {
19
+ const child = spawn(bin, args, { env: process.env });
20
+ const stdout = createOutputCapture();
21
+ const stderr = createOutputCapture();
22
+ child.stdout.on("data", stdout.push);
23
+ child.stderr.on("data", stderr.push);
24
+ child.on("error", () =>
25
+ resolvePromise({
26
+ code: 127,
27
+ stdout: stdout.value(),
28
+ stderr: stderr.value(),
29
+ }),
30
+ );
31
+ child.on("close", (code) =>
32
+ resolvePromise({
33
+ code: code ?? 0,
34
+ stdout: stdout.value(),
35
+ stderr: stderr.value(),
36
+ }),
37
+ );
38
+ });
39
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Teleport control — which host the native tools in this directory target.
3
+ *
4
+ * teleport(device) → native tools run ON that companion device
5
+ * teleport_back() → native tools run on the daemon host again
6
+ */
7
+
8
+ import { getMeshService } from "../../../mesh/index.js";
9
+ import { clearTeleport, setTeleport } from "../../../mesh/teleport.js";
10
+ import type { Result } from "./results.js";
11
+ import type { SharedActionHandlers } from "../types.js";
12
+
13
+ export const teleportHandlers: SharedActionHandlers = {
14
+ teleport: (body, chatId) => teleport(chatId, body.device),
15
+ teleport_back: (_body, chatId) => teleportBack(chatId),
16
+ };
17
+
18
+ async function teleport(chatId: number, query: unknown): Promise<Result> {
19
+ const svc = getMeshService();
20
+ await svc.list();
21
+ const resolved = svc.resolveDevice(query);
22
+ if ("error" in resolved) return { ok: false, text: resolved.error };
23
+ const target = resolved.target;
24
+ if (!target.online) {
25
+ return {
26
+ ok: false,
27
+ text: `${target.name} appears offline — cannot teleport onto a device that isn't connected.`,
28
+ };
29
+ }
30
+ if (target.capabilities && !target.capabilities.includes("exec")) {
31
+ return {
32
+ ok: false,
33
+ text: `${target.name} does not advertise the "exec" capability, so teleport can't run commands on it.`,
34
+ };
35
+ }
36
+ await setTeleport(chatId, target.id, target.name);
37
+ return {
38
+ ok: true,
39
+ text: [
40
+ `🛰️ Teleported onto ${target.name}. Native bash/read/write/edit/glob/search now run ON that device.`,
41
+ `Working dir starts at the device default; \`cd\` in bash persists across calls.`,
42
+ `Call teleport_back to return to the daemon host.`,
43
+ ].join(" "),
44
+ };
45
+ }
46
+
47
+ async function teleportBack(chatId: number): Promise<Result> {
48
+ const prior = await clearTeleport(chatId);
49
+ return {
50
+ ok: true,
51
+ text: prior
52
+ ? `↩️ Teleported back from ${prior.deviceName}. Native tools run on the daemon host again.`
53
+ : "Not teleported — native tools already run on the daemon host.",
54
+ };
55
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * `native_write` / `native_edit` — the file-mutating half of the native
3
+ * tools, local or teleported.
4
+ *
5
+ * `edit` splices literally (never String.replace, whose `$` patterns would
6
+ * corrupt shell snippets and regexes), refuses binary files, and points at
7
+ * the closest-looking line when old_string doesn't match byte-for-byte.
8
+ */
9
+
10
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
11
+ import { dirname } from "node:path";
12
+ import { getMeshService } from "../../../mesh/index.js";
13
+ import { getTeleport } from "../../../mesh/teleport.js";
14
+ import { resolvePathParam, str } from "./params.js";
15
+ import type { Result } from "./results.js";
16
+ import type { SharedActionHandlers } from "../types.js";
17
+
18
+ export const writeHandlers: SharedActionHandlers = {
19
+ native_write: (body, chatId) => write(chatId, body.path, body.content),
20
+ native_edit: (body, chatId) =>
21
+ edit(chatId, body.path, body.old_string, body.new_string, body.replace_all),
22
+ };
23
+
24
+ async function write(
25
+ chatId: number,
26
+ path: unknown,
27
+ content: unknown,
28
+ ): Promise<Result> {
29
+ const address = str(path);
30
+ if (!address) return { ok: false, text: "A file path is required." };
31
+ const body = typeof content === "string" ? content : "";
32
+ const active = await getTeleport(chatId);
33
+ const p = resolvePathParam(address, active?.deviceName);
34
+ const shown = p === address ? p : `${address} → ${p}`;
35
+ if (active) {
36
+ return getMeshService().writeFileToDevice(active.deviceId, p, body);
37
+ }
38
+ try {
39
+ await mkdir(dirname(p), { recursive: true });
40
+ await writeFile(p, body);
41
+ } catch (err) {
42
+ return {
43
+ ok: false,
44
+ text: `Cannot write ${shown}: ${(err as Error).message}`,
45
+ };
46
+ }
47
+ return {
48
+ ok: true,
49
+ text: `Wrote ${Buffer.byteLength(body, "utf8")} bytes to ${shown} [local].`,
50
+ };
51
+ }
52
+
53
+ async function edit(
54
+ chatId: number,
55
+ path: unknown,
56
+ oldString: unknown,
57
+ newString: unknown,
58
+ replaceAll: unknown,
59
+ ): Promise<Result> {
60
+ const address = str(path);
61
+ if (!address) return { ok: false, text: "A file path is required." };
62
+ const from = typeof oldString === "string" ? oldString : "";
63
+ const to = typeof newString === "string" ? newString : "";
64
+ if (from === to)
65
+ return { ok: false, text: "old_string and new_string are identical." };
66
+ const active = await getTeleport(chatId);
67
+ const p = resolvePathParam(address, active?.deviceName);
68
+ const shown = p === address ? p : `${address} → ${p}`;
69
+ const svc = getMeshService();
70
+
71
+ let content: string;
72
+ if (active) {
73
+ const res = await svc.readFileBytes(active.deviceId, p);
74
+ if ("error" in res) return { ok: false, text: res.error };
75
+ content = res.data.toString("utf8");
76
+ } else {
77
+ try {
78
+ content = await readFile(p, "utf8");
79
+ } catch (err) {
80
+ return {
81
+ ok: false,
82
+ text: `Cannot read ${shown}: ${(err as Error).message}`,
83
+ };
84
+ }
85
+ }
86
+
87
+ // Binary guard: decoding binary as UTF-8 is lossy (invalid sequences
88
+ // become U+FFFD), so a read→replace→write round-trip would corrupt every
89
+ // non-text byte — even when old_string matches a clean region.
90
+ if (content.includes("\0")) {
91
+ return {
92
+ ok: false,
93
+ text: `${shown} looks binary — refusing a text edit that would corrupt it. Use bash for byte-level changes.`,
94
+ };
95
+ }
96
+
97
+ const count = from ? content.split(from).length - 1 : 0;
98
+ if (count === 0) {
99
+ return {
100
+ ok: false,
101
+ text: `old_string not found in ${shown}.${nearMissHint(content, from)}`,
102
+ };
103
+ }
104
+ if (count > 1 && replaceAll !== true) {
105
+ return {
106
+ ok: false,
107
+ text: `old_string appears ${count}× in ${shown}; pass replace_all or make it unique.`,
108
+ };
109
+ }
110
+ // Splice, not String.replace: replace() treats dollar-sign substitution
111
+ // patterns ($&, $', $BACKTICK, $$) in the replacement as directives, silently
112
+ // corrupting any new_string that contains them (shell snippets, regexes,
113
+ // Makefiles...). split/join and index-splice are both literal.
114
+ const updated =
115
+ replaceAll === true
116
+ ? content.split(from).join(to)
117
+ : spliceOnce(content, from, to);
118
+ const firstLine = content.slice(0, content.indexOf(from)).split("\n").length;
119
+
120
+ if (active) {
121
+ const res = await svc.writeFileToDevice(active.deviceId, p, updated);
122
+ return res.ok
123
+ ? {
124
+ ok: true,
125
+ text: `Edited ${shown} [${active.deviceName}] (${count} replacement${count === 1 ? "" : "s"}, first at line ${firstLine}).`,
126
+ }
127
+ : res;
128
+ }
129
+ try {
130
+ await writeFile(p, updated);
131
+ } catch (err) {
132
+ return {
133
+ ok: false,
134
+ text: `Cannot write ${shown}: ${(err as Error).message}`,
135
+ };
136
+ }
137
+ return {
138
+ ok: true,
139
+ text: `Edited ${shown} [local] (${count} replacement${count === 1 ? "" : "s"}, first at line ${firstLine}).`,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * When an edit's old_string doesn't match verbatim, the cause is almost
145
+ * always invisible: indentation, tabs vs spaces, or a trailing space. Point
146
+ * at the closest-looking line so the model re-reads that region instead of
147
+ * blindly retrying the same string.
148
+ */
149
+ function nearMissHint(content: string, from: string): string {
150
+ const probe =
151
+ from
152
+ .split("\n")
153
+ .map((l) => l.trim())
154
+ .find((l) => l.length >= 8) ?? from.trim();
155
+ if (!probe) return "";
156
+ const lines = content.split("\n");
157
+ const idx = lines.findIndex((l) => l.trim().includes(probe));
158
+ if (idx === -1) return "";
159
+ return (
160
+ `\nLine ${idx + 1} looks close — whitespace/indentation must match the file byte-for-byte. ` +
161
+ `Re-read that region before retrying:\n${String(idx + 1).padStart(6)}\t${lines[idx]}`
162
+ );
163
+ }
164
+
165
+ /** Replace the first occurrence of `from` with `to`, both taken literally. */
166
+ function spliceOnce(content: string, from: string, to: string): string {
167
+ const idx = content.indexOf(from);
168
+ if (idx === -1) return content;
169
+ return content.slice(0, idx) + to + content.slice(idx + from.length);
170
+ }
@@ -7,7 +7,7 @@
7
7
  * imports `src/frontend/factories.ts`.
8
8
  *
9
9
  * Chat-id shapes are the long-standing conventions from
10
- * `util/chat-id.ts`. Priorities encode the historical resolution order
10
+ * `./chat-id.ts`. Priorities encode the historical resolution order
11
11
  * (terminal, native, teams, discord, telegram) — load-bearing in two
12
12
  * places: telegram's numeric matcher is a near-catch-all so it must run
13
13
  * last, and the terminal claims the legacy chat id "1" that telegram's
@@ -25,7 +25,7 @@ import {
25
25
  isTelegramChatId,
26
26
  isTerminalChatId,
27
27
  isWhatsAppChatId,
28
- } from "../../util/chat-id.js";
28
+ } from "./chat-id.js";
29
29
 
30
30
  function registerBuiltinFrontends(): void {
31
31
  registerFrontend({
@@ -100,6 +100,16 @@ export function formatChildExit(
100
100
  return lines.length > 0 ? `${head}; stderr: ${lines.join(" | ")}` : head;
101
101
  }
102
102
 
103
+ /**
104
+ * Human-readable form of a child key for log lines. Keys join server name
105
+ * and chat id with a NUL byte (see `childKey` in index.ts) — unambiguous
106
+ * as a Map key, but it renders as `\u0000` in the JSON log.
107
+ */
108
+ function describeKey(key: string): string {
109
+ const nul = key.indexOf("\u0000");
110
+ return nul === -1 ? key : `${key.slice(0, nul)} chat=${key.slice(nul + 1)}`;
111
+ }
112
+
103
113
  /**
104
114
  * Bookkeeping for a child process that has gone away, asked or not.
105
115
  * Wired as the transport's `onclose` BEFORE `client.connect` so the SDK
@@ -125,7 +135,7 @@ function onChildClosed(key: string, transport: HubChildTransport): void {
125
135
  : "died before registration";
126
136
  logWarn(
127
137
  "gateway",
128
- `hub child ${key} ${phase} (pid ${transport.pid ?? "?"}): ${formatChildExit(record)}`,
138
+ `hub child ${describeKey(key)} ${phase} (pid ${transport.pid ?? "?"}): ${formatChildExit(record)}`,
129
139
  );
130
140
  }
131
141
 
@@ -199,7 +209,10 @@ async function spawnChild(key: string, spec: ChildSpec): Promise<ChildHandle> {
199
209
  try {
200
210
  await client.close();
201
211
  } catch (err) {
202
- logWarn("gateway", `hub child ${key} close failed: ${String(err)}`);
212
+ logWarn(
213
+ "gateway",
214
+ `hub child ${describeKey(key)} close failed: ${String(err)}`,
215
+ );
203
216
  }
204
217
  })());
205
218
  })(),
@@ -226,7 +239,10 @@ async function spawnChild(key: string, spec: ChildSpec): Promise<ChildHandle> {
226
239
  };
227
240
 
228
241
  children.set(key, entry);
229
- log("gateway", `hub child started: ${key} (pid ${transport.pid ?? "?"})`);
242
+ log(
243
+ "gateway",
244
+ `hub child started: ${describeKey(key)} (pid ${transport.pid ?? "?"})`,
245
+ );
230
246
  return entry.handle;
231
247
  }
232
248
 
@@ -327,9 +343,9 @@ function reapIdle(): void {
327
343
  if (entry.lastActivity >= cutoff) continue;
328
344
  children.delete(key);
329
345
  entry.close().catch((err) => {
330
- logError("gateway", `hub reap of ${key} failed`, err);
346
+ logError("gateway", `hub reap of ${describeKey(key)} failed`, err);
331
347
  });
332
- log("gateway", `hub child reaped (idle): ${key}`);
348
+ log("gateway", `hub child reaped (idle): ${describeKey(key)}`);
333
349
  }
334
350
  }
335
351
 
@@ -38,7 +38,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
38
38
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
39
39
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
40
40
  import { getPluginMcpServers } from "../plugin/index.js";
41
- import { wrapMcpServer } from "../../util/mcp-launcher.js";
41
+ import { wrapMcpServer } from "./launcher.js";
42
42
  import { log, logError } from "../../util/log.js";
43
43
  import { buildTalonToolServer, VALID_TOOL_FRONTENDS } from "./talon-server.js";
44
44
  import { buildProxyServer } from "./proxy-server.js";
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
18
  import { composeTools } from "../tools/index.js";
19
- import { createBridge, textResult } from "../tools/bridge.js";
19
+ import { createBridge, textResult } from "../tools/ops/bridge.js";
20
20
  import type { ToolFrontend, ToolTag } from "../tools/types.js";
21
21
 
22
22
  export const VALID_TOOL_FRONTENDS: ReadonlySet<string> = new Set([
@@ -37,7 +37,7 @@ import {
37
37
  type MemorySource,
38
38
  type MemoryTrust,
39
39
  } from "../../storage/memory.js";
40
- import { chatScope } from "../../util/chat-id.js";
40
+ import { chatScope } from "../frontend-runtime/chat-id.js";
41
41
  import { log, logWarn } from "../../util/log.js";
42
42
 
43
43
  /**
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { resolve } from "node:path";
8
8
  import { logWarn } from "../../util/log.js";
9
- import { wrapMcpServer } from "../../util/mcp-launcher.js";
9
+ import { wrapMcpServer } from "../mcp-hub/launcher.js";
10
10
  import { isBunRuntime } from "../../util/runtime.js";
11
11
  import { registry, reloadState } from "./registry.js";
12
12
  import type { McpServerConfig } from "./types.js";
@@ -7,7 +7,7 @@
7
7
  * (kill/respawn, SIGTERM grace, log streaming, TALON_FIRE line scanning)
8
8
  * is built around child processes. Lua therefore runs as `talon _lua-run
9
9
  * <script>` — the same self-reinvocation shape MCP supervision uses
10
- * (util/mcp-launcher.ts) — so it works identically for tsx source runs,
10
+ * (core/mcp-hub/launcher.ts) — so it works identically for tsx source runs,
11
11
  * npm installs, and bun-compiled binaries with no source tree on disk.
12
12
  *
13
13
  * Sandbox (verified empirically, not aspirational):
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { z } from "zod";
6
- import type { ToolDefinition } from "./types.js";
6
+ import type { ToolDefinition } from "../types.js";
7
7
 
8
8
  export const chatTools: ToolDefinition[] = [
9
9
  {
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import type { ToolDefinition } from "./types.js";
2
+ import type { ToolDefinition } from "../types.js";
3
3
 
4
4
  /**
5
5
  * Cross-frontend send. Deliberately NOT frontend-restricted (the meshTools
@@ -3,8 +3,8 @@
3
3
  */
4
4
 
5
5
  import { z } from "zod";
6
- import type { ToolDefinition } from "./types.js";
7
- import { snowflakeOrIdSchema } from "./schemas.js";
6
+ import type { ToolDefinition } from "../types.js";
7
+ import { snowflakeOrIdSchema } from "../schemas.js";
8
8
 
9
9
  export const historyTools: ToolDefinition[] = [
10
10
  {
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { z } from "zod";
6
- import type { ToolDefinition } from "./types.js";
6
+ import type { ToolDefinition } from "../types.js";
7
7
 
8
8
  export const mediaTools: ToolDefinition[] = [
9
9
  {
@@ -3,8 +3,8 @@
3
3
  */
4
4
 
5
5
  import { z } from "zod";
6
- import type { ToolDefinition } from "./types.js";
7
- import { snowflakeOrIdSchema } from "./schemas.js";
6
+ import type { ToolDefinition } from "../types.js";
7
+ import { snowflakeOrIdSchema } from "../schemas.js";
8
8
 
9
9
  export const memberTools: ToolDefinition[] = [
10
10
  {
@@ -4,8 +4,8 @@
4
4
  */
5
5
 
6
6
  import { z } from "zod";
7
- import type { ToolDefinition } from "./types.js";
8
- import { chatIdSchema, idSchema, snowflakeOrIdSchema } from "./schemas.js";
7
+ import type { ToolDefinition } from "../types.js";
8
+ import { chatIdSchema, idSchema, snowflakeOrIdSchema } from "../schemas.js";
9
9
 
10
10
  /**
11
11
  * Throw a typed Error when a bridge response reports failure