talon-agent 3.8.1 → 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.
- package/README.md +1 -1
- package/package.json +1 -2
- package/src/backend/claude-sdk/handler.ts +1 -1
- package/src/backend/codex/handler/message.ts +1 -1
- package/src/backend/kilo/handler/index.ts +1 -2
- package/src/backend/kilo/handler/state.ts +3 -8
- package/src/backend/kilo/index.ts +2 -28
- package/src/backend/openai-agents/handler/message.ts +1 -1
- package/src/backend/openai-agents/session.ts +1 -1
- package/src/backend/opencode/handler/index.ts +1 -2
- package/src/backend/opencode/handler/state.ts +3 -8
- package/src/backend/opencode/index.ts +0 -7
- package/src/backend/shared/delivery.ts +1 -1
- package/src/backend/shared/handle-retry.ts +1 -1
- package/src/backend/shared/index.ts +3 -2
- package/src/backend/shared/turn-interrupt.ts +1 -1
- package/src/cli/index.ts +0 -8
- package/src/core/bus/index.ts +1 -9
- package/src/core/mesh/registry.ts +106 -4
- package/src/core/mesh/service.ts +22 -7
- package/src/core/mesh/transfers.ts +33 -4
- package/src/core/plugin/index.ts +0 -9
- package/src/core/prompt/index.ts +1 -16
- package/src/core/vfs/index.ts +2 -21
- package/src/core/weaver/index.ts +5 -9
- package/src/frontend/discord/actions/messaging.ts +73 -13
- package/src/frontend/discord/actions/shared.ts +21 -3
- package/src/frontend/discord/commands/admin.ts +1 -1
- package/src/frontend/discord/errors.ts +16 -5
- package/src/frontend/discord/formatting.ts +19 -4
- package/src/frontend/native/index.ts +22 -8
- package/src/frontend/native/protocol.ts +6 -0
- package/src/frontend/native/server.ts +171 -12
- package/src/frontend/shared/format.ts +6 -0
- package/src/frontend/telegram/actions/messaging.ts +120 -24
- package/src/frontend/telegram/callbacks/metrics.ts +1 -1
- package/src/frontend/telegram/commands/admin.ts +1 -1
- package/src/frontend/telegram/formatting.ts +64 -19
- package/src/frontend/terminal/commands.ts +46 -8
- package/src/frontend/terminal/input.ts +110 -12
- package/src/frontend/terminal/renderer.ts +2 -0
- package/src/{util → storage}/metrics.ts +1 -1
- package/src/storage/scheduled-store.ts +6 -3
- package/src/util/config.ts +8 -0
- package/src/util/log.ts +1 -0
- package/src/backend/codex/index.ts +0 -33
- package/src/backend/openai-agents/index.ts +0 -48
- package/src/cli/plugin-entries.ts +0 -104
- package/src/core/agent-runtime/index.ts +0 -62
- package/src/core/background/index.ts +0 -21
- package/src/core/engine/index.ts +0 -20
- package/src/core/models/index.ts +0 -17
- package/src/core/soul/index.ts +0 -140
- package/src/core/tools/mcp-server.ts +0 -111
- /package/src/{backend/shared → util}/session-name.ts +0 -0
package/README.md
CHANGED
|
@@ -170,7 +170,7 @@ The `desktop` frontend turns the daemon into a **client bridge** — a versioned
|
|
|
170
170
|
- **Remote (mobile/LAN):** point the app at `host:port` + token; the bridge requires `Authorization: Bearer …` (or `?token=` on the SSE stream) whenever a token is set.
|
|
171
171
|
- **Encryption:** off-loopback binds serve **HTTPS by default** with a persistent self-signed certificate (`~/.talon/keys/`); the companion pins its SHA-256 fingerprint on first connect and refuses any change afterwards. The daemon logs the fingerprint at startup and `/health` advertises it. Opt out (or in, on loopback) with `"tls": false` / `true` in the `desktop` section.
|
|
172
172
|
|
|
173
|
-
The app provides multi-chat history, live streaming with reasoning + tool-call visibility, per-chat model/effort/
|
|
173
|
+
The app provides multi-chat history, live streaming with reasoning + tool-call visibility, per-chat model/effort/reset, and **settings sync** — read and change the daemon's own config (default model, display name, timezone, pulse/heartbeat/dream) and restart it. See [apps/companion/README.md](apps/companion/README.md).
|
|
174
174
|
|
|
175
175
|
---
|
|
176
176
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "3.
|
|
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 "../../
|
|
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 "../../../
|
|
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 (
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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 "../../../
|
|
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 "../../
|
|
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 (
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 "../../
|
|
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 "../../
|
|
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
|
-
* - `
|
|
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 "
|
|
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 "../../
|
|
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 = [
|
package/src/core/bus/index.ts
CHANGED
|
@@ -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
|
-
|
|
229
|
-
|
|
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)
|
|
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) }
|
package/src/core/mesh/service.ts
CHANGED
|
@@ -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).
|
|
274
|
-
*
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
168
|
-
|
|
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;
|
package/src/core/plugin/index.ts
CHANGED
|
@@ -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,
|
package/src/core/prompt/index.ts
CHANGED
|
@@ -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";
|