talon-agent 3.8.2 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/package.json +1 -2
  2. package/src/backend/claude-sdk/handler.ts +1 -1
  3. package/src/backend/codex/handler/message.ts +1 -1
  4. package/src/backend/kilo/handler/index.ts +1 -2
  5. package/src/backend/kilo/handler/state.ts +3 -8
  6. package/src/backend/kilo/index.ts +2 -28
  7. package/src/backend/openai-agents/handler/message.ts +1 -1
  8. package/src/backend/openai-agents/session.ts +1 -1
  9. package/src/backend/opencode/handler/index.ts +1 -2
  10. package/src/backend/opencode/handler/state.ts +3 -8
  11. package/src/backend/opencode/index.ts +0 -7
  12. package/src/backend/shared/delivery.ts +1 -1
  13. package/src/backend/shared/handle-retry.ts +1 -1
  14. package/src/backend/shared/index.ts +3 -2
  15. package/src/backend/shared/turn-interrupt.ts +1 -1
  16. package/src/cli/index.ts +0 -8
  17. package/src/core/bus/index.ts +1 -9
  18. package/src/core/mesh/registry.ts +106 -4
  19. package/src/core/mesh/service.ts +22 -7
  20. package/src/core/mesh/transfers.ts +33 -4
  21. package/src/core/plugin/index.ts +0 -9
  22. package/src/core/prompt/index.ts +1 -16
  23. package/src/core/vfs/index.ts +2 -21
  24. package/src/core/weaver/index.ts +5 -9
  25. package/src/frontend/discord/actions/messaging.ts +73 -13
  26. package/src/frontend/discord/actions/shared.ts +21 -3
  27. package/src/frontend/discord/commands/admin.ts +1 -1
  28. package/src/frontend/discord/errors.ts +16 -5
  29. package/src/frontend/discord/formatting.ts +19 -4
  30. package/src/frontend/native/index.ts +22 -8
  31. package/src/frontend/native/protocol.ts +6 -0
  32. package/src/frontend/native/server.ts +171 -12
  33. package/src/frontend/shared/format.ts +6 -0
  34. package/src/frontend/telegram/actions/messaging.ts +120 -24
  35. package/src/frontend/telegram/callbacks/metrics.ts +1 -1
  36. package/src/frontend/telegram/commands/admin.ts +1 -1
  37. package/src/frontend/telegram/formatting.ts +64 -19
  38. package/src/frontend/terminal/commands.ts +46 -8
  39. package/src/frontend/terminal/input.ts +110 -12
  40. package/src/frontend/terminal/renderer.ts +2 -0
  41. package/src/{util → storage}/metrics.ts +1 -1
  42. package/src/storage/scheduled-store.ts +6 -3
  43. package/src/util/config.ts +8 -0
  44. package/src/util/log.ts +1 -0
  45. package/src/backend/codex/index.ts +0 -33
  46. package/src/backend/openai-agents/index.ts +0 -48
  47. package/src/cli/plugin-entries.ts +0 -104
  48. package/src/core/agent-runtime/index.ts +0 -62
  49. package/src/core/background/index.ts +0 -21
  50. package/src/core/engine/index.ts +0 -20
  51. package/src/core/models/index.ts +0 -17
  52. package/src/core/soul/index.ts +0 -140
  53. package/src/core/tools/mcp-server.ts +0 -111
  54. /package/src/{backend/shared → util}/session-name.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.8.2",
3
+ "version": "3.9.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -131,7 +131,6 @@
131
131
  "zod": "^4.3.6"
132
132
  },
