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.
Files changed (138) hide show
  1. package/package.json +1 -1
  2. package/src/backend/claude-sdk/constants.ts +1 -6
  3. package/src/backend/claude-sdk/index.ts +2 -11
  4. package/src/backend/claude-sdk/models/parsing.ts +2 -2
  5. package/src/backend/claude-sdk/plan-usage.ts +0 -5
  6. package/src/backend/claude-sdk/stream.ts +1 -1
  7. package/src/backend/codex/auth.ts +2 -2
  8. package/src/backend/codex/constants.ts +0 -21
  9. package/src/backend/codex/handler/index.ts +0 -1
  10. package/src/backend/codex/mcp-config.ts +2 -2
  11. package/src/backend/codex/plan-usage.ts +0 -5
  12. package/src/backend/codex/state.ts +1 -1
  13. package/src/backend/codex/token-usage.ts +1 -1
  14. package/src/backend/kilo/factory.ts +39 -119
  15. package/src/backend/kilo/handler/index.ts +1 -11
  16. package/src/backend/kilo/handler/message.ts +20 -317
  17. package/src/backend/kilo/index.ts +0 -6
  18. package/src/backend/kilo/server.ts +55 -292
  19. package/src/backend/kilo/sessions.ts +5 -86
  20. package/src/backend/openai-agents/handler/index.ts +0 -1
  21. package/src/backend/openai-agents/handler/state.ts +0 -5
  22. package/src/backend/openai-agents/init.ts +1 -18
  23. package/src/backend/opencode/factory.ts +41 -118
  24. package/src/backend/opencode/handler/index.ts +1 -11
  25. package/src/backend/opencode/handler/message.ts +20 -321
  26. package/src/backend/opencode/index.ts +0 -4
  27. package/src/backend/opencode/server.ts +56 -225
  28. package/src/backend/opencode/sessions.ts +5 -86
  29. package/src/backend/remote-server/chat-turn.ts +359 -0
  30. package/src/backend/remote-server/client.ts +1 -1
  31. package/src/backend/remote-server/events.ts +1 -1
  32. package/src/backend/remote-server/factory.ts +150 -0
  33. package/src/backend/remote-server/index.ts +15 -43
  34. package/src/backend/remote-server/mcp.ts +1 -4
  35. package/src/backend/remote-server/model-catalog/catalog.ts +1 -1
  36. package/src/backend/remote-server/model-catalog/index.ts +1 -15
  37. package/src/backend/remote-server/model-catalog/provider.ts +0 -1
  38. package/src/backend/remote-server/server-bindings.ts +232 -0
  39. package/src/backend/remote-server/sse-stream.ts +1 -3
  40. package/src/backend/remote-server/turn-timeout.ts +1 -1
  41. package/src/backend/{kilo/handler → remote-server}/turn.ts +103 -67
  42. package/src/backend/shared/index.ts +7 -43
  43. package/src/cli/logs.ts +1 -1
  44. package/src/cli/status.ts +1 -1
  45. package/src/core/agent-runtime/backend-registry.ts +1 -1
  46. package/src/core/agent-runtime/capabilities.ts +1 -1
  47. package/src/core/agent-runtime/events.ts +1 -1
  48. package/src/core/background/heartbeat/agent.ts +1 -1
  49. package/src/core/background/heartbeat/index.ts +1 -2
  50. package/src/core/background/isolated-agent.ts +1 -1
  51. package/src/core/background/triggers/exit.ts +1 -1
  52. package/src/core/background/triggers/index.ts +1 -1
  53. package/src/core/bus/events.ts +2 -2
  54. package/src/core/doctor.ts +1 -1
  55. package/src/core/engine/backend-controller/index.ts +1 -3
  56. package/src/core/engine/backend-controller/rebind.ts +2 -2
  57. package/src/core/engine/dispatcher.ts +1 -15
  58. package/src/core/engine/gateway-actions/index.ts +0 -3
  59. package/src/core/engine/gateway-actions/types.ts +1 -1
  60. package/src/core/frontend-runtime/capabilities.ts +0 -2
  61. package/src/core/frontend-runtime/index.ts +1 -6
  62. package/src/core/frontend-runtime/routing.ts +0 -1
  63. package/src/core/mcp-hub/children.ts +0 -5
  64. package/src/core/mesh/bridge-links.ts +319 -0
  65. package/src/core/mesh/common.ts +30 -0
  66. package/src/core/mesh/device-files.ts +710 -0
  67. package/src/core/mesh/index.ts +1 -18
  68. package/src/core/mesh/service.ts +72 -894
  69. package/src/core/models/active-model.ts +0 -19
  70. package/src/core/plugin/index.ts +0 -1
  71. package/src/core/plugin/registry.ts +1 -1
  72. package/src/core/prompt/templates.ts +0 -5
  73. package/src/core/scripting/lua-runner.ts +1 -1
  74. package/src/core/scripts/runner.ts +1 -1
  75. package/src/core/soul/critic.ts +1 -1
  76. package/src/core/soul/embedder.ts +2 -2
  77. package/src/core/soul/governance.ts +1 -1
  78. package/src/core/soul/hdc.ts +1 -1
  79. package/src/core/soul/reflex.ts +2 -2
  80. package/src/core/soul/retrieve.ts +1 -1
  81. package/src/core/soul/signals.ts +4 -4
  82. package/src/core/soul/types.ts +1 -1
  83. package/src/core/tasks/index.ts +0 -4
  84. package/src/core/tasks/types.ts +1 -1
  85. package/src/core/tools/index.ts +1 -6
  86. package/src/core/tools/mcp-env.ts +3 -33
  87. package/src/core/types.ts +1 -1
  88. package/src/core/update/self-update.ts +1 -1
  89. package/src/core/vfs/fusefs.ts +1 -1
  90. package/src/core/vfs/types.ts +1 -1
  91. package/src/core/weaver/index.ts +1 -1
  92. package/src/core/weaver/typing-loop.ts +1 -1
  93. package/src/frontend/discord/actions/types.ts +1 -1
  94. package/src/frontend/discord/commands/definitions.ts +1 -1
  95. package/src/frontend/discord/handlers/access.ts +7 -61
  96. package/src/frontend/discord/handlers/index.ts +2 -15
  97. package/src/frontend/discord/handlers/registry.ts +0 -6
  98. package/src/frontend/discord/handlers/state.ts +15 -8
  99. package/src/frontend/native/protocol.ts +1 -4
  100. package/src/frontend/native/server.ts +392 -324
  101. package/src/frontend/shared/access.ts +152 -0
  102. package/src/frontend/shared/status-context.ts +1 -1
  103. package/src/frontend/telegram/actions/types.ts +1 -1
  104. package/src/frontend/telegram/commands/index.ts +1 -1
  105. package/src/frontend/telegram/commands/state.ts +1 -1
  106. package/src/frontend/telegram/handlers/access.ts +8 -25
  107. package/src/frontend/telegram/handlers/index.ts +0 -1
  108. package/src/frontend/telegram/handlers/queue.ts +3 -35
  109. package/src/frontend/telegram/handlers/state.ts +15 -8
  110. package/src/frontend/telegram/helpers/menu.ts +1 -1
  111. package/src/frontend/terminal/commands.ts +1 -4
  112. package/src/frontend/terminal/renderer.ts +1 -1
  113. package/src/frontend/whatsapp/actions/chat-info.ts +0 -3
  114. package/src/frontend/whatsapp/actions/messaging.ts +1 -7
  115. package/src/frontend/whatsapp/actions/shared.ts +1 -1
  116. package/src/frontend/whatsapp/actions/types.ts +1 -1
  117. package/src/frontend/whatsapp/formatting.ts +1 -1
  118. package/src/frontend/whatsapp/media-store.ts +1 -1
  119. package/src/frontend/whatsapp/pins.ts +0 -5
  120. package/src/native/fusefs.ts +3 -3
  121. package/src/native/scheduler-core.ts +1 -1
  122. package/src/native/warden.ts +2 -2
  123. package/src/plugins/mempalace/provision.ts +1 -5
  124. package/src/plugins/playwright/provision.ts +1 -1
  125. package/src/storage/db.ts +1 -1
  126. package/src/storage/goal-store.ts +1 -5
  127. package/src/storage/metrics.ts +0 -5
  128. package/src/storage/repositories/scripts-repo.ts +0 -5
  129. package/src/storage/scheduled-store.ts +1 -5
  130. package/src/storage/script-store.ts +5 -13
  131. package/src/storage/sessions.ts +0 -3
  132. package/src/storage/skill-store.ts +3 -3
  133. package/src/util/exec-output.ts +1 -1
  134. package/src/util/mcp-launcher.ts +3 -0
  135. package/src/backend/kilo/events.ts +0 -60
  136. package/src/backend/kilo/handler/state.ts +0 -9
  137. package/src/backend/opencode/handler/state.ts +0 -9
  138. 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
