talon-agent 1.35.0 → 1.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/backend/claude-sdk/handler.ts +6 -2
- package/src/backend/claude-sdk/options.ts +41 -1
- package/src/backend/codex/handler/events.ts +1 -1
- package/src/backend/codex/handler/message.ts +1 -0
- package/src/backend/kilo/handler/message.ts +1 -0
- package/src/backend/kilo/handler/turn.ts +1 -0
- package/src/backend/openai-agents/handler/events.ts +1 -1
- package/src/backend/openai-agents/handler/message.ts +5 -1
- package/src/backend/opencode/handler/message.ts +1 -0
- package/src/backend/opencode/handler/turn.ts +1 -0
- package/src/backend/remote-server/events.ts +3 -1
- package/src/backend/shared/metrics.ts +26 -51
- package/src/bootstrap.ts +1 -0
- package/src/core/engine/gateway-actions/index.ts +2 -0
- package/src/core/engine/gateway-actions/mesh.ts +27 -0
- package/src/core/engine/gateway-actions/native.ts +607 -0
- package/src/core/mcp-hub/index.ts +3 -0
- package/src/core/mcp-hub/talon-server.ts +3 -0
- package/src/core/mesh/persist.ts +49 -0
- package/src/core/mesh/registry.ts +10 -18
- package/src/core/mesh/service.ts +754 -42
- package/src/core/mesh/teleport.ts +158 -0
- package/src/core/mesh/transfers.ts +186 -0
- package/src/core/tools/bridge.ts +85 -4
- package/src/core/tools/index.ts +23 -2
- package/src/core/tools/mesh.ts +83 -1
- package/src/core/tools/native.ts +138 -0
- package/src/core/tools/types.ts +2 -1
- package/src/frontend/discord/commands/admin.ts +9 -2
- package/src/frontend/discord/helpers.ts +7 -6
- package/src/frontend/native/chats.ts +2 -2
- package/src/frontend/native/index.ts +2 -0
- package/src/frontend/native/server.ts +40 -1
- package/src/frontend/telegram/commands/admin.ts +10 -2
- package/src/frontend/telegram/helpers/diagnostics.ts +7 -6
- package/src/storage/db.ts +6 -1
- package/src/storage/repositories/sessions-repo.ts +20 -1
- package/src/storage/sessions.ts +348 -4
- package/src/storage/sql/db.sql +5 -0
- package/src/storage/sql/schema.sql +2 -1
- package/src/storage/sql/sessions.sql +3 -3
- package/src/storage/sql/statements.generated.ts +8 -4
- package/src/util/config.ts +10 -0
- package/src/util/exec-output.ts +64 -0
- package/src/util/metrics.ts +148 -60
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teleport state — the active node each chat's native tools route through.
|
|
3
|
+
*
|
|
4
|
+
* When a node is active, Talon's native shell/file tools (bash/read/write/
|
|
5
|
+
* edit/glob/search) execute ON that companion device via the mesh exec/fs
|
|
6
|
+
* channel instead of on the daemon host — Talon acts as if it were running
|
|
7
|
+
* on the phone. `teleport_back` clears it and everything runs locally again.
|
|
8
|
+
*
|
|
9
|
+
* State is a tiny JSON sidecar under the Talon root so it survives restarts.
|
|
10
|
+
* The sidecar is keyed by chat id: teleporting in one chat must not redirect
|
|
11
|
+
* native tools in another chat.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import { resolve } from "node:path";
|
|
16
|
+
import { dirs } from "../../util/paths.js";
|
|
17
|
+
import { writePrivateJson } from "./persist.js";
|
|
18
|
+
|
|
19
|
+
export type TeleportState = {
|
|
20
|
+
/** Target device id (as registered in the mesh). */
|
|
21
|
+
deviceId: string;
|
|
22
|
+
/** Human name for messages. */
|
|
23
|
+
deviceName: string;
|
|
24
|
+
/**
|
|
25
|
+
* Working directory tracked across native `bash` calls so a teleported
|
|
26
|
+
* session feels persistent (a `cd` in one command carries to the next).
|
|
27
|
+
* Undefined until the first command resolves it.
|
|
28
|
+
*/
|
|
29
|
+
cwd?: string;
|
|
30
|
+
/** When the teleport was engaged (epoch ms). */
|
|
31
|
+
since: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type TeleportStore = {
|
|
35
|
+
chats: Record<string, TeleportState>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Resolved lazily so a test can point it at a tmp file via env. */
|
|
39
|
+
function stateFile(): string {
|
|
40
|
+
return (
|
|
41
|
+
process.env.TALON_TELEPORT_STATE_FILE ??
|
|
42
|
+
resolve(dirs.root, "teleport-state.json")
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let cache: TeleportStore | undefined;
|
|
47
|
+
|
|
48
|
+
function emptyStore(): TeleportStore {
|
|
49
|
+
return { chats: Object.create(null) as Record<string, TeleportState> };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeChatId(chatId: number | string): string {
|
|
53
|
+
return String(chatId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseState(value: unknown): TeleportState | null {
|
|
57
|
+
const parsed = value as Partial<TeleportState> | null;
|
|
58
|
+
if (!parsed || typeof parsed.deviceId !== "string" || !parsed.deviceId) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
deviceId: parsed.deviceId,
|
|
63
|
+
deviceName:
|
|
64
|
+
typeof parsed.deviceName === "string" && parsed.deviceName
|
|
65
|
+
? parsed.deviceName
|
|
66
|
+
: parsed.deviceId,
|
|
67
|
+
...(typeof parsed.cwd === "string" ? { cwd: parsed.cwd } : {}),
|
|
68
|
+
since: typeof parsed.since === "number" ? parsed.since : Date.now(),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function readStore(): Promise<TeleportStore> {
|
|
73
|
+
if (cache !== undefined) return cache;
|
|
74
|
+
try {
|
|
75
|
+
const raw = await readFile(stateFile(), "utf8");
|
|
76
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
77
|
+
const store = emptyStore();
|
|
78
|
+
if (
|
|
79
|
+
parsed &&
|
|
80
|
+
typeof parsed === "object" &&
|
|
81
|
+
"chats" in parsed &&
|
|
82
|
+
parsed.chats &&
|
|
83
|
+
typeof parsed.chats === "object"
|
|
84
|
+
) {
|
|
85
|
+
for (const [chatId, state] of Object.entries(
|
|
86
|
+
parsed.chats as Record<string, unknown>,
|
|
87
|
+
)) {
|
|
88
|
+
const normalized = parseState(state);
|
|
89
|
+
if (normalized) store.chats[chatId] = normalized;
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
// Legacy singleton file from pre-chat-scoped teleport. Keep reading it
|
|
93
|
+
// harmlessly, but do not bind it to any chat.
|
|
94
|
+
}
|
|
95
|
+
cache = store;
|
|
96
|
+
} catch {
|
|
97
|
+
cache = emptyStore();
|
|
98
|
+
}
|
|
99
|
+
return cache;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The active teleport target, or null when operating locally. */
|
|
103
|
+
export async function getTeleport(
|
|
104
|
+
chatId: number | string,
|
|
105
|
+
): Promise<TeleportState | null> {
|
|
106
|
+
const store = await readStore();
|
|
107
|
+
return store.chats[normalizeChatId(chatId)] ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Engage teleport onto a device. */
|
|
111
|
+
export async function setTeleport(
|
|
112
|
+
chatId: number | string,
|
|
113
|
+
deviceId: string,
|
|
114
|
+
deviceName: string,
|
|
115
|
+
): Promise<TeleportState> {
|
|
116
|
+
const state: TeleportState = {
|
|
117
|
+
deviceId,
|
|
118
|
+
deviceName,
|
|
119
|
+
since: Date.now(),
|
|
120
|
+
};
|
|
121
|
+
const store = await readStore();
|
|
122
|
+
store.chats[normalizeChatId(chatId)] = state;
|
|
123
|
+
cache = store;
|
|
124
|
+
await writePrivateJson(stateFile(), store);
|
|
125
|
+
return state;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Persist an updated working directory for the active teleport. */
|
|
129
|
+
export async function setTeleportCwd(
|
|
130
|
+
chatId: number | string,
|
|
131
|
+
cwd: string,
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
const current = await getTeleport(chatId);
|
|
134
|
+
if (!current) return;
|
|
135
|
+
const next: TeleportState = { ...current, cwd };
|
|
136
|
+
const store = await readStore();
|
|
137
|
+
store.chats[normalizeChatId(chatId)] = next;
|
|
138
|
+
cache = store;
|
|
139
|
+
await writePrivateJson(stateFile(), store);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Return to local operation. Returns the prior target (for the message). */
|
|
143
|
+
export async function clearTeleport(
|
|
144
|
+
chatId: number | string,
|
|
145
|
+
): Promise<TeleportState | null> {
|
|
146
|
+
const store = await readStore();
|
|
147
|
+
const key = normalizeChatId(chatId);
|
|
148
|
+
const prior = store.chats[key] ?? null;
|
|
149
|
+
delete store.chats[key];
|
|
150
|
+
cache = store;
|
|
151
|
+
await writePrivateJson(stateFile(), store);
|
|
152
|
+
return prior;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Test seam — drop the in-memory cache so the next read hits disk. */
|
|
156
|
+
export function resetTeleportCache(): void {
|
|
157
|
+
cache = undefined;
|
|
158
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TransferStore — one-time tokens for streaming device file transfers.
|
|
3
|
+
*
|
|
4
|
+
* The chunked command channel moves file bytes at 256KB–1MB per full mesh
|
|
5
|
+
* round trip (SSE command out, HTTP result back) — fine for small text,
|
|
6
|
+
* hopeless for real files. The streaming path costs ONE command round trip
|
|
7
|
+
* to arrange the transfer, then the file body flows as a single raw HTTP
|
|
8
|
+
* stream between the companion and the bridge at full TCP throughput:
|
|
9
|
+
*
|
|
10
|
+
* pull (device → daemon):
|
|
11
|
+
* daemon: token = createPull(deviceId, destPath)
|
|
12
|
+
* daemon → device (command): upload_file { token, path }
|
|
13
|
+
* device → daemon (HTTP): POST /devices/file?transfer=token (raw body)
|
|
14
|
+
* bridge route: acceptUpload(token, stream) → tmp+rename
|
|
15
|
+
* device → daemon (command result): ok + bytes — the command round trip
|
|
16
|
+
* doubles as the completion signal.
|
|
17
|
+
*
|
|
18
|
+
* push (daemon → device):
|
|
19
|
+
* daemon: token = createPush(deviceId, sourcePath)
|
|
20
|
+
* daemon → device (command): download_file { token, path }
|
|
21
|
+
* device → daemon (HTTP): GET /devices/file?transfer=token
|
|
22
|
+
* bridge route: openDownload(token) → stream the source
|
|
23
|
+
* device writes to its path, answers the command with ok + bytes.
|
|
24
|
+
*
|
|
25
|
+
* Tokens are single-use, bound to one device + one path, and expire unused.
|
|
26
|
+
* The registry never trusts the HTTP caller with a path — the token IS the
|
|
27
|
+
* authorization, and it only reaches the device over the authed mesh
|
|
28
|
+
* channel (the HTTP routes additionally sit behind the bridge bearer token).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { randomBytes } from "node:crypto";
|
|
32
|
+
import { createWriteStream } from "node:fs";
|
|
33
|
+
import { mkdir, rename, rm, stat } from "node:fs/promises";
|
|
34
|
+
import { dirname } from "node:path";
|
|
35
|
+
import { pipeline } from "node:stream/promises";
|
|
36
|
+
import type { Readable } from "node:stream";
|
|
37
|
+
|
|
38
|
+
/** Unused tokens die after this long (transfer not started). */
|
|
39
|
+
const TOKEN_TTL_MS = 10 * 60 * 1000;
|
|
40
|
+
|
|
41
|
+
type Transfer = {
|
|
42
|
+
token: string;
|
|
43
|
+
direction: "pull" | "push";
|
|
44
|
+
deviceId: string;
|
|
45
|
+
/** pull: destination on the daemon host; push: source on the daemon host. */
|
|
46
|
+
localPath: string;
|
|
47
|
+
createdAt: number;
|
|
48
|
+
/** Set once the HTTP leg has started (single-use latch). */
|
|
49
|
+
consumed: boolean;
|
|
50
|
+
/** pull only — resolved by acceptUpload with the byte count. */
|
|
51
|
+
uploadDone?: {
|
|
52
|
+
promise: Promise<number>;
|
|
53
|
+
resolve: (bytes: number) => void;
|
|
54
|
+
reject: (err: Error) => void;
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export class TransferStore {
|
|
59
|
+
private readonly transfers = new Map<string, Transfer>();
|
|
60
|
+
|
|
61
|
+
/** Arrange a device→daemon transfer. Returns the token to send to the
|
|
62
|
+
* device and a promise that resolves (bytes) when the upload lands. */
|
|
63
|
+
createPull(
|
|
64
|
+
deviceId: string,
|
|
65
|
+
destPath: string,
|
|
66
|
+
): { token: string; done: Promise<number> } {
|
|
67
|
+
this.sweep();
|
|
68
|
+
const token = randomBytes(24).toString("base64url");
|
|
69
|
+
let resolve!: (bytes: number) => void;
|
|
70
|
+
let reject!: (err: Error) => void;
|
|
71
|
+
const promise = new Promise<number>((res, rej) => {
|
|
72
|
+
resolve = res;
|
|
73
|
+
reject = rej;
|
|
74
|
+
});
|
|
75
|
+
// A pull whose upload never arrives must not leave an eternally-pending
|
|
76
|
+
// promise; callers race it with the command result, which times out.
|
|
77
|
+
promise.catch(() => {});
|
|
78
|
+
this.transfers.set(token, {
|
|
79
|
+
token,
|
|
80
|
+
direction: "pull",
|
|
81
|
+
deviceId,
|
|
82
|
+
localPath: destPath,
|
|
83
|
+
createdAt: Date.now(),
|
|
84
|
+
consumed: false,
|
|
85
|
+
uploadDone: { promise, resolve, reject },
|
|
86
|
+
});
|
|
87
|
+
return { token, done: promise };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Arrange a daemon→device transfer of `sourcePath`. */
|
|
91
|
+
createPush(deviceId: string, sourcePath: string): { token: string } {
|
|
92
|
+
this.sweep();
|
|
93
|
+
const token = randomBytes(24).toString("base64url");
|
|
94
|
+
this.transfers.set(token, {
|
|
95
|
+
token,
|
|
96
|
+
direction: "push",
|
|
97
|
+
deviceId,
|
|
98
|
+
localPath: sourcePath,
|
|
99
|
+
createdAt: Date.now(),
|
|
100
|
+
consumed: false,
|
|
101
|
+
});
|
|
102
|
+
return { token };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Drop a token (transfer arrangement failed before the HTTP leg). */
|
|
106
|
+
cancel(token: string): void {
|
|
107
|
+
const t = this.transfers.get(token);
|
|
108
|
+
if (t?.uploadDone && !t.consumed) {
|
|
109
|
+
t.uploadDone.reject(new Error("transfer cancelled"));
|
|
110
|
+
}
|
|
111
|
+
this.transfers.delete(token);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Bridge route: a device is streaming a pull's file body up. Writes to a
|
|
116
|
+
* temp file and renames into place, so a dropped connection can't leave a
|
|
117
|
+
* half-written destination. Resolves the pull's `done` promise.
|
|
118
|
+
*/
|
|
119
|
+
async acceptUpload(
|
|
120
|
+
token: string,
|
|
121
|
+
body: Readable,
|
|
122
|
+
): Promise<{ ok: true; bytes: number } | { ok: false; error: string }> {
|
|
123
|
+
const t = this.take(token, "pull");
|
|
124
|
+
if (!t)
|
|
125
|
+
return { ok: false, error: "Unknown or already-used transfer token." };
|
|
126
|
+
const tmp = `${t.localPath}.part-${randomBytes(4).toString("hex")}`;
|
|
127
|
+
try {
|
|
128
|
+
await mkdir(dirname(t.localPath), { recursive: true });
|
|
129
|
+
let bytes = 0;
|
|
130
|
+
body.on("data", (d: Buffer) => (bytes += d.length));
|
|
131
|
+
await pipeline(body, createWriteStream(tmp, { mode: 0o600 }));
|
|
132
|
+
await rename(tmp, t.localPath);
|
|
133
|
+
this.transfers.delete(token);
|
|
134
|
+
t.uploadDone?.resolve(bytes);
|
|
135
|
+
return { ok: true, bytes };
|
|
136
|
+
} catch (err) {
|
|
137
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
138
|
+
this.transfers.delete(token);
|
|
139
|
+
const error = `Upload for ${t.localPath} failed mid-stream: ${(err as Error).message}`;
|
|
140
|
+
t.uploadDone?.reject(new Error(error));
|
|
141
|
+
return { ok: false, error };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Bridge route: a device wants a push's file body. Returns the source
|
|
147
|
+
* path to stream (single use) or null for unknown/used tokens.
|
|
148
|
+
*/
|
|
149
|
+
async openDownload(
|
|
150
|
+
token: string,
|
|
151
|
+
): Promise<{ path: string; size: number } | null> {
|
|
152
|
+
const t = this.take(token, "push");
|
|
153
|
+
if (!t) return null;
|
|
154
|
+
try {
|
|
155
|
+
const s = await stat(t.localPath);
|
|
156
|
+
if (!s.isFile()) throw new Error("not a file");
|
|
157
|
+
return { path: t.localPath, size: s.size };
|
|
158
|
+
} catch {
|
|
159
|
+
this.transfers.delete(token);
|
|
160
|
+
return null;
|
|
161
|
+
} finally {
|
|
162
|
+
// Single-use either way; the device retries by re-arranging.
|
|
163
|
+
this.transfers.delete(token);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Validate + latch a token for its HTTP leg. */
|
|
168
|
+
private take(token: string, direction: Transfer["direction"]) {
|
|
169
|
+
this.sweep();
|
|
170
|
+
const t = this.transfers.get(token);
|
|
171
|
+
if (!t || t.direction !== direction || t.consumed) return null;
|
|
172
|
+
if (Date.now() - t.createdAt > TOKEN_TTL_MS) {
|
|
173
|
+
this.cancel(token);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
t.consumed = true;
|
|
177
|
+
return t;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private sweep(): void {
|
|
181
|
+
const now = Date.now();
|
|
182
|
+
for (const [token, t] of this.transfers) {
|
|
183
|
+
if (!t.consumed && now - t.createdAt > TOKEN_TTL_MS) this.cancel(token);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/core/tools/bridge.ts
CHANGED
|
@@ -5,8 +5,51 @@
|
|
|
5
5
|
* exactly one copy of callBridge / textResult.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { Agent, fetch as undiciFetch } from "undici";
|
|
8
9
|
import type { BridgeFunction } from "./types.js";
|
|
9
10
|
|
|
11
|
+
/** Default wall-clock budget for a bridge action. */
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Actions that can legitimately outlive the default budget. Device file
|
|
16
|
+
* transfers are chunked with no size cap, so they get a very generous
|
|
17
|
+
* ceiling; exec-style actions are bounded by their own 300s command cap
|
|
18
|
+
* (+ margin). Native read/write/edit route to a device when a teleport is
|
|
19
|
+
* engaged, so they inherit transfer-grade budgets too.
|
|
20
|
+
*/
|
|
21
|
+
const LONG_ACTION_TIMEOUTS_MS: Record<string, number> = {
|
|
22
|
+
device_pull_file: 3_600_000,
|
|
23
|
+
device_push_file: 3_600_000,
|
|
24
|
+
device_read_file: 3_600_000,
|
|
25
|
+
device_write_file: 3_600_000,
|
|
26
|
+
native_read: 3_600_000,
|
|
27
|
+
native_write: 3_600_000,
|
|
28
|
+
native_edit: 3_600_000,
|
|
29
|
+
device_exec: 330_000,
|
|
30
|
+
native_bash: 330_000,
|
|
31
|
+
native_glob: 330_000,
|
|
32
|
+
native_search: 330_000,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Long-haul actions (>300s budgets) can't ride Node's built-in fetch: its
|
|
37
|
+
* default dispatcher fails any request whose response headers take >300s,
|
|
38
|
+
* which would kill a long transfer regardless of our AbortSignal — and the
|
|
39
|
+
* built-in fetch brand-checks `dispatcher`, rejecting an Agent from the npm
|
|
40
|
+
* undici package ("fetch failed"). So long-haul calls use the npm undici's
|
|
41
|
+
* OWN fetch with its own Agent (watchdogs off); the deadline is then
|
|
42
|
+
* governed solely by the per-action AbortSignal. Everything else keeps the
|
|
43
|
+
* plain global fetch (fast path, easily stubbed in tests).
|
|
44
|
+
*/
|
|
45
|
+
let longHaulAgent: Agent | undefined;
|
|
46
|
+
function dispatcher(): Agent {
|
|
47
|
+
longHaulAgent ??= new Agent({ headersTimeout: 0, bodyTimeout: 0 });
|
|
48
|
+
return longHaulAgent;
|
|
49
|
+
}
|
|
50
|
+
/** Built-in fetch's default dispatcher fails headers slower than this. */
|
|
51
|
+
const BUILTIN_FETCH_HEADERS_CEILING_MS = 300_000;
|
|
52
|
+
|
|
10
53
|
/**
|
|
11
54
|
* Create a bridge caller bound to a default URL and chat.
|
|
12
55
|
*
|
|
@@ -18,6 +61,11 @@ import type { BridgeFunction } from "./types.js";
|
|
|
18
61
|
* gateway routes to it, AND keeps `chat_id` in the body as a signal
|
|
19
62
|
* that this is an explicit-routing request (the gateway uses that signal
|
|
20
63
|
* to skip the active-context-required check it normally enforces).
|
|
64
|
+
*
|
|
65
|
+
* Failure contract: every failure mode THROWS with a message that names
|
|
66
|
+
* the action and what went wrong. The MCP server layer converts a throw
|
|
67
|
+
* into an `isError` tool result, so the model always gets told — a tool
|
|
68
|
+
* call can time out, but it can never silently vanish.
|
|
21
69
|
*/
|
|
22
70
|
export function createBridge(
|
|
23
71
|
bridgeUrl: string,
|
|
@@ -30,14 +78,43 @@ export function createBridge(
|
|
|
30
78
|
? String((params as Record<string, unknown>).chat_id)
|
|
31
79
|
: null;
|
|
32
80
|
const effectiveChatId = explicitChatId ?? chatId;
|
|
33
|
-
const
|
|
81
|
+
const timeoutMs = LONG_ACTION_TIMEOUTS_MS[action] ?? DEFAULT_TIMEOUT_MS;
|
|
82
|
+
const init = {
|
|
34
83
|
method: "POST",
|
|
35
84
|
headers: { "Content-Type": "application/json" },
|
|
36
85
|
// chat_id stays in body when set — gateway uses its presence as the
|
|
37
86
|
// "explicit routing" signal. _chatId is the routing key either way.
|
|
38
87
|
body: JSON.stringify({ action, ...params, _chatId: effectiveChatId }),
|
|
39
|
-
signal: AbortSignal.timeout(
|
|
40
|
-
}
|
|
88
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
89
|
+
};
|
|
90
|
+
// Minimal common surface of DOM Response and undici's Response.
|
|
91
|
+
let resp: {
|
|
92
|
+
ok: boolean;
|
|
93
|
+
status: number;
|
|
94
|
+
text(): Promise<string>;
|
|
95
|
+
json(): Promise<unknown>;
|
|
96
|
+
};
|
|
97
|
+
try {
|
|
98
|
+
resp =
|
|
99
|
+
timeoutMs > BUILTIN_FETCH_HEADERS_CEILING_MS
|
|
100
|
+
? await undiciFetch(`${bridgeUrl}/action`, {
|
|
101
|
+
...init,
|
|
102
|
+
dispatcher: dispatcher(),
|
|
103
|
+
})
|
|
104
|
+
: await fetch(`${bridgeUrl}/action`, init);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
const cause = err as Error & { name?: string };
|
|
107
|
+
if (cause.name === "TimeoutError" || cause.name === "AbortError") {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`"${action}" did not complete within ${Math.round(timeoutMs / 1000)}s. ` +
|
|
110
|
+
`The operation may still be running on the daemon — check its ` +
|
|
111
|
+
`outcome (e.g. list the target directory) before retrying.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
throw new Error(
|
|
115
|
+
`"${action}" could not reach the Talon gateway: ${cause.message}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
41
118
|
if (!resp.ok) {
|
|
42
119
|
const text = await resp.text();
|
|
43
120
|
throw new Error(`Bridge error (${resp.status}): ${text}`);
|
|
@@ -49,11 +126,15 @@ export function createBridge(
|
|
|
49
126
|
/** Wrap a bridge result into the MCP content format. */
|
|
50
127
|
export function textResult(result: unknown): {
|
|
51
128
|
content: Array<{ type: "text"; text: string }>;
|
|
129
|
+
isError?: boolean;
|
|
52
130
|
} {
|
|
53
|
-
const r = result as { text?: string; error?: string };
|
|
131
|
+
const r = result as { ok?: boolean; text?: string; error?: string };
|
|
54
132
|
return {
|
|
55
133
|
content: [
|
|
56
134
|
{ type: "text" as const, text: r.text ?? JSON.stringify(result) },
|
|
57
135
|
],
|
|
136
|
+
// Gateway results carry ok:false on failure — mark those as tool errors
|
|
137
|
+
// so the model treats the message as a failure, not a success payload.
|
|
138
|
+
...(r && typeof r === "object" && r.ok === false ? { isError: true } : {}),
|
|
58
139
|
};
|
|
59
140
|
}
|
package/src/core/tools/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { webTools } from "./web.js";
|
|
|
22
22
|
import { adminTools } from "./admin.js";
|
|
23
23
|
import { modelTools } from "./models.js";
|
|
24
24
|
import { meshTools } from "./mesh.js";
|
|
25
|
+
import { nativeTools } from "./native.js";
|
|
25
26
|
|
|
26
27
|
/** All built-in tool definitions. */
|
|
27
28
|
export const ALL_TOOLS: readonly ToolDefinition[] = [
|
|
@@ -42,6 +43,15 @@ export const ALL_TOOLS: readonly ToolDefinition[] = [
|
|
|
42
43
|
...meshTools,
|
|
43
44
|
];
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Native tools (bash/read/write/edit/glob/search + teleport) are kept OUT of
|
|
48
|
+
* ALL_TOOLS on purpose: they are an opt-in replacement for the SDK built-ins,
|
|
49
|
+
* surfaced only when `composeTools({ includeNativeTools: true })` is asked
|
|
50
|
+
* (driven by `config.nativeTools`). Keeping them separate preserves the
|
|
51
|
+
* "no options → the full built-in set" invariant every other caller relies on.
|
|
52
|
+
*/
|
|
53
|
+
export { nativeTools };
|
|
54
|
+
|
|
45
55
|
/**
|
|
46
56
|
* Names of tools that explicitly terminate the model's turn.
|
|
47
57
|
*
|
|
@@ -75,7 +85,7 @@ const DELIVERY_TOOL_NAMES: ReadonlySet<string> = new Set(
|
|
|
75
85
|
* Kilo's `<server>_<bare>` instead of MCP's canonical `mcp__<server>__<bare>`).
|
|
76
86
|
*/
|
|
77
87
|
const ALL_TOOL_NAMES: ReadonlySet<string> = new Set(
|
|
78
|
-
ALL_TOOLS.map((t) => t.name),
|
|
88
|
+
[...ALL_TOOLS, ...nativeTools].map((t) => t.name),
|
|
79
89
|
);
|
|
80
90
|
|
|
81
91
|
/**
|
|
@@ -180,6 +190,13 @@ export interface ComposeOptions {
|
|
|
180
190
|
excludeTags?: ToolTag[];
|
|
181
191
|
/** Exclude specific tools by name. */
|
|
182
192
|
excludeNames?: string[];
|
|
193
|
+
/**
|
|
194
|
+
* Include the `native` tool set (bash/read/write/edit/glob/search +
|
|
195
|
+
* teleport). Off unless explicitly requested — these are only meant to be
|
|
196
|
+
* live when `config.nativeTools` replaces the SDK built-ins, so every other
|
|
197
|
+
* caller (and tests) gets the built-in surface by default.
|
|
198
|
+
*/
|
|
199
|
+
includeNativeTools?: boolean;
|
|
183
200
|
}
|
|
184
201
|
|
|
185
202
|
/**
|
|
@@ -189,7 +206,11 @@ export interface ComposeOptions {
|
|
|
189
206
|
* Callers describe what they need and get back matching definitions.
|
|
190
207
|
*/
|
|
191
208
|
export function composeTools(options: ComposeOptions = {}): ToolDefinition[] {
|
|
192
|
-
|
|
209
|
+
// Native tools are opt-in — appended only when replacing the SDK built-ins,
|
|
210
|
+
// so every default caller (and tests) gets exactly the built-in set.
|
|
211
|
+
let tools = options.includeNativeTools
|
|
212
|
+
? [...ALL_TOOLS, ...nativeTools]
|
|
213
|
+
: [...ALL_TOOLS];
|
|
193
214
|
|
|
194
215
|
if (options.frontend) {
|
|
195
216
|
tools = tools.filter(
|
package/src/core/tools/mesh.ts
CHANGED
|
@@ -12,7 +12,7 @@ const deviceParam = z
|
|
|
12
12
|
.string()
|
|
13
13
|
.optional()
|
|
14
14
|
.describe(
|
|
15
|
-
"Device id
|
|
15
|
+
"Device id, exact name, or unique name fragment (case/separator-insensitive; see list_devices). A fragment matching several devices errors unless exactly one is online — prefer the id when duplicates exist. Defaults to the most recently seen mobile mesh device.",
|
|
16
16
|
);
|
|
17
17
|
|
|
18
18
|
export const meshTools: ToolDefinition[] = [
|
|
@@ -68,4 +68,86 @@ export const meshTools: ToolDefinition[] = [
|
|
|
68
68
|
execute: (params, bridge) => bridge("get_device_status", params),
|
|
69
69
|
tag: "mesh",
|
|
70
70
|
},
|
|
71
|
+
{
|
|
72
|
+
name: "device_exec",
|
|
73
|
+
description:
|
|
74
|
+
"Run a shell command ON a Talon companion device (e.g. the phone) and return its stdout/stderr/exit code. The device executes it in its own sandbox (Android: app UID, or elevated via Shizuku if available). Use for on-device tasks like tidying a folder. Prefer `teleport` for a sustained session.",
|
|
75
|
+
schema: {
|
|
76
|
+
device: deviceParam,
|
|
77
|
+
cmd: z.string().describe("The shell command to run on the device."),
|
|
78
|
+
cwd: z
|
|
79
|
+
.string()
|
|
80
|
+
.optional()
|
|
81
|
+
.describe("Working directory on the device to run in."),
|
|
82
|
+
timeout_sec: z
|
|
83
|
+
.number()
|
|
84
|
+
.optional()
|
|
85
|
+
.describe("Max seconds to allow (default 60, max 300)."),
|
|
86
|
+
},
|
|
87
|
+
execute: (params, bridge) => bridge("device_exec", params),
|
|
88
|
+
tag: "mesh",
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: "device_list_dir",
|
|
92
|
+
description:
|
|
93
|
+
"List a directory on a Talon companion device (name, type, size per entry).",
|
|
94
|
+
schema: {
|
|
95
|
+
device: deviceParam,
|
|
96
|
+
path: z.string().describe("Absolute directory path on the device."),
|
|
97
|
+
},
|
|
98
|
+
execute: (params, bridge) => bridge("device_list_dir", params),
|
|
99
|
+
tag: "mesh",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "device_read_file",
|
|
103
|
+
description:
|
|
104
|
+
"Read a (text) file off a Talon companion device and return its contents. Small files ride the command channel; big files stream over HTTP automatically. No size cap.",
|
|
105
|
+
schema: {
|
|
106
|
+
device: deviceParam,
|
|
107
|
+
path: z.string().describe("Absolute file path on the device."),
|
|
108
|
+
},
|
|
109
|
+
execute: (params, bridge) => bridge("device_read_file", params),
|
|
110
|
+
tag: "mesh",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: "device_write_file",
|
|
114
|
+
description:
|
|
115
|
+
"Write text content to a file on a Talon companion device (creates/overwrites).",
|
|
116
|
+
schema: {
|
|
117
|
+
device: deviceParam,
|
|
118
|
+
path: z.string().describe("Absolute file path on the device."),
|
|
119
|
+
content: z.string().describe("Text content to write."),
|
|
120
|
+
},
|
|
121
|
+
execute: (params, bridge) => bridge("device_write_file", params),
|
|
122
|
+
tag: "mesh",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: "device_pull_file",
|
|
126
|
+
description:
|
|
127
|
+
"Copy a file FROM a Talon companion device to the daemon host (workspace). Streams disk-to-disk in a single HTTP request at full throughput (no size cap; chunked fallback for old app builds). Lands in workspace/mesh-pull/ unless a local path is given.",
|
|
128
|
+
schema: {
|
|
129
|
+
device: deviceParam,
|
|
130
|
+
remote_path: z.string().describe("Absolute source path on the device."),
|
|
131
|
+
local_path: z
|
|
132
|
+
.string()
|
|
133
|
+
.optional()
|
|
134
|
+
.describe("Destination under the workspace (optional)."),
|
|
135
|
+
},
|
|
136
|
+
execute: (params, bridge) => bridge("device_pull_file", params),
|
|
137
|
+
tag: "mesh",
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: "device_push_file",
|
|
141
|
+
description:
|
|
142
|
+
"Copy a file FROM the daemon host (workspace) TO a Talon companion device. Streams disk-to-disk in a single HTTP request at full throughput (no size cap; chunked fallback for old app builds).",
|
|
143
|
+
schema: {
|
|
144
|
+
device: deviceParam,
|
|
145
|
+
local_path: z.string().describe("Source path under the workspace."),
|
|
146
|
+
remote_path: z
|
|
147
|
+
.string()
|
|
148
|
+
.describe("Absolute destination path on the device."),
|
|
149
|
+
},
|
|
150
|
+
execute: (params, bridge) => bridge("device_push_file", params),
|
|
151
|
+
tag: "mesh",
|
|
152
|
+
},
|
|
71
153
|
];
|