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.
- package/package.json +2 -2
- package/src/app.ts +3 -3
- package/src/backend/claude-sdk/factory.ts +21 -41
- package/src/backend/claude-sdk/handler.ts +191 -69
- package/src/backend/claude-sdk/host/in-process.ts +147 -0
- package/src/backend/claude-sdk/one-shot.ts +17 -2
- package/src/backend/claude-sdk/stream.ts +9 -0
- package/src/backend/codex/mcp-config.ts +1 -1
- package/src/backend/codex/one-shot.ts +18 -4
- package/src/backend/openai-agents/mcp-pool.ts +1 -1
- package/src/backend/remote-server/one-shot.ts +16 -3
- package/src/backend/runtime/index.ts +2 -2
- package/src/backend/runtime/one-shot-hooks.ts +45 -0
- package/src/backend/runtime/turn/delivery.ts +1 -1
- package/src/backend/runtime/turn/turn-phases.ts +1 -1
- package/src/bootstrap.ts +2 -2
- package/src/cli.ts +5 -2
- package/src/core/agent-runtime/README.md +15 -0
- package/src/core/agent-runtime/agent-host.ts +388 -0
- package/src/core/agent-runtime/capabilities.ts +3 -0
- package/src/core/background/cron/scheduler.ts +1 -1
- package/src/core/background/triggers/command.ts +1 -1
- package/src/{util → core/config}/harden.ts +1 -1
- package/src/core/config/index.ts +1 -1
- package/src/core/daemon/pidfile.ts +1 -1
- package/src/core/daemon/resource-sampler.ts +2 -2
- package/src/{util → core/daemon}/respawn.ts +1 -1
- package/src/core/doctor/index.ts +1 -1
- package/src/core/engine/gateway-actions/fetch-url.ts +1 -1
- package/src/core/engine/gateway-actions/index.ts +1 -1
- package/src/core/engine/gateway-actions/memory.ts +2 -2
- package/src/core/engine/gateway-actions/native/exec-background.ts +129 -0
- package/src/core/engine/gateway-actions/native/exec-remote.ts +85 -0
- package/src/core/engine/gateway-actions/native/exec.ts +182 -0
- package/src/core/engine/gateway-actions/native/index.ts +46 -0
- package/src/core/engine/gateway-actions/native/params.ts +46 -0
- package/src/core/engine/gateway-actions/native/read.ts +182 -0
- package/src/core/engine/gateway-actions/native/results.ts +34 -0
- package/src/core/engine/gateway-actions/native/search.ts +212 -0
- package/src/core/engine/gateway-actions/native/shell.ts +39 -0
- package/src/core/engine/gateway-actions/native/teleport.ts +55 -0
- package/src/core/engine/gateway-actions/native/write.ts +170 -0
- package/src/core/frontend-runtime/builtins.ts +2 -2
- package/src/core/mcp-hub/children.ts +21 -5
- package/src/core/mcp-hub/index.ts +1 -1
- package/src/core/mcp-hub/talon-server.ts +1 -1
- package/src/core/memory/taps.ts +1 -1
- package/src/core/plugin/mcp.ts +1 -1
- package/src/core/scripts/lua.ts +1 -1
- package/src/core/tools/{chat.ts → chat/chat.ts} +1 -1
- package/src/core/tools/{cross-send.ts → chat/cross-send.ts} +1 -1
- package/src/core/tools/{history.ts → chat/history.ts} +2 -2
- package/src/core/tools/{media.ts → chat/media.ts} +1 -1
- package/src/core/tools/{members.ts → chat/members.ts} +2 -2
- package/src/core/tools/{messaging.ts → chat/messaging.ts} +2 -2
- package/src/core/tools/{moderation.ts → chat/moderation.ts} +2 -2
- package/src/core/tools/{stickers.ts → chat/stickers.ts} +2 -2
- package/src/core/tools/{whatsapp.ts → chat/whatsapp.ts} +1 -1
- package/src/core/tools/{memory.ts → content/memory.ts} +2 -2
- package/src/core/tools/{web.ts → content/web.ts} +1 -1
- package/src/core/tools/index.ts +20 -20
- package/src/core/tools/{admin.ts → ops/admin.ts} +1 -1
- package/src/core/tools/{bridge.ts → ops/bridge.ts} +2 -2
- package/src/core/tools/{goals.ts → ops/goals.ts} +2 -2
- package/src/core/tools/{mesh.ts → ops/mesh.ts} +1 -1
- package/src/core/tools/{models.ts → ops/models.ts} +1 -1
- package/src/core/tools/{native.ts → ops/native.ts} +1 -1
- package/src/core/tools/{scheduling.ts → ops/scheduling.ts} +1 -1
- package/src/core/tools/{scripts.ts → ops/scripts.ts} +1 -1
- package/src/core/tools/{skills.ts → ops/skills.ts} +1 -1
- package/src/core/tools/{triggers.ts → ops/triggers.ts} +1 -1
- package/src/core/types.ts +11 -0
- package/src/{util → core/vfs}/workspace.ts +2 -2
- package/src/frontend/discord/callbacks/components/effort.ts +1 -1
- package/src/frontend/discord/callbacks/components/index.ts +1 -1
- package/src/frontend/discord/callbacks/components/settings.ts +1 -1
- package/src/frontend/discord/commands/admin.ts +1 -1
- package/src/frontend/discord/commands/info.ts +1 -1
- package/src/frontend/discord/commands/interaction.ts +1 -1
- package/src/frontend/discord/commands/session.ts +3 -3
- package/src/frontend/discord/commands/settings.ts +1 -1
- package/src/frontend/discord/handlers/messages.ts +1 -1
- package/src/frontend/discord/handlers/state.ts +1 -1
- package/src/frontend/discord/middleware.ts +1 -1
- package/src/frontend/discord/ready.ts +1 -1
- package/src/frontend/discord/render.ts +41 -259
- package/src/frontend/native/chats/chats.ts +1 -1
- package/src/frontend/native/surface/models.ts +1 -1
- package/src/frontend/native/turn/context.ts +1 -1
- package/src/frontend/native/turn/emit.ts +1 -1
- package/src/frontend/{shared → presentation}/format.ts +4 -3
- package/src/frontend/{shared → presentation}/reasoning-levels.ts +12 -0
- package/src/frontend/presentation/reports.ts +493 -0
- package/src/frontend/{shared → presentation}/session-status.ts +1 -1
- package/src/frontend/teams/commands.ts +2 -2
- package/src/frontend/teams/turn.ts +1 -1
- package/src/frontend/telegram/admin/health.ts +1 -1
- package/src/frontend/telegram/admin/sessions.ts +1 -1
- package/src/frontend/telegram/callbacks/effort.ts +2 -2
- package/src/frontend/telegram/callbacks/metrics.ts +1 -1
- package/src/frontend/telegram/callbacks/model/views.ts +1 -1
- package/src/frontend/telegram/callbacks/query.ts +1 -1
- package/src/frontend/telegram/callbacks/settings.ts +2 -2
- package/src/frontend/telegram/commands/admin.ts +4 -4
- package/src/frontend/telegram/commands/info.ts +2 -2
- package/src/frontend/telegram/commands/session.ts +4 -4
- package/src/frontend/telegram/commands/settings.ts +4 -2
- package/src/frontend/telegram/handlers/state.ts +1 -1
- package/src/frontend/telegram/model-menu.ts +1 -1
- package/src/frontend/telegram/render/html.ts +30 -0
- package/src/frontend/telegram/{helpers → render}/menu.ts +46 -19
- package/src/frontend/telegram/render/reports.ts +182 -0
- package/src/frontend/terminal/builtins/context.ts +2 -2
- package/src/frontend/terminal/builtins/session.ts +1 -1
- package/src/frontend/terminal/builtins/status.ts +2 -2
- package/src/frontend/terminal/index.ts +2 -2
- package/src/frontend/terminal/renderer.ts +1 -1
- package/src/frontend/whatsapp/commands.ts +5 -5
- package/src/frontend/whatsapp/registry.ts +1 -1
- package/src/index.ts +5 -2
- package/src/util/runtime.ts +1 -1
- package/src/core/engine/gateway-actions/native.ts +0 -1035
- package/src/frontend/telegram/helpers/diagnostics.ts +0 -428
- package/src/frontend/telegram/helpers/format.ts +0 -42
- package/src/frontend/telegram/helpers/index.ts +0 -13
- package/src/util/cleanup-registry.ts +0 -36
- /package/src/{util → core/daemon}/boot-timer.ts +0 -0
- /package/src/{util → core/frontend-runtime}/chat-id.ts +0 -0
- /package/src/{util/mcp-launcher.ts → core/mcp-hub/launcher.ts} +0 -0
- /package/src/{util → core/tools/content}/web-content.ts +0 -0
- /package/src/core/tools/{mcp-env.ts → ops/mcp-env.ts} +0 -0
- /package/src/{util → core/weaver}/session-name.ts +0 -0
- /package/src/frontend/{shared → presentation}/access.ts +0 -0
- /package/src/frontend/{shared → presentation}/model-commands.ts +0 -0
- /package/src/frontend/{shared → presentation}/plan-usage-report.ts +0 -0
- /package/src/frontend/{shared → presentation}/status-context.ts +0 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `native_bash` on a teleported device — dispatched over the mesh exec
|
|
3
|
+
* channel.
|
|
4
|
+
*
|
|
5
|
+
* The command is wrapped so the shell's resulting working directory comes
|
|
6
|
+
* back in markers and persists across calls (a `cd` carries forward) without
|
|
7
|
+
* disturbing the real exit code. glob/search reuse this path for their
|
|
8
|
+
* on-device rg/find/grep fallbacks.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getMeshService } from "../../../mesh/index.js";
|
|
12
|
+
import { getTeleport, setTeleportCwd } from "../../../mesh/teleport.js";
|
|
13
|
+
import { renderExec, type Result } from "./results.js";
|
|
14
|
+
import { shellQuote } from "./shell.js";
|
|
15
|
+
|
|
16
|
+
/** Same self-correction hint, phrased for a teleported (on-device) run. */
|
|
17
|
+
const TELEPORT_TIMEOUT_HINT =
|
|
18
|
+
"(Streaming/never-ending commands can't ride a teleported foreground call — " +
|
|
19
|
+
"background it in-shell instead: `cmd > /tmp/out.log 2>&1 &`, then poll the log " +
|
|
20
|
+
"with read, or bound the command itself: `logcat -d`, `timeout 30 …`, `head -n 200`.)";
|
|
21
|
+
/** Markers used to recover the post-command working dir from a teleport shell. */
|
|
22
|
+
const CWD_OPEN = "__TALON_CWD__";
|
|
23
|
+
const CWD_CLOSE = "__TALON_CWD_END__";
|
|
24
|
+
|
|
25
|
+
export async function bashTeleported(
|
|
26
|
+
chatId: number,
|
|
27
|
+
deviceId: string,
|
|
28
|
+
cmd: string,
|
|
29
|
+
timeoutMs: number,
|
|
30
|
+
): Promise<Result> {
|
|
31
|
+
const active = await getTeleport(chatId);
|
|
32
|
+
const cwd = active?.cwd;
|
|
33
|
+
// Wrap so the resulting working dir is reported back and persists across
|
|
34
|
+
// calls (a `cd` in `cmd` carries forward), while the real exit code is
|
|
35
|
+
// preserved. printf can't fail in a way that masks the command's status.
|
|
36
|
+
const wrapped =
|
|
37
|
+
`${cwd ? `cd ${shellQuote(cwd)} 2>/dev/null; ` : ""}` +
|
|
38
|
+
`{ ${cmd}\n}; __talon_rc=$?; ` +
|
|
39
|
+
`printf '${CWD_OPEN}%s${CWD_CLOSE}' "$(pwd 2>/dev/null)"; exit $__talon_rc`;
|
|
40
|
+
const dispatched = await getMeshService().dispatchCommand(
|
|
41
|
+
deviceId,
|
|
42
|
+
"exec",
|
|
43
|
+
{ cmd: wrapped, timeoutMs },
|
|
44
|
+
timeoutMs + 5_000,
|
|
45
|
+
);
|
|
46
|
+
if ("error" in dispatched) return { ok: false, text: dispatched.error };
|
|
47
|
+
const { target, result } = dispatched;
|
|
48
|
+
const data = result.data ?? {};
|
|
49
|
+
let stdout = typeof data.stdout === "string" ? data.stdout : "";
|
|
50
|
+
const stderr = typeof data.stderr === "string" ? data.stderr : "";
|
|
51
|
+
const via =
|
|
52
|
+
typeof data.via === "string" && data.via ? ` via ${data.via}` : "";
|
|
53
|
+
const exitCode =
|
|
54
|
+
typeof data.exitCode === "number" ? data.exitCode : undefined;
|
|
55
|
+
// Recover + strip the trailing cwd marker.
|
|
56
|
+
const open = stdout.lastIndexOf(CWD_OPEN);
|
|
57
|
+
if (open !== -1) {
|
|
58
|
+
const close = stdout.indexOf(CWD_CLOSE, open);
|
|
59
|
+
if (close !== -1) {
|
|
60
|
+
const newCwd = stdout.slice(open + CWD_OPEN.length, close).trim();
|
|
61
|
+
stdout = stdout.slice(0, open);
|
|
62
|
+
if (newCwd) await setTeleportCwd(chatId, newCwd);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!result.ok && exitCode === undefined) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
text: result.message ?? `${target.name} could not run the command.`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// The device marks a command it had to kill at its own exec budget with
|
|
72
|
+
// this stderr marker (see the companion's device_exec) — surface the same
|
|
73
|
+
// self-correction hint the local timeout path gets.
|
|
74
|
+
const timedOutOnDevice = stderr.includes("[killed: timeout]");
|
|
75
|
+
const body = renderExec(
|
|
76
|
+
`${target.name}${via}`,
|
|
77
|
+
`exit ${exitCode ?? "?"}`,
|
|
78
|
+
stdout,
|
|
79
|
+
stderr,
|
|
80
|
+
);
|
|
81
|
+
return {
|
|
82
|
+
ok: exitCode === 0,
|
|
83
|
+
text: timedOutOnDevice ? `${body}\n${TELEPORT_TIMEOUT_HINT}` : body,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `native_bash` — the entry point and the local foreground run.
|
|
3
|
+
*
|
|
4
|
+
* The handler picks one of three paths: a detached background launch
|
|
5
|
+
* (exec-background.ts), a teleported on-device run (exec-remote.ts), or the
|
|
6
|
+
* local spawn below, which owns the timeout escalation (SIGTERM the process
|
|
7
|
+
* group, SIGKILL the survivors) so a runaway pipeline can still flush what
|
|
8
|
+
* it produced.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { stat as fsStat } from "node:fs/promises";
|
|
13
|
+
import { getTeleport } from "../../../mesh/teleport.js";
|
|
14
|
+
import { createOutputCapture } from "../../../../util/exec-output.js";
|
|
15
|
+
import { bashBackground } from "./exec-background.js";
|
|
16
|
+
import { bashTeleported } from "./exec-remote.js";
|
|
17
|
+
import { resolvePathParam } from "./params.js";
|
|
18
|
+
import { renderExec, type Result } from "./results.js";
|
|
19
|
+
import type { SharedActionHandlers } from "../types.js";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_EXEC_TIMEOUT_MS = 60_000;
|
|
22
|
+
const MAX_EXEC_TIMEOUT_MS = 300_000;
|
|
23
|
+
/** Grace between SIGTERM and SIGKILL on timeout, so pipelines can flush. */
|
|
24
|
+
const KILL_GRACE_MS = 2_000;
|
|
25
|
+
/** Appended to a timed-out bash result so the model self-corrects. */
|
|
26
|
+
const TIMEOUT_HINT =
|
|
27
|
+
"(Streaming/never-ending commands — adb logcat, tail -f, dev servers, watchers — " +
|
|
28
|
+
"will always hit this wall. Re-run with background:true to launch it detached with " +
|
|
29
|
+
"output captured to a log file, or bound the command itself: `adb logcat -d`, " +
|
|
30
|
+
"`timeout 30 …`, `head -n 200`.)";
|
|
31
|
+
|
|
32
|
+
export const execHandlers: SharedActionHandlers = {
|
|
33
|
+
native_bash: (body, chatId) =>
|
|
34
|
+
bash(chatId, body.command, body.cwd, body.timeout_sec, body.background),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
async function bash(
|
|
38
|
+
chatId: number,
|
|
39
|
+
command: unknown,
|
|
40
|
+
cwd: unknown,
|
|
41
|
+
timeoutSec: unknown,
|
|
42
|
+
background?: unknown,
|
|
43
|
+
): Promise<Result> {
|
|
44
|
+
const cmd = typeof command === "string" ? command : "";
|
|
45
|
+
if (!cmd.trim()) return { ok: false, text: "No command given." };
|
|
46
|
+
const timeoutMs = clampTimeout(timeoutSec);
|
|
47
|
+
const active = await getTeleport(chatId);
|
|
48
|
+
|
|
49
|
+
let dir = typeof cwd === "string" && cwd.trim() ? cwd.trim() : undefined;
|
|
50
|
+
if (dir !== undefined && !active) {
|
|
51
|
+
dir = resolvePathParam(dir, undefined);
|
|
52
|
+
// Validate here: a bad cwd makes spawn fail with "spawn bash ENOENT",
|
|
53
|
+
// which reads as "bash is missing" and sends the model down the wrong
|
|
54
|
+
// path. Name the actual problem instead.
|
|
55
|
+
try {
|
|
56
|
+
const st = await fsStat(dir);
|
|
57
|
+
if (!st.isDirectory())
|
|
58
|
+
return { ok: false, text: `cwd is not a directory: ${dir}` };
|
|
59
|
+
} catch {
|
|
60
|
+
return { ok: false, text: `Working directory does not exist: ${dir}` };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const result = await (async () => {
|
|
65
|
+
if (background === true) {
|
|
66
|
+
if (active) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
text:
|
|
70
|
+
"background:true runs on the daemon host only. On a teleported device, " +
|
|
71
|
+
"background it in-shell instead: `cmd > /tmp/out.log 2>&1 &`, then poll " +
|
|
72
|
+
"the log with read.",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return bashBackground(cmd, dir);
|
|
76
|
+
}
|
|
77
|
+
if (active) return bashTeleported(chatId, active.deviceId, cmd, timeoutMs);
|
|
78
|
+
return bashLocal(cmd, dir, timeoutMs);
|
|
79
|
+
})();
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function bashLocal(
|
|
84
|
+
cmd: string,
|
|
85
|
+
cwd: string | undefined,
|
|
86
|
+
timeoutMs: number,
|
|
87
|
+
): Promise<Result> {
|
|
88
|
+
return new Promise((resolvePromise) => {
|
|
89
|
+
// detached → own process group on POSIX, so a timeout can kill the whole
|
|
90
|
+
// tree (bash's children included), not just the shell itself.
|
|
91
|
+
const detached = process.platform !== "win32";
|
|
92
|
+
const child = spawn("bash", ["-c", cmd], {
|
|
93
|
+
...(cwd ? { cwd } : {}),
|
|
94
|
+
env: process.env,
|
|
95
|
+
detached,
|
|
96
|
+
});
|
|
97
|
+
const stdout = createOutputCapture();
|
|
98
|
+
const stderr = createOutputCapture();
|
|
99
|
+
let killed = false;
|
|
100
|
+
// Timeout escalation: SIGTERM the whole process group first (lets
|
|
101
|
+
// pipelines flush + children clean up), SIGKILL any survivors after a
|
|
102
|
+
// short grace. Straight-to-SIGKILL used to eat buffered output.
|
|
103
|
+
const killTree = (signal: NodeJS.Signals) => {
|
|
104
|
+
if (detached && child.pid) {
|
|
105
|
+
try {
|
|
106
|
+
process.kill(-child.pid, signal);
|
|
107
|
+
return;
|
|
108
|
+
} catch {
|
|
109
|
+
// group already gone — fall through to the direct kill
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
child.kill(signal);
|
|
114
|
+
} catch {
|
|
115
|
+
// already dead
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
let settled = false;
|
|
119
|
+
let killTimer: NodeJS.Timeout | undefined;
|
|
120
|
+
let forceTimer: NodeJS.Timeout | undefined;
|
|
121
|
+
const finish = (r: Result) => {
|
|
122
|
+
if (settled) return;
|
|
123
|
+
settled = true;
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
if (killTimer) clearTimeout(killTimer);
|
|
126
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
127
|
+
resolvePromise(r);
|
|
128
|
+
};
|
|
129
|
+
const timedOutResult = () => {
|
|
130
|
+
const body = renderExec(
|
|
131
|
+
"local",
|
|
132
|
+
`⏱️ timed out after ${timeoutMs / 1000}s — killed; partial output kept below`,
|
|
133
|
+
stdout.value(),
|
|
134
|
+
stderr.value(),
|
|
135
|
+
);
|
|
136
|
+
return { ok: false, text: `${body}\n${TIMEOUT_HINT}` };
|
|
137
|
+
};
|
|
138
|
+
const timer = setTimeout(() => {
|
|
139
|
+
killed = true;
|
|
140
|
+
killTree("SIGTERM");
|
|
141
|
+
killTimer = setTimeout(() => killTree("SIGKILL"), KILL_GRACE_MS);
|
|
142
|
+
// `close` waits for the stdio pipes to drain — a surviving grandchild
|
|
143
|
+
// that inherited stdout (Windows has no process groups; a detached
|
|
144
|
+
// POSIX grandchild can escape the group kill) would otherwise hold
|
|
145
|
+
// this promise open long past the timeout. Force-resolve with the
|
|
146
|
+
// partial output once the escalation window has passed.
|
|
147
|
+
forceTimer = setTimeout(
|
|
148
|
+
() => finish(timedOutResult()),
|
|
149
|
+
KILL_GRACE_MS + 1_000,
|
|
150
|
+
);
|
|
151
|
+
}, timeoutMs);
|
|
152
|
+
child.stdout.on("data", stdout.push);
|
|
153
|
+
child.stderr.on("data", stderr.push);
|
|
154
|
+
child.on("error", (err) => {
|
|
155
|
+
finish({ ok: false, text: `Failed to run: ${err.message}` });
|
|
156
|
+
});
|
|
157
|
+
child.on("close", (code) => {
|
|
158
|
+
if (killed) {
|
|
159
|
+
finish(timedOutResult());
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
finish({
|
|
163
|
+
ok: (code ?? 0) === 0,
|
|
164
|
+
text: renderExec(
|
|
165
|
+
"local",
|
|
166
|
+
`exit ${code ?? 0}`,
|
|
167
|
+
stdout.value(),
|
|
168
|
+
stderr.value(),
|
|
169
|
+
),
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function clampTimeout(value: unknown): number {
|
|
176
|
+
const sec = typeof value === "number" ? value : Number(value);
|
|
177
|
+
if (!Number.isFinite(sec) || sec <= 0) return DEFAULT_EXEC_TIMEOUT_MS;
|
|
178
|
+
return Math.min(
|
|
179
|
+
MAX_EXEC_TIMEOUT_MS,
|
|
180
|
+
Math.max(1_000, Math.round(sec * 1_000)),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
* One module per concern; this file is only the registry:
|
|
18
|
+
*
|
|
19
|
+
* - `teleport` — teleport / teleport_back
|
|
20
|
+
* - `exec` — native_bash (dispatch + local foreground run)
|
|
21
|
+
* - `exec-background` — the detached background launch
|
|
22
|
+
* - `exec-remote` — the on-device run over the mesh exec channel
|
|
23
|
+
* - `read` — native_read
|
|
24
|
+
* - `write` — native_write / native_edit
|
|
25
|
+
* - `search` — native_glob / native_search
|
|
26
|
+
*
|
|
27
|
+
* The spread order below reproduces the key order of the single object
|
|
28
|
+
* literal these came from. Action names are the model's tool vocabulary and
|
|
29
|
+
* sit in the prompt-cache prefix via the tool descriptions, so neither the
|
|
30
|
+
* names nor their order may change (pinned by native-tools.test.ts).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { teleportHandlers } from "./teleport.js";
|
|
34
|
+
import { execHandlers } from "./exec.js";
|
|
35
|
+
import { readHandlers } from "./read.js";
|
|
36
|
+
import { writeHandlers } from "./write.js";
|
|
37
|
+
import { searchHandlers } from "./search.js";
|
|
38
|
+
import type { SharedActionHandlers } from "../types.js";
|
|
39
|
+
|
|
40
|
+
export const nativeHandlers: SharedActionHandlers = {
|
|
41
|
+
...teleportHandlers,
|
|
42
|
+
...execHandlers,
|
|
43
|
+
...readHandlers,
|
|
44
|
+
...writeHandlers,
|
|
45
|
+
...searchHandlers,
|
|
46
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-argument resolution: the one rule for every path parameter, plus the
|
|
3
|
+
* string/number coercions that turn an untyped request body field into
|
|
4
|
+
* something the handlers can use.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
// — path parameter resolution ----------------------------------------------
|
|
11
|
+
|
|
12
|
+
/** Expand a leading `~` — local runs only; a device's home is not ours. */
|
|
13
|
+
function expandHome(path: string): string {
|
|
14
|
+
if (path === "~") return homedir();
|
|
15
|
+
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
|
16
|
+
return path;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One rule for every path parameter: `~` expands (local runs only),
|
|
21
|
+
* everything else passes through untouched.
|
|
22
|
+
*
|
|
23
|
+
* The namespace has no tool-facing address scheme — its nodes are reached
|
|
24
|
+
* by their real paths (`~/.talon/ns/…`, kept real by the symlink farm in
|
|
25
|
+
* nsdir.ts and the FUSE layer in fusefs.ts). Real paths need no
|
|
26
|
+
* translation and behave identically here, in a bare shell, in another
|
|
27
|
+
* backend's built-in shell (Codex), and in any spawned child process.
|
|
28
|
+
* Teleported, paths belong to the device and pass through verbatim.
|
|
29
|
+
*/
|
|
30
|
+
export function resolvePathParam(
|
|
31
|
+
path: string,
|
|
32
|
+
teleportedTo: string | undefined,
|
|
33
|
+
): string {
|
|
34
|
+
return teleportedTo !== undefined ? path : expandHome(path);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// — scalar coercion --------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
export function str(value: unknown): string | undefined {
|
|
40
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function num(value: unknown): number | undefined {
|
|
44
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
45
|
+
return Number.isFinite(n) ? n : undefined;
|
|
46
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `native_read` — read a file from the daemon host or, when teleported, from
|
|
3
|
+
* the device.
|
|
4
|
+
*
|
|
5
|
+
* Two things it refuses rather than fakes: a file too big to slurp into
|
|
6
|
+
* memory (bash can slice it) and binary content decoded as UTF-8. Images are
|
|
7
|
+
* the exception — they come back as a viewable image block instead of bytes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFile, stat as fsStat } from "node:fs/promises";
|
|
11
|
+
import { extname } from "node:path";
|
|
12
|
+
import { getMeshService } from "../../../mesh/index.js";
|
|
13
|
+
import { getTeleport } from "../../../mesh/teleport.js";
|
|
14
|
+
import { num, resolvePathParam, str } from "./params.js";
|
|
15
|
+
import type { Result } from "./results.js";
|
|
16
|
+
import type { SharedActionHandlers } from "../types.js";
|
|
17
|
+
|
|
18
|
+
const MAX_READ_LINES = 2_000;
|
|
19
|
+
/**
|
|
20
|
+
* Refuse to slurp huge files into memory: `read` loads the whole file to
|
|
21
|
+
* slice lines, so an unbounded readFile of a multi-GB log balloons RSS.
|
|
22
|
+
* Past this, bash is the right tool (`sed -n`, `tail`, `head`).
|
|
23
|
+
*/
|
|
24
|
+
const MAX_READ_FILE_BYTES = 32 * 1024 * 1024;
|
|
25
|
+
/**
|
|
26
|
+
* Image extensions the model can actually view, mapped to their MIME type.
|
|
27
|
+
* Reading one returns an image content block instead of the file's raw bytes
|
|
28
|
+
* decoded as (garbage) UTF-8 — so `read`ing a photo/screenshot/design shows
|
|
29
|
+
* the picture, not mojibake.
|
|
30
|
+
*/
|
|
31
|
+
const IMAGE_MIME: Record<string, string> = {
|
|
32
|
+
".png": "image/png",
|
|
33
|
+
".jpg": "image/jpeg",
|
|
34
|
+
".jpeg": "image/jpeg",
|
|
35
|
+
".gif": "image/gif",
|
|
36
|
+
".webp": "image/webp",
|
|
37
|
+
};
|
|
38
|
+
/** Anthropic caps a single image around 5MB; refuse larger with a downscale hint. */
|
|
39
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
40
|
+
|
|
41
|
+
export const readHandlers: SharedActionHandlers = {
|
|
42
|
+
native_read: (body, chatId) =>
|
|
43
|
+
read(chatId, body.path, body.offset, body.limit),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
async function read(
|
|
47
|
+
chatId: number,
|
|
48
|
+
path: unknown,
|
|
49
|
+
offset: unknown,
|
|
50
|
+
limit: unknown,
|
|
51
|
+
): Promise<Result> {
|
|
52
|
+
const address = str(path);
|
|
53
|
+
if (!address) return { ok: false, text: "A file path is required." };
|
|
54
|
+
const active = await getTeleport(chatId);
|
|
55
|
+
const where = active ? active.deviceName : "local";
|
|
56
|
+
|
|
57
|
+
const p = resolvePathParam(address, active?.deviceName);
|
|
58
|
+
const shown = p === address ? p : `${address} → ${p}`;
|
|
59
|
+
|
|
60
|
+
// Image files: return a viewable image block, not the raw bytes decoded as
|
|
61
|
+
// UTF-8. Without this, `read`ing a photo/screenshot/design hands the model
|
|
62
|
+
// mojibake and it can't see the picture at all.
|
|
63
|
+
const mime = IMAGE_MIME[extname(p).toLowerCase()];
|
|
64
|
+
if (mime) {
|
|
65
|
+
let bytes: Buffer;
|
|
66
|
+
if (active) {
|
|
67
|
+
const res = await getMeshService().readFileBytes(active.deviceId, p);
|
|
68
|
+
if ("error" in res) return { ok: false, text: res.error };
|
|
69
|
+
bytes = res.data;
|
|
70
|
+
} else {
|
|
71
|
+
try {
|
|
72
|
+
// stat first — no point slurping a 40MB photo just to refuse it.
|
|
73
|
+
const st = await fsStat(p);
|
|
74
|
+
if (st.size > MAX_IMAGE_BYTES)
|
|
75
|
+
return oversizeImageResult(shown, where, st.size, p);
|
|
76
|
+
bytes = await readFile(p);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
text: `Cannot read ${shown}: ${(err as Error).message}`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (bytes.length > MAX_IMAGE_BYTES) {
|
|
85
|
+
return oversizeImageResult(shown, where, bytes.length, p);
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
ok: true,
|
|
89
|
+
text: `${shown} [${where}] — image (${mime}, ${bytes.length} bytes)`,
|
|
90
|
+
image: { data: bytes.toString("base64"), mimeType: mime },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Clamp: a negative offset would silently flip slice() into
|
|
95
|
+
// count-from-the-end (with line numbers that lie), and a zero/negative
|
|
96
|
+
// limit would return an empty read that looks like an empty file.
|
|
97
|
+
const start = Math.max(0, Math.trunc(num(offset) ?? 0));
|
|
98
|
+
const max = Math.min(
|
|
99
|
+
Math.max(1, Math.trunc(num(limit) ?? MAX_READ_LINES)),
|
|
100
|
+
MAX_READ_LINES,
|
|
101
|
+
);
|
|
102
|
+
let content: string;
|
|
103
|
+
if (active) {
|
|
104
|
+
const res = await getMeshService().readFileBytes(active.deviceId, p);
|
|
105
|
+
if ("error" in res) return { ok: false, text: res.error };
|
|
106
|
+
if (res.data.length > MAX_READ_FILE_BYTES)
|
|
107
|
+
return oversizeReadResult(shown, where, res.data.length, p);
|
|
108
|
+
content = res.data.toString("utf8");
|
|
109
|
+
} else {
|
|
110
|
+
try {
|
|
111
|
+
const st = await fsStat(p);
|
|
112
|
+
if (st.isFile() && st.size > MAX_READ_FILE_BYTES)
|
|
113
|
+
return oversizeReadResult(shown, where, st.size, p);
|
|
114
|
+
content = await readFile(p, "utf8");
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
text: `Cannot read ${shown}: ${(err as Error).message}`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Binary files decoded as UTF-8 are mojibake the model can't use — same
|
|
123
|
+
// rationale as the image branch, but with no viewable representation to
|
|
124
|
+
// return. Point at the shell tools that can actually inspect the bytes.
|
|
125
|
+
if (content.includes("\0")) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
text:
|
|
129
|
+
`${shown} [${where}] looks binary — refusing to render it as text. ` +
|
|
130
|
+
`Inspect it with bash instead: \`file '${p}'\`, \`xxd '${p}' | head\`, \`strings '${p}'\`.`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const lines = content.split("\n");
|
|
134
|
+
if (start >= lines.length) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
text: `${shown} [${where}] has ${lines.length} lines — offset ${start} is past the end.`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const slice = lines.slice(start, start + max);
|
|
141
|
+
const numbered = slice
|
|
142
|
+
.map((line, i) => `${String(start + i + 1).padStart(6)}\t${line}`)
|
|
143
|
+
.join("\n");
|
|
144
|
+
const more =
|
|
145
|
+
lines.length > start + max
|
|
146
|
+
? `\n… (${lines.length - start - max} more lines; raise limit/offset)`
|
|
147
|
+
: "";
|
|
148
|
+
return {
|
|
149
|
+
ok: true,
|
|
150
|
+
text: `${shown} [${where}] — ${lines.length} lines\n${numbered}${more}`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function oversizeReadResult(
|
|
155
|
+
shown: string,
|
|
156
|
+
where: string,
|
|
157
|
+
size: number,
|
|
158
|
+
p: string,
|
|
159
|
+
): Result {
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
text:
|
|
163
|
+
`${shown} [${where}] is ${(size / 1_048_576).toFixed(1)}MB — over the ` +
|
|
164
|
+
`${MAX_READ_FILE_BYTES / 1_048_576}MB read limit. Slice it with bash instead: ` +
|
|
165
|
+
`\`sed -n '1,200p' '${p}'\`, \`tail -n 200 '${p}'\`, or \`grep\` for what you need.`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function oversizeImageResult(
|
|
170
|
+
shown: string,
|
|
171
|
+
where: string,
|
|
172
|
+
size: number,
|
|
173
|
+
p: string,
|
|
174
|
+
): Result {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
text:
|
|
178
|
+
`${shown} [${where}] is ${(size / 1_048_576).toFixed(1)}MB — over the ` +
|
|
179
|
+
`${MAX_IMAGE_BYTES / 1_048_576}MB image limit. Downscale it first ` +
|
|
180
|
+
`(e.g. \`convert '${p}' -resize 1568x /tmp/small.jpg\`) and read that.`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a native tool hands back: the `Result` shape every handler in this
|
|
3
|
+
* directory returns, and the renderer that turns an exec run (local or
|
|
4
|
+
* teleported) into the `[where] status / stdout / stderr` block the model
|
|
5
|
+
* reads.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { clampExecOutput } from "../../../../util/exec-output.js";
|
|
9
|
+
|
|
10
|
+
export type Result = {
|
|
11
|
+
ok: boolean;
|
|
12
|
+
text: string;
|
|
13
|
+
/** Set for image files so the tool result carries a viewable image block. */
|
|
14
|
+
image?: { data: string; mimeType: string };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function renderExec(
|
|
18
|
+
where: string,
|
|
19
|
+
status: string,
|
|
20
|
+
stdout: string,
|
|
21
|
+
stderr: string,
|
|
22
|
+
): string {
|
|
23
|
+
const parts = [`[${where}] ${status}`];
|
|
24
|
+
if (stdout.trim())
|
|
25
|
+
parts.push(
|
|
26
|
+
`--- stdout ---\n${clampExecOutput(stdout.replace(/\s+$/, ""))}`,
|
|
27
|
+
);
|
|
28
|
+
if (stderr.trim())
|
|
29
|
+
parts.push(
|
|
30
|
+
`--- stderr ---\n${clampExecOutput(stderr.replace(/\s+$/, ""))}`,
|
|
31
|
+
);
|
|
32
|
+
if (!stdout.trim() && !stderr.trim()) parts.push("(no output)");
|
|
33
|
+
return parts.join("\n");
|
|
34
|
+
}
|