talon-agent 3.32.1 → 3.33.1
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 +1 -1
- package/src/backend/claude-sdk/constants.ts +1 -6
- package/src/backend/claude-sdk/index.ts +2 -11
- package/src/backend/claude-sdk/models/parsing.ts +2 -2
- package/src/backend/claude-sdk/plan-usage.ts +0 -5
- package/src/backend/claude-sdk/stream.ts +1 -1
- package/src/backend/codex/auth.ts +2 -2
- package/src/backend/codex/constants.ts +0 -21
- package/src/backend/codex/handler/index.ts +0 -1
- package/src/backend/codex/mcp-config.ts +2 -2
- package/src/backend/codex/plan-usage.ts +0 -5
- package/src/backend/codex/state.ts +1 -1
- package/src/backend/codex/token-usage.ts +1 -1
- package/src/backend/kilo/factory.ts +39 -119
- package/src/backend/kilo/handler/index.ts +1 -11
- package/src/backend/kilo/handler/message.ts +20 -317
- package/src/backend/kilo/index.ts +0 -6
- package/src/backend/kilo/server.ts +55 -292
- package/src/backend/kilo/sessions.ts +5 -86
- package/src/backend/openai-agents/handler/index.ts +0 -1
- package/src/backend/openai-agents/handler/state.ts +0 -5
- package/src/backend/openai-agents/init.ts +1 -18
- package/src/backend/opencode/factory.ts +41 -118
- package/src/backend/opencode/handler/index.ts +1 -11
- package/src/backend/opencode/handler/message.ts +20 -321
- package/src/backend/opencode/index.ts +0 -4
- package/src/backend/opencode/server.ts +56 -225
- package/src/backend/opencode/sessions.ts +5 -86
- package/src/backend/remote-server/chat-turn.ts +359 -0
- package/src/backend/remote-server/client.ts +1 -1
- package/src/backend/remote-server/events.ts +1 -1
- package/src/backend/remote-server/factory.ts +150 -0
- package/src/backend/remote-server/index.ts +15 -43
- package/src/backend/remote-server/mcp.ts +1 -4
- package/src/backend/remote-server/model-catalog/catalog.ts +1 -1
- package/src/backend/remote-server/model-catalog/index.ts +1 -15
- package/src/backend/remote-server/model-catalog/provider.ts +0 -1
- package/src/backend/remote-server/server-bindings.ts +232 -0
- package/src/backend/remote-server/sse-stream.ts +1 -3
- package/src/backend/remote-server/turn-timeout.ts +1 -1
- package/src/backend/{kilo/handler → remote-server}/turn.ts +103 -67
- package/src/backend/shared/index.ts +7 -43
- package/src/cli/logs.ts +1 -1
- package/src/cli/status.ts +1 -1
- package/src/core/agent-runtime/backend-registry.ts +1 -1
- package/src/core/agent-runtime/capabilities.ts +1 -1
- package/src/core/agent-runtime/events.ts +1 -1
- package/src/core/background/heartbeat/agent.ts +1 -1
- package/src/core/background/heartbeat/index.ts +1 -2
- package/src/core/background/isolated-agent.ts +1 -1
- package/src/core/background/triggers/exit.ts +1 -1
- package/src/core/background/triggers/index.ts +1 -1
- package/src/core/bus/events.ts +2 -2
- package/src/core/doctor.ts +1 -1
- package/src/core/engine/backend-controller/index.ts +1 -3
- package/src/core/engine/backend-controller/rebind.ts +2 -2
- package/src/core/engine/dispatcher.ts +1 -15
- package/src/core/engine/gateway-actions/index.ts +0 -3
- package/src/core/engine/gateway-actions/types.ts +1 -1
- package/src/core/frontend-runtime/capabilities.ts +0 -2
- package/src/core/frontend-runtime/index.ts +1 -6
- package/src/core/frontend-runtime/routing.ts +0 -1
- package/src/core/mcp-hub/children.ts +0 -5
- package/src/core/mesh/bridge-links.ts +319 -0
- package/src/core/mesh/common.ts +30 -0
- package/src/core/mesh/device-files.ts +710 -0
- package/src/core/mesh/index.ts +1 -18
- package/src/core/mesh/service.ts +72 -894
- package/src/core/models/active-model.ts +0 -19
- package/src/core/plugin/index.ts +0 -1
- package/src/core/plugin/registry.ts +1 -1
- package/src/core/prompt/templates.ts +0 -5
- package/src/core/scripting/lua-runner.ts +1 -1
- package/src/core/scripts/runner.ts +1 -1
- package/src/core/soul/critic.ts +1 -1
- package/src/core/soul/embedder.ts +2 -2
- package/src/core/soul/governance.ts +1 -1
- package/src/core/soul/hdc.ts +1 -1
- package/src/core/soul/reflex.ts +2 -2
- package/src/core/soul/retrieve.ts +1 -1
- package/src/core/soul/signals.ts +4 -4
- package/src/core/soul/types.ts +1 -1
- package/src/core/tasks/index.ts +0 -4
- package/src/core/tasks/types.ts +1 -1
- package/src/core/tools/index.ts +1 -6
- package/src/core/tools/mcp-env.ts +3 -33
- package/src/core/types.ts +1 -1
- package/src/core/update/self-update.ts +1 -1
- package/src/core/vfs/fusefs.ts +1 -1
- package/src/core/vfs/types.ts +1 -1
- package/src/core/weaver/index.ts +1 -1
- package/src/core/weaver/typing-loop.ts +1 -1
- package/src/frontend/discord/actions/types.ts +1 -1
- package/src/frontend/discord/commands/definitions.ts +1 -1
- package/src/frontend/discord/handlers/access.ts +7 -61
- package/src/frontend/discord/handlers/index.ts +2 -15
- package/src/frontend/discord/handlers/registry.ts +0 -6
- package/src/frontend/discord/handlers/state.ts +15 -8
- package/src/frontend/native/protocol.ts +1 -4
- package/src/frontend/native/server.ts +392 -324
- package/src/frontend/shared/access.ts +152 -0
- package/src/frontend/shared/status-context.ts +1 -1
- package/src/frontend/telegram/actions/types.ts +1 -1
- package/src/frontend/telegram/commands/index.ts +1 -1
- package/src/frontend/telegram/commands/state.ts +1 -1
- package/src/frontend/telegram/handlers/access.ts +8 -25
- package/src/frontend/telegram/handlers/index.ts +0 -1
- package/src/frontend/telegram/handlers/queue.ts +3 -35
- package/src/frontend/telegram/handlers/state.ts +15 -8
- package/src/frontend/telegram/helpers/menu.ts +1 -1
- package/src/frontend/terminal/commands.ts +1 -4
- package/src/frontend/terminal/renderer.ts +1 -1
- package/src/frontend/whatsapp/actions/chat-info.ts +0 -3
- package/src/frontend/whatsapp/actions/messaging.ts +1 -7
- package/src/frontend/whatsapp/actions/shared.ts +1 -1
- package/src/frontend/whatsapp/actions/types.ts +1 -1
- package/src/frontend/whatsapp/formatting.ts +1 -1
- package/src/frontend/whatsapp/media-store.ts +1 -1
- package/src/frontend/whatsapp/pins.ts +0 -5
- package/src/native/fusefs.ts +3 -3
- package/src/native/scheduler-core.ts +1 -1
- package/src/native/warden.ts +2 -2
- package/src/plugins/mempalace/provision.ts +1 -5
- package/src/plugins/playwright/provision.ts +1 -1
- package/src/storage/db.ts +1 -1
- package/src/storage/goal-store.ts +1 -5
- package/src/storage/metrics.ts +0 -5
- package/src/storage/repositories/scripts-repo.ts +0 -5
- package/src/storage/scheduled-store.ts +1 -5
- package/src/storage/script-store.ts +5 -13
- package/src/storage/sessions.ts +0 -3
- package/src/storage/skill-store.ts +3 -3
- package/src/util/exec-output.ts +1 -1
- package/src/util/mcp-launcher.ts +3 -0
- package/src/backend/kilo/events.ts +0 -60
- package/src/backend/kilo/handler/state.ts +0 -9
- package/src/backend/opencode/handler/state.ts +0 -9
- package/src/backend/opencode/handler/turn.ts +0 -314
package/src/cli/status.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { findRunningInstance } from "../core/daemon/discovery.js";
|
|
|
9
9
|
import { printBanner, loadConfig } from "./config.js";
|
|
10
10
|
import { CONFIG_FILE } from "./context.js";
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
function formatUptime(seconds: number): string {
|
|
13
13
|
if (seconds < 60) return `${seconds}s`;
|
|
14
14
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
15
15
|
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
|
@@ -58,7 +58,7 @@ export interface BackendInitContext {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/** Result returned by a backend factory's `init` step. */
|
|
61
|
-
|
|
61
|
+
interface BackendInstance {
|
|
62
62
|
/** The fully-wired `Backend` the dispatcher will route to. */
|
|
63
63
|
backend: Backend;
|
|
64
64
|
/**
|
|
@@ -188,7 +188,7 @@ export interface SessionBackend {
|
|
|
188
188
|
* the live registry. Returns the diff so the dispatcher can log
|
|
189
189
|
* what changed.
|
|
190
190
|
*/
|
|
191
|
-
|
|
191
|
+
interface ToolRefreshResult {
|
|
192
192
|
added: string[];
|
|
193
193
|
removed: string[];
|
|
194
194
|
errors: Record<string, string>;
|
|
@@ -81,7 +81,7 @@ export interface AgentResult {
|
|
|
81
81
|
* `core/errors.ts` already produces — keep the values stable so
|
|
82
82
|
* downstream code can pattern-match deterministically.
|
|
83
83
|
*/
|
|
84
|
-
|
|
84
|
+
type AgentErrorKind =
|
|
85
85
|
| "context_overflow"
|
|
86
86
|
| "rate_limit"
|
|
87
87
|
| "overload"
|
|
@@ -83,7 +83,7 @@ export function buildHeartbeatSystemPrompt(): string {
|
|
|
83
83
|
* the heartbeat is a global agent, so it sees every chat's open goals (with
|
|
84
84
|
* chat ids for routing updates back). Exported for tests.
|
|
85
85
|
*/
|
|
86
|
-
|
|
86
|
+
function renderGoalsBlock(): { text: string; count: number } {
|
|
87
87
|
let text = "(no open goals)";
|
|
88
88
|
let count = 0;
|
|
89
89
|
try {
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
* Re-exports the same public surface the old single-file module exposed.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
export type { HeartbeatState } from "./state.js";
|
|
17
16
|
export {
|
|
18
17
|
initHeartbeat,
|
|
19
18
|
startHeartbeatTimer,
|
|
@@ -22,4 +21,4 @@ export {
|
|
|
22
21
|
forceHeartbeat,
|
|
23
22
|
getHeartbeatStatus,
|
|
24
23
|
} from "./scheduler.js";
|
|
25
|
-
export { buildHeartbeatSystemPrompt
|
|
24
|
+
export { buildHeartbeatSystemPrompt } from "./agent.js";
|
|
@@ -19,7 +19,7 @@ import type { OneShotAgentParams, OneShotUsage } from "../types.js";
|
|
|
19
19
|
import { logWarn, logError } from "../../util/log.js";
|
|
20
20
|
|
|
21
21
|
/** Default bounded grace after an abort before giving up on the backend. */
|
|
22
|
-
|
|
22
|
+
const DEFAULT_ABORT_GRACE_MS = 30 * 1000;
|
|
23
23
|
|
|
24
24
|
export interface IsolatedRunOptions {
|
|
25
25
|
readonly background: BackgroundRunner;
|
|
@@ -79,7 +79,7 @@ export async function shutdownTriggers(): Promise<void> {
|
|
|
79
79
|
await new Promise((r) => setTimeout(r, 250));
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
function killChild(id: string, child: ChildProcess): void {
|
|
83
83
|
try {
|
|
84
84
|
child.kill("SIGTERM");
|
|
85
85
|
} catch {
|
|
@@ -19,7 +19,7 @@ import { commandForLanguage } from "./command.js";
|
|
|
19
19
|
import { handleStdoutLine } from "./output.js";
|
|
20
20
|
import { handleTimeout, finalizeExit } from "./exit.js";
|
|
21
21
|
|
|
22
|
-
export { initTriggers, getRunningCount
|
|
22
|
+
export { initTriggers, getRunningCount } from "./state.js";
|
|
23
23
|
export { commandForLanguage } from "./command.js";
|
|
24
24
|
export { spawnTrigger } from "./spawn.js";
|
|
25
25
|
export { cancelTrigger, shutdownTriggers } from "./exit.js";
|
package/src/core/bus/events.ts
CHANGED
|
@@ -32,7 +32,7 @@ export interface TaskSettledEvent {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/** A chat turn bound its warp and is about to run on the backend. */
|
|
35
|
-
|
|
35
|
+
interface TurnStartedEvent {
|
|
36
36
|
readonly type: "turn.started";
|
|
37
37
|
readonly chatId: string;
|
|
38
38
|
readonly source: string;
|
|
@@ -41,7 +41,7 @@ export interface TurnStartedEvent {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/** A chat turn finished successfully (refusals and failures never emit this). */
|
|
44
|
-
|
|
44
|
+
interface TurnCompletedEvent {
|
|
45
45
|
readonly type: "turn.completed";
|
|
46
46
|
readonly chatId: string;
|
|
47
47
|
readonly source: string;
|
package/src/core/doctor.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { execFileSync } from "node:child_process";
|
|
|
17
17
|
import { NATIVE_MODULES } from "../native/registry.js";
|
|
18
18
|
import { dirs } from "../util/paths.js";
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
type DoctorStatus = "ok" | "warn" | "fail" | "info";
|
|
21
21
|
|
|
22
22
|
export interface DoctorCheck {
|
|
23
23
|
label: string;
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
export * from "./types.js";
|
|
31
|
-
export { roleHolder
|
|
31
|
+
export { roleHolder } from "./holders.js";
|
|
32
32
|
export {
|
|
33
33
|
initBackendPool,
|
|
34
34
|
hasBackendPool,
|
|
@@ -48,8 +48,6 @@ export {
|
|
|
48
48
|
clearBackendChangeListenersForTest,
|
|
49
49
|
} from "./pool.js";
|
|
50
50
|
export {
|
|
51
|
-
rebindHolder,
|
|
52
|
-
releaseHolder,
|
|
53
51
|
rebindRole,
|
|
54
52
|
rebindChat,
|
|
55
53
|
releaseChat,
|
|
@@ -36,7 +36,7 @@ import type { BackendHolder, BackendRole, RebindResult } from "./types.js";
|
|
|
36
36
|
*
|
|
37
37
|
* Use `rebindRole` / `rebindChat` for typed wrappers.
|
|
38
38
|
*/
|
|
39
|
-
|
|
39
|
+
async function rebindHolder(
|
|
40
40
|
holder: BackendHolder,
|
|
41
41
|
newId: string,
|
|
42
42
|
config: TalonConfig,
|
|
@@ -113,7 +113,7 @@ export async function rebindHolder(
|
|
|
113
113
|
* No-op when the holder isn't bound — useful for "clear my override"
|
|
114
114
|
* flows that don't want to care whether the override was ever set.
|
|
115
115
|
*/
|
|
116
|
-
|
|
116
|
+
async function releaseHolder(holder: BackendHolder): Promise<void> {
|
|
117
117
|
const id = bindings.get(holder);
|
|
118
118
|
if (!id) return;
|
|
119
119
|
bindings.delete(holder);
|
|
@@ -11,12 +11,7 @@
|
|
|
11
11
|
import type { ExecuteParams, ExecuteResult } from "../types.js";
|
|
12
12
|
import { log } from "../../util/log.js";
|
|
13
13
|
import { taskTable, type KillOutcome } from "../tasks/index.js";
|
|
14
|
-
import {
|
|
15
|
-
initWeaver,
|
|
16
|
-
type Weaver,
|
|
17
|
-
type WeaverDeps,
|
|
18
|
-
type ThreadSnapshot,
|
|
19
|
-
} from "../weaver/index.js";
|
|
14
|
+
import { initWeaver, type Weaver, type WeaverDeps } from "../weaver/index.js";
|
|
20
15
|
|
|
21
16
|
// ── Dependencies (injected at startup) ──────────────────────────────────────
|
|
22
17
|
|
|
@@ -49,15 +44,6 @@ export function getActiveCount(): number {
|
|
|
49
44
|
return weaver?.getActiveCount() ?? 0;
|
|
50
45
|
}
|
|
51
46
|
|
|
52
|
-
/**
|
|
53
|
-
* A live view of every Thread the Loom holds — model/backend warp, in-flight
|
|
54
|
-
* count, and context state per chat. The hub's observability surface for
|
|
55
|
-
* `/status`, drift detection, and remote frontends. Empty before init.
|
|
56
|
-
*/
|
|
57
|
-
export function snapshot(): ThreadSnapshot[] {
|
|
58
|
-
return weaver?.snapshot() ?? [];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
47
|
/**
|
|
62
48
|
* Stop only this chat's active turn. A later message already waiting in the
|
|
63
49
|
* per-chat FIFO is intentionally left alone and runs after the stop settles.
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { ActionResult } from "../../types.js";
|
|
10
10
|
import type { Backend } from "../../agent-runtime/capabilities.js";
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
type SharedActionHandler = (
|
|
13
13
|
body: Record<string, unknown>,
|
|
14
14
|
chatId: number,
|
|
15
15
|
backend?: Backend | null,
|
|
@@ -9,12 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import "./builtins.js";
|
|
11
11
|
|
|
12
|
-
export type {
|
|
13
|
-
Frontend,
|
|
14
|
-
FrontendCreate,
|
|
15
|
-
FrontendDescriptor,
|
|
16
|
-
FrontendFactory,
|
|
17
|
-
} from "./capabilities.js";
|
|
12
|
+
export type { Frontend } from "./capabilities.js";
|
|
18
13
|
export {
|
|
19
14
|
getFrontendDescriptor,
|
|
20
15
|
hasFrontend,
|
|
@@ -82,11 +82,6 @@ function failureBackoffMs(count: number): number {
|
|
|
82
82
|
);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
/** Test seam: forget recorded spawn failures. */
|
|
86
|
-
export function resetSpawnFailures(): void {
|
|
87
|
-
spawnFailures.clear();
|
|
88
|
-
}
|
|
89
|
-
|
|
90
85
|
/** Idle TTL for hub children; tunable for tests / tight deployments. */
|
|
91
86
|
function idleTtlMs(): number {
|
|
92
87
|
const raw = Number(process.env.TALON_MCP_HUB_IDLE_MS);
|
|
@@ -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
|
+
}
|