talon-agent 1.34.1 → 1.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/backend/claude-sdk/options.ts +41 -1
- package/src/bootstrap.ts +1 -0
- package/src/core/engine/gateway-actions/index.ts +5 -0
- package/src/core/engine/gateway-actions/mesh.ts +49 -0
- package/src/core/engine/gateway-actions/native.ts +574 -0
- package/src/core/mcp-hub/index.ts +3 -0
- package/src/core/mcp-hub/talon-server.ts +3 -0
- package/src/core/mesh/index.ts +27 -0
- package/src/core/mesh/persist.ts +49 -0
- package/src/core/mesh/registry.ts +264 -0
- package/src/core/mesh/service.ts +1157 -0
- package/src/core/mesh/teleport.ts +104 -0
- package/src/core/mesh/transfers.ts +186 -0
- package/src/core/mesh/types.ts +137 -0
- package/src/core/tools/bridge.ts +85 -4
- package/src/core/tools/index.ts +25 -2
- package/src/core/tools/mesh.ts +153 -0
- package/src/core/tools/native.ts +138 -0
- package/src/core/tools/types.ts +3 -1
- package/src/frontend/native/actions.ts +17 -1
- package/src/frontend/native/index.ts +37 -0
- package/src/frontend/native/protocol.ts +30 -0
- package/src/frontend/native/server.ts +77 -0
- package/src/util/config.ts +10 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teleport state — the single "active node" the 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
|
+
* and is shared across the process (the native gateway actions read it on
|
|
11
|
+
* every call). Deliberately global, not per-chat: teleport is an operator
|
|
12
|
+
* mode for the whole daemon, matching how Dylan drives a single bot.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFile } from "node:fs/promises";
|
|
16
|
+
import { resolve } from "node:path";
|
|
17
|
+
import { dirs } from "../../util/paths.js";
|
|
18
|
+
import { writePrivateJson } from "./persist.js";
|
|
19
|
+
|
|
20
|
+
export type TeleportState = {
|
|
21
|
+
/** Target device id (as registered in the mesh). */
|
|
22
|
+
deviceId: string;
|
|
23
|
+
/** Human name for messages. */
|
|
24
|
+
deviceName: string;
|
|
25
|
+
/**
|
|
26
|
+
* Working directory tracked across native `bash` calls so a teleported
|
|
27
|
+
* session feels persistent (a `cd` in one command carries to the next).
|
|
28
|
+
* Undefined until the first command resolves it.
|
|
29
|
+
*/
|
|
30
|
+
cwd?: string;
|
|
31
|
+
/** When the teleport was engaged (epoch ms). */
|
|
32
|
+
since: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Resolved lazily so a test can point it at a tmp file via env. */
|
|
36
|
+
function stateFile(): string {
|
|
37
|
+
return (
|
|
38
|
+
process.env.TALON_TELEPORT_STATE_FILE ??
|
|
39
|
+
resolve(dirs.root, "teleport-state.json")
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let cache: TeleportState | null | undefined;
|
|
44
|
+
|
|
45
|
+
/** The active teleport target, or null when operating locally. */
|
|
46
|
+
export async function getTeleport(): Promise<TeleportState | null> {
|
|
47
|
+
if (cache !== undefined) return cache;
|
|
48
|
+
try {
|
|
49
|
+
const raw = await readFile(stateFile(), "utf8");
|
|
50
|
+
const parsed = JSON.parse(raw) as Partial<TeleportState>;
|
|
51
|
+
cache =
|
|
52
|
+
parsed && typeof parsed.deviceId === "string" && parsed.deviceId
|
|
53
|
+
? {
|
|
54
|
+
deviceId: parsed.deviceId,
|
|
55
|
+
deviceName:
|
|
56
|
+
typeof parsed.deviceName === "string" && parsed.deviceName
|
|
57
|
+
? parsed.deviceName
|
|
58
|
+
: parsed.deviceId,
|
|
59
|
+
...(typeof parsed.cwd === "string" ? { cwd: parsed.cwd } : {}),
|
|
60
|
+
since: typeof parsed.since === "number" ? parsed.since : Date.now(),
|
|
61
|
+
}
|
|
62
|
+
: null;
|
|
63
|
+
} catch {
|
|
64
|
+
cache = null;
|
|
65
|
+
}
|
|
66
|
+
return cache;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Engage teleport onto a device. */
|
|
70
|
+
export async function setTeleport(
|
|
71
|
+
deviceId: string,
|
|
72
|
+
deviceName: string,
|
|
73
|
+
): Promise<TeleportState> {
|
|
74
|
+
const state: TeleportState = {
|
|
75
|
+
deviceId,
|
|
76
|
+
deviceName,
|
|
77
|
+
since: Date.now(),
|
|
78
|
+
};
|
|
79
|
+
cache = state;
|
|
80
|
+
await writePrivateJson(stateFile(), state);
|
|
81
|
+
return state;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Persist an updated working directory for the active teleport. */
|
|
85
|
+
export async function setTeleportCwd(cwd: string): Promise<void> {
|
|
86
|
+
const current = await getTeleport();
|
|
87
|
+
if (!current) return;
|
|
88
|
+
const next: TeleportState = { ...current, cwd };
|
|
89
|
+
cache = next;
|
|
90
|
+
await writePrivateJson(stateFile(), next);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Return to local operation. Returns the prior target (for the message). */
|
|
94
|
+
export async function clearTeleport(): Promise<TeleportState | null> {
|
|
95
|
+
const prior = await getTeleport();
|
|
96
|
+
cache = null;
|
|
97
|
+
await writePrivateJson(stateFile(), null);
|
|
98
|
+
return prior;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Test seam — drop the in-memory cache so the next read hits disk. */
|
|
102
|
+
export function resetTeleportCache(): void {
|
|
103
|
+
cache = undefined;
|
|
104
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-mesh domain types + normalizers.
|
|
3
|
+
*
|
|
4
|
+
* The canonical shapes for companion devices and their reported locations.
|
|
5
|
+
* They live in core (not a frontend) because the mesh is daemon-wide state:
|
|
6
|
+
* devices register through the native bridge transport, but the model reads
|
|
7
|
+
* them from ANY frontend via the shared `list_devices` /
|
|
8
|
+
* `get_device_location` gateway actions. The bridge protocol re-exports
|
|
9
|
+
* these types so the wire contract is unchanged.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type DevicePlatform = "android" | "macos" | "windows" | "linux" | "ios";
|
|
13
|
+
|
|
14
|
+
export type DeviceInfo = {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
platform: DevicePlatform;
|
|
18
|
+
appVersion: string;
|
|
19
|
+
online: boolean;
|
|
20
|
+
/** Epoch milliseconds. */
|
|
21
|
+
lastSeen: number;
|
|
22
|
+
/** Battery percent, 0-100. */
|
|
23
|
+
battery?: number;
|
|
24
|
+
charging?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Command names this device can execute (e.g. "locate", "ring",
|
|
27
|
+
* "open_url"). Reported by the device at registration; absent for app
|
|
28
|
+
* versions that predate the command channel — treat as unknown, not
|
|
29
|
+
* empty.
|
|
30
|
+
*/
|
|
31
|
+
capabilities?: string[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* An on-demand command pushed to one companion device over the mesh
|
|
36
|
+
* transport. The device executes it and answers with a DeviceCommandResult
|
|
37
|
+
* carrying the same `id`.
|
|
38
|
+
*/
|
|
39
|
+
export type DeviceCommand = {
|
|
40
|
+
/** Correlation id — echoed back as the result's `commandId`. */
|
|
41
|
+
id: string;
|
|
42
|
+
deviceId: string;
|
|
43
|
+
/** Command name (e.g. "ring", "open_url", "clipboard_get", "status"). */
|
|
44
|
+
name: string;
|
|
45
|
+
params: Record<string, unknown>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** A device's answer to a DeviceCommand. */
|
|
49
|
+
export type DeviceCommandResult = {
|
|
50
|
+
commandId: string;
|
|
51
|
+
deviceId: string;
|
|
52
|
+
ok: boolean;
|
|
53
|
+
/** Human-readable outcome (shown to the model). */
|
|
54
|
+
message?: string;
|
|
55
|
+
/** Structured payload (e.g. clipboard text, status fields). */
|
|
56
|
+
data?: Record<string, unknown>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type DeviceLocation = {
|
|
60
|
+
deviceId: string;
|
|
61
|
+
lat: number;
|
|
62
|
+
lon: number;
|
|
63
|
+
accuracyM?: number;
|
|
64
|
+
altitudeM?: number;
|
|
65
|
+
speedMps?: number;
|
|
66
|
+
headingDeg?: number;
|
|
67
|
+
/** Epoch milliseconds. */
|
|
68
|
+
ts: number;
|
|
69
|
+
provider?: string;
|
|
70
|
+
batteryPct?: number;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function toDeviceInfo(
|
|
74
|
+
value: DeviceInfo,
|
|
75
|
+
now = Date.now(),
|
|
76
|
+
offlineAfterMs = 90_000,
|
|
77
|
+
): DeviceInfo {
|
|
78
|
+
const battery =
|
|
79
|
+
typeof value.battery === "number"
|
|
80
|
+
? Math.max(0, Math.min(100, Math.round(value.battery)))
|
|
81
|
+
: undefined;
|
|
82
|
+
return {
|
|
83
|
+
id: value.id,
|
|
84
|
+
name: value.name,
|
|
85
|
+
platform: value.platform,
|
|
86
|
+
appVersion: value.appVersion,
|
|
87
|
+
online: now - value.lastSeen <= offlineAfterMs && value.online,
|
|
88
|
+
lastSeen: value.lastSeen,
|
|
89
|
+
...(battery !== undefined ? { battery } : {}),
|
|
90
|
+
...(typeof value.charging === "boolean"
|
|
91
|
+
? { charging: value.charging }
|
|
92
|
+
: {}),
|
|
93
|
+
...(value.capabilities?.length
|
|
94
|
+
? { capabilities: [...value.capabilities] }
|
|
95
|
+
: {}),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Normalize a device's advertised capability list: strings only, trimmed,
|
|
101
|
+
* lowercased, deduped, and bounded so a misbehaving client can't bloat the
|
|
102
|
+
* persisted registry. Undefined when nothing valid remains.
|
|
103
|
+
*/
|
|
104
|
+
export function sanitizeCapabilities(value: unknown): string[] | undefined {
|
|
105
|
+
if (!Array.isArray(value)) return undefined;
|
|
106
|
+
const seen = new Set<string>();
|
|
107
|
+
for (const item of value) {
|
|
108
|
+
if (typeof item !== "string") continue;
|
|
109
|
+
const cap = item.trim().toLowerCase().slice(0, 32);
|
|
110
|
+
if (cap) seen.add(cap);
|
|
111
|
+
if (seen.size >= 16) break;
|
|
112
|
+
}
|
|
113
|
+
return seen.size ? [...seen] : undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function toDeviceLocation(value: DeviceLocation): DeviceLocation {
|
|
117
|
+
return {
|
|
118
|
+
deviceId: value.deviceId,
|
|
119
|
+
lat: value.lat,
|
|
120
|
+
lon: value.lon,
|
|
121
|
+
...(typeof value.accuracyM === "number"
|
|
122
|
+
? { accuracyM: value.accuracyM }
|
|
123
|
+
: {}),
|
|
124
|
+
...(typeof value.altitudeM === "number"
|
|
125
|
+
? { altitudeM: value.altitudeM }
|
|
126
|
+
: {}),
|
|
127
|
+
...(typeof value.speedMps === "number" ? { speedMps: value.speedMps } : {}),
|
|
128
|
+
...(typeof value.headingDeg === "number"
|
|
129
|
+
? { headingDeg: value.headingDeg }
|
|
130
|
+
: {}),
|
|
131
|
+
ts: value.ts,
|
|
132
|
+
...(value.provider ? { provider: value.provider } : {}),
|
|
133
|
+
...(typeof value.batteryPct === "number"
|
|
134
|
+
? { batteryPct: Math.max(0, Math.min(100, Math.round(value.batteryPct))) }
|
|
135
|
+
: {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
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
|
@@ -21,6 +21,8 @@ import { skillTools } from "./skills.js";
|
|
|
21
21
|
import { webTools } from "./web.js";
|
|
22
22
|
import { adminTools } from "./admin.js";
|
|
23
23
|
import { modelTools } from "./models.js";
|
|
24
|
+
import { meshTools } from "./mesh.js";
|
|
25
|
+
import { nativeTools } from "./native.js";
|
|
24
26
|
|
|
25
27
|
/** All built-in tool definitions. */
|
|
26
28
|
export const ALL_TOOLS: readonly ToolDefinition[] = [
|
|
@@ -38,8 +40,18 @@ export const ALL_TOOLS: readonly ToolDefinition[] = [
|
|
|
38
40
|
...webTools,
|
|
39
41
|
...adminTools,
|
|
40
42
|
...modelTools,
|
|
43
|
+
...meshTools,
|
|
41
44
|
];
|
|
42
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
|
+
|
|
43
55
|
/**
|
|
44
56
|
* Names of tools that explicitly terminate the model's turn.
|
|
45
57
|
*
|
|
@@ -73,7 +85,7 @@ const DELIVERY_TOOL_NAMES: ReadonlySet<string> = new Set(
|
|
|
73
85
|
* Kilo's `<server>_<bare>` instead of MCP's canonical `mcp__<server>__<bare>`).
|
|
74
86
|
*/
|
|
75
87
|
const ALL_TOOL_NAMES: ReadonlySet<string> = new Set(
|
|
76
|
-
ALL_TOOLS.map((t) => t.name),
|
|
88
|
+
[...ALL_TOOLS, ...nativeTools].map((t) => t.name),
|
|
77
89
|
);
|
|
78
90
|
|
|
79
91
|
/**
|
|
@@ -178,6 +190,13 @@ export interface ComposeOptions {
|
|
|
178
190
|
excludeTags?: ToolTag[];
|
|
179
191
|
/** Exclude specific tools by name. */
|
|
180
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;
|
|
181
200
|
}
|
|
182
201
|
|
|
183
202
|
/**
|
|
@@ -187,7 +206,11 @@ export interface ComposeOptions {
|
|
|
187
206
|
* Callers describe what they need and get back matching definitions.
|
|
188
207
|
*/
|
|
189
208
|
export function composeTools(options: ComposeOptions = {}): ToolDefinition[] {
|
|
190
|
-
|
|
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];
|
|
191
214
|
|
|
192
215
|
if (options.frontend) {
|
|
193
216
|
tools = tools.filter(
|