133
133
  "devDependencies": {
134
- "@swc/core": "^1.15.46",
135
134
  "@types/node": "^26.0.0",
136
135
  "@types/write-file-atomic": "^4.0.3",
137
136
  "@vitest/coverage-v8": "^4.1.3",
@@ -22,7 +22,7 @@ import {
22
22
  } from "../../storage/sessions.js";
23
23
  import { log, logError, logWarn } from "../../util/log.js";
24
24
  import { traceMessage } from "../../util/trace.js";
25
- import { incrementCounter } from "../../util/metrics.js";
25
+ import { incrementCounter } from "../../storage/metrics.js";
26
26
  import { isTurnTerminator } from "../../core/tools/index.js";
27
27
 
28
28
  import type { Query } from "@anthropic-ai/claude-agent-sdk";
@@ -22,7 +22,7 @@ import {
22
22
  import { getChatSettings } from "../../../storage/chat-settings.js";
23
23
  import { log, logError, logWarn } from "../../../util/log.js";
24
24
  import { traceMessage } from "../../../util/trace.js";
25
- import { incrementCounter } from "../../../util/metrics.js";
25
+ import { incrementCounter } from "../../../storage/metrics.js";
26
26
 
27
27
  import {
28
28
  createStreamState,
@@ -2,7 +2,7 @@
2
2
  * Kilo message handler.
3
3
  *
4
4
  * Split by responsibility:
5
- * - `state` — in-flight session registry (getActiveSession)
5
+ * - `state` — in-flight session registry (activeSessions)
6
6
  * - `turn` — runKiloTurn + SSE subscription + last-assistant lookup
7
7
  * - `message` — the handleMessage entry point + post-loop accounting
8
8
  *
@@ -10,4 +10,3 @@
10
10
  */
11
11
 
12
12
  export { handleMessage } from "./message.js";
13
- export { getActiveSession } from "./state.js";
@@ -1,14 +1,9 @@
1
1
  /**
2
2
  * Active-turn session registry for Kilo.
3
3
  *
4
- * Tracks the in-flight Kilo session id per chat so gateway actions (e.g.
5
- * abort on user `/cancel`, refresh MCP on plugin reload) can reach into a
6
- * running turn without going through chat state.
4
+ * Tracks the in-flight Kilo session id per chat for the duration of a
5
+ * turn: `message.ts` records the id on entry and clears it in its finally
6
+ * block, and `turn.ts` matches incoming SSE events against it.
7
7
  */
8
8
 
9
9
  export const activeSessions = new Map<string, string>();
10
-
11
- /** Get the in-flight Kilo session id for a chat, if a turn is running. */
12
- export function getActiveSession(chatId: string): string | undefined {
13
- return activeSessions.get(chatId);
14
- }
@@ -26,51 +26,25 @@ export {
26
26
  type OpenCodeModelCatalog,
27
27
  type OpenCodeModelResolution,
28
28
  type ModelButton,
29
- getOpenCodeModelCatalog,
30
- getOpenCodeModelInfo,
31
29
  getOpenCodeModelSelectionValue,
32
30
  resolveOpenCodeModelInput,
33
31
  getOpenCodeQuickPickModels,
34
- getOpenCodeSettingsPresentation,
35
- renderOpenCodeModelSummary,
36
- renderOpenCodeModelList,
37
32
  formatOpenCodeSelectionError,
38
- formatOpenCodeUnavailableModel,
39
33
  } from "./models/index.js";
40
34
 
41
35
  // ── Sessions ───────────────────────────────────────────────────────────────
42
36
  export {
43
37
  summarizeKiloAssistantMessages,
44
38
  getKiloSessionSnapshot,
45
- getKiloTurnSummary,
46
- extractPartsSummary,
47
- extractAssistantUsage,
48
- rejectPendingQuestions,
49
39
  type KiloAssistantInfo,
50
40
  type KiloSessionSnapshot,
51
41
  } from "./sessions.js";
52
42
 
53
43
  // ── Server / lifecycle ─────────────────────────────────────────────────────
54
- export {
55
- initKiloAgent,
56
- stopKiloServer,
57
- ensureServer,
58
- ensureSession,
59
- ensureChatMcpServer,
60
- ensurePluginMcpServers,
61
- buildToolOverrides,
62
- disconnectChatMcpServer,
63
- resolveProviderID,
64
- parseStoredKiloModelSelection,
65
- KILO_HOSTNAME,
66
- KILO_PORT,
67
- KILO_BASE_URL,
68
- KILO_SYSTEM_PROMPT_SUFFIX,
69
- TALON_MCP_SERVER_NAME,
70
- } from "./server.js";
44
+ export { initKiloAgent, stopKiloServer } from "./server.js";
71
45
 
72
46
  // ── Handler ────────────────────────────────────────────────────────────────
73
- export { handleMessage, getActiveSession } from "./handler/index.js";
47
+ export { handleMessage } from "./handler/index.js";
74
48
 
75
49
  // ── Model provider (Backend adapter) ──────────────────────────────────
76
50
  export {
@@ -21,7 +21,7 @@ import { getChatSettings } from "../../../storage/chat-settings.js";
21
21
  import { classify } from "../../../core/errors.js";
22
22
  import { log, logError, logWarn } from "../../../util/log.js";
23
23
  import { traceMessage } from "../../../util/trace.js";
24
- import { incrementCounter } from "../../../util/metrics.js";
24
+ import { incrementCounter } from "../../../storage/metrics.js";
25
25
 
26
26
  import {
27
27
  createStreamState,
@@ -42,7 +42,7 @@
42
42
 
43
43
  import { MemorySession } from "@openai/agents";
44
44
  import type { AgentInputItem } from "@openai/agents";
45
- import { incrementCounter } from "../../util/metrics.js";
45
+ import { incrementCounter } from "../../storage/metrics.js";
46
46
 
47
47
  // ── Public types ───────────────────────────────────────────────────────────
48
48
 
@@ -2,7 +2,7 @@
2
2
  * OpenCode message handler.
3
3
  *
4
4
  * Split by responsibility (parallel to the kilo handler/ layout):
5
- * - `state` — in-flight session registry (getActiveSession)
5
+ * - `state` — in-flight session registry (activeSessions)
6
6
  * - `turn` — runOpenCodeTurn + SSE subscription + last-assistant lookup
7
7
  * - `message` — the handleMessage entry point + post-loop accounting
8
8
  *
@@ -10,4 +10,3 @@
10
10
  */
11
11
 
12
12
  export { handleMessage } from "./message.js";
13
- export { getActiveSession } from "./state.js";
@@ -1,14 +1,9 @@
1
1
  /**
2
2
  * Active-turn session registry for OpenCode.
3
3
  *
4
- * Tracks the in-flight OpenCode session id per chat so gateway actions (e.g.
5
- * abort on user `/cancel`, refresh MCP on plugin reload) can reach into a
6
- * running turn without going through chat state.
4
+ * Tracks the in-flight OpenCode session id per chat for the duration of a
5
+ * turn: `message.ts` records the id on entry and clears it in its finally
6
+ * block, and `turn.ts` matches incoming SSE events against it.
7
7
  */
8
8
 
9
9
  export const activeSessions = new Map<string, string>();
10
-
11
- /** Get the in-flight OpenCode session id for a chat, if a turn is running. */
12
- export function getActiveSession(chatId: string): string | undefined {
13
- return activeSessions.get(chatId);
14
- }
@@ -13,16 +13,9 @@ export {
13
13
  type OpenCodeModelCatalog,
14
14
  type OpenCodeModelResolution,
15
15
  type ModelButton,
16
- getOpenCodeModelCatalog,
17
- getOpenCodeModelInfo,
18
16
  getOpenCodeModelSelectionValue,
19
17
  resolveOpenCodeModelInput,
20
- getOpenCodeQuickPickModels,
21
- getOpenCodeSettingsPresentation,
22
- renderOpenCodeModelSummary,
23
- renderOpenCodeModelList,
24
18
  formatOpenCodeSelectionError,
25
- formatOpenCodeUnavailableModel,
26
19
  } from "./models/index.js";
27
20
 
28
21
  export {
@@ -30,7 +30,7 @@
30
30
 
31
31
  import type { StreamState } from "./stream-state.js";
32
32
  import { logWarn } from "../../util/log.js";
33
- import { incrementCounter } from "../../util/metrics.js";
33
+ import { incrementCounter } from "../../storage/metrics.js";
34
34
 
35
35
  /** Route the delivery decision selected. */
36
36
  export type DeliveryRoute = "tool" | "text-part" | "synthetic-error" | "empty";
@@ -25,7 +25,7 @@
25
25
  import type { QueryParams, QueryResult } from "./handler-types.js";
26
26
  import { classify, type TalonError } from "../../core/errors.js";
27
27
  import { logWarn } from "../../util/log.js";
28
- import { incrementCounter } from "../../util/metrics.js";
28
+ import { incrementCounter } from "../../storage/metrics.js";
29
29
  import { resetSession } from "../../storage/sessions.js";
30
30
  import { classifyRetry } from "./model-retry.js";
31
31
  import type { AgentEvent } from "../../core/agent-runtime/events.js";
@@ -15,7 +15,8 @@
15
15
  * rollups, flow violations) with backend dimensions.
16
16
  * - `prompt-format` — user-prompt formatter ([time] [Name] [msg_id:N]).
17
17
  * - `frontends` — config `frontend` → messaging-frontend list.
18
- * - `session-name` — first-message → short session title.
18
+ * - `extractSessionName` — re-exported from `util/session-name` so the
19
+ * backends keep one import site; the helper itself is frontend-neutral.
19
20
  * - `usage` — cache-hit % + log summarisers.
20
21
  * - `system-prompt` — per-session prompt snapshots + backend suffix
21
22
  * (assembly itself lives in `core/prompt/`).
@@ -59,7 +60,7 @@ export {
59
60
  type DeliveryToolNames,
60
61
  } from "./delivery-contract.js";
61
62
 
62
- export { extractSessionName } from "./session-name.js";
63
+ export { extractSessionName } from "../../util/session-name.js";
63
64
 
64
65
  export {
65
66
  cacheHitPercent,
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { log, logWarn } from "../../util/log.js";
21
- import { incrementCounter } from "../../util/metrics.js";
21
+ import { incrementCounter } from "../../storage/metrics.js";
22
22
 
23
23
  const interrupts = new Map<string, () => void | Promise<void>>();
24
24
 
package/src/cli/index.ts CHANGED
@@ -38,14 +38,6 @@ import { mainMenu } from "./menu.js";
38
38
 
39
39
  export * from "./context.js";
40
40
  export * from "./config.js";
41
- export { runSetup } from "./setup.js";
42
- export { showStatus, formatUptime } from "./status.js";
43
- export { viewConfig } from "./config-view.js";
44
- export { tailLogs, formatLogLine } from "./logs.js";
45
- export { runDoctor } from "./doctor.js";
46
- export { startChat } from "./chat.js";
47
- export { daemonStart, daemonStop, daemonRestart } from "./daemon.js";
48
- export { mainMenu } from "./menu.js";
49
41
 
50
42
  /** Every dispatchable subcommand — the unknown-command suggester's vocabulary. */
51
43
  const CLI_COMMANDS = [
@@ -9,12 +9,4 @@
9
9
  */
10
10
 
11
11
  export { TalonBus, bus } from "./bus.js";
12
- export type {
13
- PublishedEvent,
14
- TalonEvent,
15
- TalonEventType,
16
- TaskSettledEvent,
17
- TaskStartedEvent,
18
- TurnCompletedEvent,
19
- TurnStartedEvent,
20
- } from "./events.js";
12
+ export type { PublishedEvent, TalonEvent } from "./events.js";
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { resolve } from "node:path";
11
+ import { logWarn } from "../../util/log.js";
11
12
  import { dirs } from "../../util/paths.js";
12
13
  import { readArray, writePrivateJson } from "./persist.js";
13
14
  import {
@@ -32,6 +33,33 @@ const LOCATION_FILE = resolve(dirs.root, "mesh-locations.json");
32
33
  const HISTORY_FILE = resolve(dirs.root, "mesh-history.json");
33
34
  /** Newest fixes kept per device (~a day of 5-minute periodic reports). */
34
35
  const MAX_HISTORY_PER_DEVICE = 500;
36
+ /**
37
+ * Registry bounds. Registrations and location reports are client-supplied
38
+ * strings that the daemon persists forever and rewrites in full on every
39
+ * heartbeat — without ceilings, one misbehaving (or malicious) client can
40
+ * grow the sidecars until every mesh operation crawls. The caps are far
41
+ * above anything a real device reports: ids are UUIDs (36 chars), names are
42
+ * human device names, versions are semver-ish.
43
+ */
44
+ const MAX_ID_CHARS = 128;
45
+ const MAX_NAME_CHARS = 128;
46
+ const MAX_VERSION_CHARS = 64;
47
+ const MAX_PROVIDER_CHARS = 32;
48
+ /**
49
+ * Devices kept in the registry. Well past any plausible personal mesh, and
50
+ * with the per-device history bound it also caps the history sidecar.
51
+ * Reaching it evicts the least-recently-seen entry rather than refusing the
52
+ * newcomer: a live device re-registers every ~60s and takes its slot back,
53
+ * so the mesh stays usable even when the cap is being pushed against.
54
+ */
55
+ const MAX_DEVICES = 128;
56
+ /**
57
+ * Location entries for device ids that never registered. A fix can legitimately
58
+ * arrive just before the first registration lands, so these aren't refused —
59
+ * but they're the one path where an unknown id creates persisted state, so the
60
+ * set is kept tiny and the stalest orphan is evicted to make room.
61
+ */
62
+ const MAX_UNREGISTERED_LOCATIONS = 16;
35
63
  const PLATFORMS = new Set<DevicePlatform>([
36
64
  "android",
37
65
  "macos",
@@ -72,6 +100,12 @@ export class MeshRegistry {
72
100
  .filter((l): l is DeviceLocation => l !== null)) {
73
101
  this.appendHistory(fix);
74
102
  }
103
+ // Sidecars written before the caps existed (or edited by hand) get
104
+ // trimmed on the way in, so an oversized file is a one-boot problem
105
+ // rather than permanent state. No write here — load() only reads; the
106
+ // next mutation persists the trimmed maps.
107
+ this.enforceDeviceCap();
108
+ this.enforceOrphanLocationCap();
75
109
  }
76
110
 
77
111
  async register(
@@ -107,6 +141,7 @@ export class MeshRegistry {
107
141
  this.history.delete(id);
108
142
  evicted = true;
109
143
  }
144
+ if (this.enforceDeviceCap()) evicted = true;
110
145
  await this.persistDevices();
111
146
  if (evicted) {
112
147
  await this.persistLocations();
@@ -159,11 +194,69 @@ export class MeshRegistry {
159
194
  await this.persistDevices();
160
195
  }
161
196
  this.appendHistory(loc);
197
+ this.enforceOrphanLocationCap();
162
198
  await this.persistLocations();
163
199
  await this.persistHistory();
164
200
  return loc;
165
201
  }
166
202
 
203
+ /**
204
+ * Drop least-recently-seen devices until the registry is inside MAX_DEVICES,
205
+ * taking their location + history with them (an evicted device leaves no
206
+ * residue). Returns whether anything went, so callers know the location and
207
+ * history sidecars need rewriting too.
208
+ */
209
+ private enforceDeviceCap(): boolean {
210
+ if (this.devices.size <= MAX_DEVICES) return false;
211
+ const stalest = [...this.devices.values()]
212
+ .sort((a, b) => a.lastSeen - b.lastSeen)
213
+ .slice(0, this.devices.size - MAX_DEVICES);
214
+ for (const device of stalest) {
215
+ this.devices.delete(device.id);
216
+ this.locations.delete(device.id);
217
+ this.history.delete(device.id);
218
+ }
219
+ logWarn(
220
+ "mesh",
221
+ `Device registry at its ${MAX_DEVICES}-device cap — evicted ${stalest
222
+ .map((d) => `${d.name} [${d.id}]`)
223
+ .join(", ")} (least recently seen)`,
224
+ );
225
+ return true;
226
+ }
227
+
228
+ /**
229
+ * Bound the state an id that never registered can create: locations for
230
+ * unknown device ids past MAX_UNREGISTERED_LOCATIONS are dropped
231
+ * stalest-first, with their history. Registered devices are untouched —
232
+ * their entries are already bounded by the device cap.
233
+ */
234
+ private enforceOrphanLocationCap(): boolean {
235
+ let dropped = false;
236
+ const orphans = [...this.locations.values()]
237
+ .filter((l) => !this.devices.has(l.deviceId))
238
+ .sort((a, b) => a.ts - b.ts);
239
+ if (orphans.length > MAX_UNREGISTERED_LOCATIONS) {
240
+ for (const loc of orphans.slice(
241
+ 0,
242
+ orphans.length - MAX_UNREGISTERED_LOCATIONS,
243
+ )) {
244
+ this.locations.delete(loc.deviceId);
245
+ this.history.delete(loc.deviceId);
246
+ }
247
+ dropped = true;
248
+ }
249
+ // A fix always writes a location before its history entry, so a history
250
+ // key belonging to neither a device nor a location can only come from a
251
+ // hand-edited (or tampered) sidecar — it would otherwise be immortal.
252
+ for (const id of this.history.keys()) {
253
+ if (this.devices.has(id) || this.locations.has(id)) continue;
254
+ this.history.delete(id);
255
+ dropped = true;
256
+ }
257
+ return dropped;
258
+ }
259
+
167
260
  /** Fixes for one device since `sinceTs`, oldest first. */
168
261
  getHistory(deviceId: string, sinceTs = 0): DeviceLocation[] {
169
262
  return (this.history.get(deviceId) ?? [])
@@ -225,10 +318,16 @@ function sanitizeDevice(
225
318
  requireCore: boolean,
226
319
  ): DeviceInfo | null {
227
320
  const id = stringField(body.id);
228
- const name = stringField(body.name);
229
- const appVersion = stringField(body.appVersion);
321
+ // Display-only fields are clamped: an absurd name costs the device its
322
+ // label, not its place on the mesh. The id is the registry's identity key,
323
+ // so it is REFUSED rather than clamped — truncating it would quietly merge
324
+ // two devices (or a device and an attacker's near-miss) onto one entry.
325
+ const name = stringField(body.name)?.slice(0, MAX_NAME_CHARS);
326
+ const appVersion = stringField(body.appVersion)?.slice(0, MAX_VERSION_CHARS);
230
327
  const platform = stringField(body.platform) as DevicePlatform | undefined;
231
- if ((requireCore || id !== undefined) && !id) return null;
328
+ if ((requireCore || id !== undefined) && (!id || id.length > MAX_ID_CHARS)) {
329
+ return null;
330
+ }
232
331
  if ((requireCore || name !== undefined) && !name) return null;
233
332
  if ((requireCore || appVersion !== undefined) && !appVersion) return null;
234
333
  if (
@@ -266,6 +365,9 @@ function sanitizeLocation(
266
365
  if (!deviceId || !Number.isFinite(lat) || !Number.isFinite(lon) || !ts) {
267
366
  return null;
268
367
  }
368
+ // Same identity-key reasoning as sanitizeDevice: a fix keyed by a clamped
369
+ // id would attach to the wrong device's timeline.
370
+ if (deviceId.length > MAX_ID_CHARS) return null;
269
371
  if (lat! < -90 || lat! > 90 || lon! < -180 || lon! > 180) return null;
270
372
  return toDeviceLocation({
271
373
  deviceId,
@@ -285,7 +387,7 @@ function sanitizeLocation(
285
387
  : {}),
286
388
  ts,
287
389
  ...(stringField(body.provider)
288
- ? { provider: stringField(body.provider) }
390
+ ? { provider: stringField(body.provider)!.slice(0, MAX_PROVIDER_CHARS) }
289
391
  : {}),
290
392
  ...(numberField(body.batteryPct) !== undefined
291
393
  ? { batteryPct: numberField(body.batteryPct) }
@@ -270,20 +270,31 @@ export class MeshService {
270
270
  *
271
271
  * The result must come from the device the command was sent to: a reply
272
272
  * whose deviceId names a DIFFERENT device is dropped (a confused or
273
- * misbehaving companion must not be able to answer for its peers). A
274
- * missing deviceId is tolerated for older app builds.
273
+ * misbehaving companion must not be able to answer for its peers).
274
+ *
275
+ * An ABSENT deviceId is dropped too. It used to be tolerated "for older app
276
+ * builds", but omitting the field skipped the ownership check entirely —
277
+ * which is exactly what a spoofer would do to feed the model fabricated
278
+ * exec stdout or a fake install success. The alternative (accept it when
279
+ * only one command is pending) was rejected: it still cannot attribute the
280
+ * reply, it merely narrows the window to whenever the mesh is idle, which
281
+ * is most of the time. Nothing real is lost — `deviceId` has always been
282
+ * part of the command-result wire contract (protocol/fixtures/mesh_v1.json,
283
+ * asserted by daemon, node and companion alike) and both shipped clients
284
+ * send it. An unattributable reply now leaves the command to time out with
285
+ * the honest "did not answer" rather than resolving with someone's data.
275
286
  */
276
287
  completeCommand(body: Record<string, unknown>): boolean {
277
288
  const commandId = typeof body.commandId === "string" ? body.commandId : "";
278
289
  const pending = this.pendingCommands.get(commandId);
279
290
  if (!pending) return false;
280
291
  const from = typeof body.deviceId === "string" ? body.deviceId : "";
281
- if (from && from !== pending.deviceId) return false;
292
+ if (from !== pending.deviceId) return false;
282
293
  const { resolve } = pending;
283
294
  this.pendingCommands.delete(commandId);
284
295
  resolve({
285
296
  commandId,
286
- deviceId: typeof body.deviceId === "string" ? body.deviceId : "",
297
+ deviceId: from,
287
298
  ok: body.ok === true,
288
299
  ...(typeof body.message === "string" && body.message.trim()
289
300
  ? { message: body.message.trim().slice(0, 2_000) }
@@ -587,21 +598,25 @@ export class MeshService {
587
598
 
588
599
  // ── Streaming transfer bridge surface ─────────────────────────────────────
589
600
  // The HTTP routes on the native bridge delegate here; the token is the
590
- // entire authorization (single-use, device- and path-bound).
601
+ // entire authorization (single-use, device- and path-bound). `fromDeviceId`
602
+ // is the caller's self-declared identity — checked against the device the
603
+ // token was minted for, so a leaked token can't be redeemed by a peer.
591
604
 
592
605
  /** POST /devices/file — a device streams a pull's file body up. */
593
606
  acceptFileUpload(
594
607
  token: string,
595
608
  body: Readable,
609
+ fromDeviceId?: string,
596
610
  ): Promise<{ ok: true; bytes: number } | { ok: false; error: string }> {
597
- return this.transfers.acceptUpload(token, body);
611
+ return this.transfers.acceptUpload(token, body, fromDeviceId);
598
612
  }
599
613
 
600
614
  /** GET /devices/file — a device asks for a push's file body. */
601
615
  openFileDownload(
602
616
  token: string,
617
+ fromDeviceId?: string,
603
618
  ): Promise<{ path: string; size: number } | null> {
604
- return this.transfers.openDownload(token);
619
+ return this.transfers.openDownload(token, fromDeviceId);
605
620
  }
606
621
 
607
622
  /** Streaming is per-command capability — old app builds fall back. */
@@ -26,6 +26,8 @@
26
26
  * The registry never trusts the HTTP caller with a path — the token IS the
27
27
  * authorization, and it only reaches the device over the authed mesh
28
28
  * channel (the HTTP routes additionally sit behind the bridge bearer token).
29
+ * The device binding is enforced on the HTTP leg too: a caller that names
30
+ * itself must name the device the token was minted for (see take()).
29
31
  */
30
32
 
31
33
  import { randomBytes } from "node:crypto";
@@ -115,12 +117,15 @@ export class TransferStore {
115
117
  * Bridge route: a device is streaming a pull's file body up. Writes to a
116
118
  * temp file and renames into place, so a dropped connection can't leave a
117
119
  * half-written destination. Resolves the pull's `done` promise.
120
+ *
121
+ * `fromDeviceId` is the device the HTTP caller says it is (see take()).
118
122
  */
119
123
  async acceptUpload(
120
124
  token: string,
121
125
  body: Readable,
126
+ fromDeviceId?: string,
122
127
  ): Promise<{ ok: true; bytes: number } | { ok: false; error: string }> {
123
- const t = this.take(token, "pull");
128
+ const t = this.take(token, "pull", fromDeviceId);
124
129
  if (!t)
125
130
  return { ok: false, error: "Unknown or already-used transfer token." };
126
131
  const tmp = `${t.localPath}.part-${randomBytes(4).toString("hex")}`;
@@ -148,8 +153,9 @@ export class TransferStore {
148
153
  */
149
154
  async openDownload(
150
155
  token: string,
156
+ fromDeviceId?: string,
151
157
  ): Promise<{ path: string; size: number } | null> {
152
- const t = this.take(token, "push");
158
+ const t = this.take(token, "push", fromDeviceId);
153
159
  if (!t) return null;
154
160
  try {
155
161
  const s = await stat(t.localPath);
@@ -164,11 +170,34 @@ export class TransferStore {
164
170
  }
165
171
  }
166
172
 
167
- /** Validate + latch a token for its HTTP leg. */
168
- private take(token: string, direction: Transfer["direction"]) {
173
+ /**
174
+ * Validate + latch a token for its HTTP leg.
175
+ *
176
+ * `fromDeviceId` is the device the caller claims to be (the `deviceId`
177
+ * query param on /devices/file). A token is minted for exactly one device,
178
+ * so a claim naming a DIFFERENT device is refused — a token that leaked to
179
+ * another mesh member can't be redeemed under that member's own identity.
180
+ * The refusal happens before the single-use latch, so a wrong claim can't
181
+ * burn the real device's token either.
182
+ *
183
+ * A caller that claims nothing is still served. Requiring the claim would
184
+ * break the transfer that ships the client build able to make it:
185
+ * `update_device`/`update_node` push the new binary over `download_file`,
186
+ * so a daemon upgraded ahead of its fleet would refuse the very transfer
187
+ * that updates the fleet. Since the token only ever reaches the target
188
+ * device (over the addressed command channel) and is single-use with a
189
+ * 10-minute TTL, tolerating an unclaimed leg costs little; once the fleet
190
+ * advertises the claim, dropping this line makes the binding mandatory.
191
+ */
192
+ private take(
193
+ token: string,
194
+ direction: Transfer["direction"],
195
+ fromDeviceId?: string,
196
+ ) {
169
197
  this.sweep();
170
198
  const t = this.transfers.get(token);
171
199
  if (!t || t.direction !== direction || t.consumed) return null;
200
+ if (fromDeviceId && fromDeviceId !== t.deviceId) return null;
172
201
  if (Date.now() - t.createdAt > TOKEN_TTL_MS) {
173
202
  this.cancel(token);
174
203
  return null;
@@ -13,15 +13,6 @@
13
13
  * including the `_deps` test seam.
14
14
  */
15
15
 
16
- export type {
17
- PluginPathEntry,
18
- PluginMcpEntry,
19
- PluginEntry,
20
- TalonPlugin,
21
- LoadedPlugin,
22
- McpServerConfig,
23
- } from "./types.js";
24
- export { isPathPlugin, isMcpPlugin } from "./types.js";
25
16
  export { _deps } from "./registry.js";
26
17
  export {
27
18
  loadPlugins,
@@ -12,19 +12,4 @@
12
12
  * `backend/shared/delivery-contract.ts`.
13
13
  */
14
14
 
15
- export {
16
- assembleSystemPrompt,
17
- joinSystemPromptParts,
18
- MEMORY_INJECT_MAX_CHARS,
19
- type AssemblePromptInputs,
20
- type SystemPromptParts,
21
- } from "./assemble.js";
22
-
23
- export {
24
- loadSystemTemplate,
25
- renderTemplate,
26
- clearTemplateCache,
27
- type TemplateVars,
28
- } from "./templates.js";
29
-
30
- export { renderWorkspaceListing } from "./workspace-listing.js";
15
+ export { loadSystemTemplate } from "./templates.js";
@@ -35,27 +35,8 @@ import { createProcMount } from "./mounts/proc.js";
35
35
  import { Vfs } from "./vfs.js";
36
36
 
37
37
  export { Vfs } from "./vfs.js";
38
- export { createFileMount } from "./mounts/files.js";
39
- export { createProcMount, type ProcMountDeps } from "./mounts/proc.js";
40
- export { createPluginsMount, type PluginView } from "./mounts/plugins.js";
41
- export { syncNamespaceDir, type NsDirSync } from "./nsdir.js";
42
- export {
43
- isNamespaceFsMounted,
44
- mountNamespaceFs,
45
- namespaceFsStatus,
46
- serveNamespaceRequest,
47
- unmountNamespaceFs,
48
- type FuseStatus,
49
- type MountOptions,
50
- } from "./fusefs.js";
51
- export type {
52
- VfsErrorCode,
53
- VfsMount,
54
- VfsNodeKind,
55
- VfsResult,
56
- VfsStat,
57
- } from "./types.js";
58
- export { VFS_MAX_READ_BYTES, vfsError, vfsOk } from "./types.js";
38
+
39
+ export { mountNamespaceFs, unmountNamespaceFs } from "./fusefs.js";
59
40
 
60
41
  function pluginViews(): PluginView[] {
61
42
  const modules: PluginView[] = registry.all.map(({ plugin, path }) => ({