talon-agent 3.33.0 → 3.33.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +3 -3
  2. package/src/backend/kilo/factory.ts +39 -119
  3. package/src/backend/kilo/handler/index.ts +1 -11
  4. package/src/backend/kilo/handler/message.ts +20 -317
  5. package/src/backend/kilo/server.ts +55 -272
  6. package/src/backend/kilo/sessions.ts +5 -83
  7. package/src/backend/opencode/factory.ts +41 -118
  8. package/src/backend/opencode/handler/index.ts +1 -11
  9. package/src/backend/opencode/handler/message.ts +20 -321
  10. package/src/backend/opencode/server.ts +57 -217
  11. package/src/backend/opencode/sessions.ts +5 -83
  12. package/src/backend/remote-server/chat-turn.ts +359 -0
  13. package/src/backend/remote-server/factory.ts +150 -0
  14. package/src/backend/remote-server/index.ts +14 -19
  15. package/src/backend/remote-server/server-bindings.ts +232 -0
  16. package/src/backend/{kilo/handler → remote-server}/turn.ts +103 -67
  17. package/src/core/mesh/bridge-links.ts +319 -0
  18. package/src/core/mesh/common.ts +30 -0
  19. package/src/core/mesh/device-files.ts +710 -0
  20. package/src/core/mesh/service.ts +72 -894
  21. package/src/frontend/discord/handlers/access.ts +7 -56
  22. package/src/frontend/discord/handlers/state.ts +15 -8
  23. package/src/frontend/native/server.ts +391 -323
  24. package/src/frontend/shared/access.ts +152 -0
  25. package/src/frontend/telegram/handlers/access.ts +8 -25
  26. package/src/frontend/telegram/handlers/queue.ts +3 -35
  27. package/src/frontend/telegram/handlers/state.ts +15 -8
  28. package/src/backend/kilo/events.ts +0 -60
  29. package/src/backend/kilo/handler/state.ts +0 -9
  30. package/src/backend/opencode/handler/state.ts +0 -9
  31. package/src/backend/opencode/handler/turn.ts +0 -314
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Bridge links — the MeshService collaborator that hands a device what it
3
+ * needs to reach this daemon: node provisioning (a single-use installer
4
+ * link that fetches a digest-verified talon-node binary from the bridge),
5
+ * companion pairing (a single-use link carrying the bridge URL, token and
6
+ * certificate fingerprint), and the reachability answer behind both.
7
+ *
8
+ * It owns the grant stores and the bridge's self-reported identity; it
9
+ * never touches devices or commands.
10
+ */
11
+
12
+ import { networkInterfaces } from "node:os";
13
+ import {
14
+ CompanionPairStore,
15
+ pairLink,
16
+ pairPage,
17
+ type CompanionPairPayload,
18
+ } from "./companion-pairing.js";
19
+ import { formatBytes, type MeshToolResult } from "./common.js";
20
+ import {
21
+ NODE_TARGETS,
22
+ normalizeGoarch,
23
+ normalizeGoos,
24
+ type NodeBinaryResolver,
25
+ } from "./node-binaries.js";
26
+ import { installOneLiner, NodeProvisionStore } from "./node-provision.js";
27
+
28
+ /**
29
+ * What the native bridge tells the mesh about itself once it's listening —
30
+ * everything a generated node installer needs to point a fresh host here.
31
+ * Registered by the native frontend after server start; null when the
32
+ * bridge isn't running (provisioning tools then fail with a clear reason).
33
+ */
34
+ export type MeshBridgeInfo = {
35
+ scheme: "http" | "https";
36
+ /** The bind host from config — may be a wildcard (0.0.0.0/::). */
37
+ host: string;
38
+ port: number;
39
+ /** Bearer token clients authenticate with (absent on open loopback). */
40
+ token?: string;
41
+ /** TLS certificate SHA-256 (absent over plain HTTP). */
42
+ fingerprint?: string;
43
+ };
44
+
45
+ export class BridgeLinks {
46
+ /** One-time grants for bridge-served node installers. */
47
+ private readonly provision = new NodeProvisionStore();
48
+ /** One-time grants that hand a phone this bridge's connection details. */
49
+ private readonly companionPairs = new CompanionPairStore();
50
+ private bridgeInfo: MeshBridgeInfo | null = null;
51
+
52
+ constructor(private readonly resolveNode: NodeBinaryResolver) {}
53
+
54
+ /** The native bridge reports its reachable identity here (null on stop). */
55
+ setBridgeInfo(info: MeshBridgeInfo | null): void {
56
+ this.bridgeInfo = info;
57
+ }
58
+
59
+ // ── Node provisioning ──────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * `get_node_binary`: materialize a talon-node binary for any supported
63
+ * platform/arch on the daemon host — source build in a dev checkout, else
64
+ * the digest-verified release download (cached under ~/.talon/node-bin).
65
+ */
66
+ async getNodeBinary(os: unknown, arch: unknown): Promise<MeshToolResult> {
67
+ const goos = normalizeGoos(os);
68
+ const goarch = normalizeGoarch(arch);
69
+ if (!goos || !goarch) {
70
+ return { ok: false, text: unknownTargetText(os, arch) };
71
+ }
72
+ try {
73
+ const bin = await this.resolveNode(goos, goarch);
74
+ return {
75
+ ok: true,
76
+ text: `talon-node ${bin.version} for ${goos}/${goarch}: ${bin.path} (${formatBytes(bin.size)}, sha256 ${bin.sha256}, via ${bin.source})`,
77
+ };
78
+ } catch (err) {
79
+ return { ok: false, text: (err as Error).message };
80
+ }
81
+ }
82
+
83
+ /**
84
+ * `make_node_install_link`: mint a single-use provisioning URL on the
85
+ * bridge and return the one command that turns a fresh host into a mesh
86
+ * node — it fetches the installer script, which downloads the (digest-
87
+ * verified) binary from the same bridge, installs it, pre-pins the bridge
88
+ * certificate, and registers the boot service.
89
+ */
90
+ async makeNodeInstallLink(
91
+ os: unknown,
92
+ arch: unknown,
93
+ name?: unknown,
94
+ bridgeUrl?: unknown,
95
+ ): Promise<MeshToolResult> {
96
+ const goos = normalizeGoos(os);
97
+ const goarch = normalizeGoarch(arch);
98
+ if (!goos || !goarch) {
99
+ return { ok: false, text: unknownTargetText(os, arch) };
100
+ }
101
+ const info = this.bridgeInfo;
102
+ if (!info) {
103
+ return {
104
+ ok: false,
105
+ text: "The native bridge isn't running, so there is nothing for a new node to connect to. Enable the native frontend first.",
106
+ };
107
+ }
108
+ if (!info.token) {
109
+ return {
110
+ ok: false,
111
+ text: "The bridge has no bearer token (loopback-only bind), and nodes authenticate with one. Set native.host to a reachable address (a token is auto-minted) and restart.",
112
+ };
113
+ }
114
+ const base = this.bridgeBaseUrl(info, bridgeUrl);
115
+ if (typeof base !== "string") return { ok: false, text: base.error };
116
+ let bin;
117
+ try {
118
+ bin = await this.resolveNode(goos, goarch);
119
+ } catch (err) {
120
+ return { ok: false, text: (err as Error).message };
121
+ }
122
+ const grant = this.provision.create({
123
+ goos,
124
+ goarch,
125
+ ...(typeof name === "string" && name.trim() ? { name: name.trim() } : {}),
126
+ binaryPath: bin.path,
127
+ sha256: bin.sha256,
128
+ size: bin.size,
129
+ version: bin.version,
130
+ bridgeUrl: base,
131
+ bearerToken: info.token,
132
+ ...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
133
+ });
134
+ return {
135
+ ok: true,
136
+ text: [
137
+ `Run this on the new ${goos}/${goarch} host:`,
138
+ "",
139
+ ` ${installOneLiner(grant)}`,
140
+ "",
141
+ `It installs talon-node ${bin.version} (sha256-verified against ${grant.sha256.slice(0, 12)}…), pins the bridge certificate, and registers a boot service — the host appears on the mesh within a minute.`,
142
+ `Single-use link, expires in 30 minutes. The host must be able to reach ${base}.`,
143
+ ].join("\n"),
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Mint a single-use link that connects a phone to this bridge without
149
+ * anyone typing an address or a token — the companion half of
150
+ * {@link makeNodeInstallLink}.
151
+ *
152
+ * Returns the link plus the values it carries, so a caller (a `/mesh`
153
+ * reply, say) can print both: the link is the one-tap path, the values are
154
+ * what someone falls back to when the app isn't installed yet.
155
+ */
156
+ makeCompanionPairLink(
157
+ label?: unknown,
158
+ bridgeUrl?: unknown,
159
+ ):
160
+ | {
161
+ ok: true;
162
+ link: string;
163
+ url: string;
164
+ token: string;
165
+ fingerprint?: string;
166
+ }
167
+ | { ok: false; text: string } {
168
+ const info = this.bridgeInfo;
169
+ if (!info) {
170
+ return {
171
+ ok: false,
172
+ text: "The native bridge isn't running, so there is nothing for a phone to connect to. Enable the native frontend first.",
173
+ };
174
+ }
175
+ if (!info.token) {
176
+ return {
177
+ ok: false,
178
+ text: "The bridge has no bearer token (loopback-only bind), and companions authenticate with one. Set native.host to a reachable address (a token is auto-minted) and restart.",
179
+ };
180
+ }
181
+ const base = this.bridgeBaseUrl(info, bridgeUrl);
182
+ if (typeof base !== "string") return { ok: false, text: base.error };
183
+ const grant = this.companionPairs.create({
184
+ bridgeUrl: base,
185
+ bearerToken: info.token,
186
+ ...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
187
+ ...(typeof label === "string" && label.trim()
188
+ ? { label: label.trim() }
189
+ : {}),
190
+ });
191
+ return {
192
+ ok: true,
193
+ link: pairLink(grant),
194
+ url: base,
195
+ token: info.token,
196
+ ...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
197
+ };
198
+ }
199
+
200
+ /**
201
+ * GET /pair — serve a pairing grant (single-use), as the landing page or
202
+ * as the JSON the companion reads.
203
+ */
204
+ openCompanionPair(
205
+ token: string,
206
+ format: "html" | "json",
207
+ ): { contentType: string; body: string } | null {
208
+ const payload = this.companionPairs.claim(token);
209
+ if (!payload) return null;
210
+ return format === "json"
211
+ ? {
212
+ contentType: "application/json; charset=utf-8",
213
+ body: JSON.stringify(payload satisfies CompanionPairPayload),
214
+ }
215
+ : {
216
+ contentType: "text/html; charset=utf-8",
217
+ body: pairPage(payload),
218
+ };
219
+ }
220
+
221
+ /**
222
+ * How this bridge is reachable, for an operator asking "what do I point a
223
+ * device at?".
224
+ *
225
+ * The bearer token comes back with it. Deciding who may see a credential is
226
+ * the caller's job, not this seam's — the Telegram `/mesh` shows it to the
227
+ * configured admin and withholds it from everyone else — and an operator who
228
+ * asked their own daemon for its own connection details should get an
229
+ * answer, not a lecture.
230
+ */
231
+ bridgeReachability():
232
+ | {
233
+ ok: true;
234
+ url: string;
235
+ authRequired: boolean;
236
+ token?: string;
237
+ fingerprint?: string;
238
+ }
239
+ | { ok: false; text: string } {
240
+ const info = this.bridgeInfo;
241
+ if (!info) return { ok: false, text: "The native bridge isn't running." };
242
+ const base = this.bridgeBaseUrl(info);
243
+ if (typeof base !== "string") return { ok: false, text: base.error };
244
+ return {
245
+ ok: true,
246
+ url: base,
247
+ authRequired: Boolean(info.token),
248
+ ...(info.token ? { token: info.token } : {}),
249
+ ...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
250
+ };
251
+ }
252
+
253
+ /** GET /node/install — serve a grant's installer script (single-use). */
254
+ openNodeInstall(token: string): { script: string; filename: string } | null {
255
+ return this.provision.openScript(token);
256
+ }
257
+
258
+ /** GET /node/binary — serve a grant's binary (single-use). */
259
+ openNodeBinary(token: string): { path: string; size: number } | null {
260
+ return this.provision.openBinary(token);
261
+ }
262
+
263
+ /**
264
+ * The bridge base URL a NEW host should dial: an explicit override wins;
265
+ * otherwise derive from the bridge's bind. A wildcard bind maps to this
266
+ * host's first external IPv4; a loopback bind is unreachable from other
267
+ * machines, so it's an error rather than a link that can't work.
268
+ */
269
+ private bridgeBaseUrl(
270
+ info: MeshBridgeInfo,
271
+ explicit?: unknown,
272
+ ): string | { error: string } {
273
+ if (typeof explicit === "string" && explicit.trim()) {
274
+ const url = explicit.trim().replace(/\/+$/, "");
275
+ if (!/^https?:\/\/\S+$/.test(url)) {
276
+ return { error: `bridge_url must be an http(s) URL, got "${url}".` };
277
+ }
278
+ return url;
279
+ }
280
+ let host = info.host;
281
+ if (host === "0.0.0.0" || host === "::") {
282
+ const external = firstExternalIPv4();
283
+ if (!external) {
284
+ return {
285
+ error:
286
+ "Could not determine this host's external address — pass bridge_url explicitly (the URL the new node should dial).",
287
+ };
288
+ }
289
+ host = external;
290
+ } else if (isLoopbackAddress(host)) {
291
+ return {
292
+ error: `The bridge is bound to loopback (${host}), which other machines can't reach. Set native.host to a reachable address, or pass bridge_url if a tunnel exposes it.`,
293
+ };
294
+ }
295
+ return `${info.scheme}://${host}:${info.port}`;
296
+ }
297
+ }
298
+
299
+ /** Error text for an os/arch pair outside the talon-node build matrix. */
300
+ function unknownTargetText(os: unknown, arch: unknown): string {
301
+ return `No talon-node target for os="${String(os)}", arch="${String(arch)}". Supported: ${NODE_TARGETS.map(
302
+ (t) => `${t.goos}/${t.goarch}`,
303
+ ).join(", ")} (macos ≡ darwin, x86_64 ≡ amd64, aarch64 ≡ arm64).`;
304
+ }
305
+
306
+ /** First non-internal IPv4 on this host — the wildcard-bind fallback. */
307
+ function firstExternalIPv4(): string | undefined {
308
+ for (const list of Object.values(networkInterfaces())) {
309
+ for (const iface of list ?? []) {
310
+ if (!iface.internal && iface.family === "IPv4") return iface.address;
311
+ }
312
+ }
313
+ return undefined;
314
+ }
315
+
316
+ function isLoopbackAddress(host: string): boolean {
317
+ const h = host.toLowerCase();
318
+ return h === "localhost" || h === "::1" || h.startsWith("127.");
319
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Small helpers shared by the mesh service and its collaborators
3
+ * (device-files.ts, bridge-links.ts).
4
+ */
5
+
6
+ /** The model-facing shape every mesh tool answers with. */
7
+ export type MeshToolResult = { ok: boolean; text: string };
8
+
9
+ /** Timeout for a single filesystem command (list/stat/one chunk/etc.). */
10
+ export const FS_COMMAND_TIMEOUT_MS = 30_000;
11
+
12
+ export function formatBytes(bytes: number): string {
13
+ if (bytes < 1024) return `${bytes} B`;
14
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
15
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
16
+ }
17
+
18
+ export function age(ms: number): string {
19
+ const sec = Math.max(0, Math.round(ms / 1000));
20
+ if (sec < 60) return `${sec}s ago`;
21
+ const min = Math.round(sec / 60);
22
+ if (min < 60) return `${min}m ago`;
23
+ const hrs = Math.round(min / 60);
24
+ return `${hrs}h ago`;
25
+ }
26
+
27
+ /** Trim a string path param, returning undefined when blank. */
28
+ export function requirePath(value: unknown): string | undefined {
29
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
30
+ }