- export function formatUptime(seconds: number): string {
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
- export interface BackendInstance {
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
- export interface ToolRefreshResult {
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
- export type AgentErrorKind =
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
- export function renderGoalsBlock(): { text: string; count: number } {
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, renderGoalsBlock } from "./agent.js";
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
- export const DEFAULT_ABORT_GRACE_MS = 30 * 1000;
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
- export function killChild(id: string, child: ChildProcess): void {
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, type TriggerDeps } from "./state.js";
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";
@@ -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
- export interface TurnStartedEvent {
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
- export interface TurnCompletedEvent {
44
+ interface TurnCompletedEvent {
45
45
  readonly type: "turn.completed";
46
46
  readonly chatId: string;
47
47
  readonly source: string;
@@ -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
- export type DoctorStatus = "ok" | "warn" | "fail" | "info";
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, chatHolder } from "./holders.js";
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
- export async function rebindHolder(
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
- export async function releaseHolder(holder: BackendHolder): Promise<void> {
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.
@@ -100,6 +100,3 @@ export async function handleChatFreeAction(
100
100
  if (!handler) return null;
101
101
  return handler(body, 0);
102
102
  }
103
-
104
- export { chatFreeActions };
105
- export type { SharedActionHandler, SharedActionHandlers } from "./types.js";
@@ -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
- export type SharedActionHandler = (
12
+ type SharedActionHandler = (
13
13
  body: Record<string, unknown>,
14
14
  chatId: number,
15
15
  backend?: Backend | null,
@@ -58,5 +58,3 @@ export type FrontendCreate = (
58
58
 
59
59
  /** A fully-registered frontend: identity plus the factory. */
60
60
  export type FrontendFactory = FrontendDescriptor & { create: FrontendCreate };
61
-
62
- export type { FrontendDescriptor } from "./registry.js";
@@ -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,
@@ -12,5 +12,4 @@ export {
12
12
  getFrontendDescriptor,
13
13
  resolveFrontendIdAmong,
14
14
  resolveOwnerFrontendId,
15
- type FrontendDescriptor,
16
15
  } from "./registry.js";
@@ -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
+ }