talon-agent 5.28.0 → 5.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/package.json +3 -2
  2. package/prompts/system/agent-brief.md +9 -6
  3. package/src/backend/claude-sdk/constants.ts +22 -0
  4. package/src/backend/claude-sdk/models/discovery.ts +3 -0
  5. package/src/backend/claude-sdk/one-shot.ts +3 -1
  6. package/src/backend/claude-sdk/options.ts +7 -1
  7. package/src/core/agents/index.ts +2 -0
  8. package/src/core/agents/prompt.ts +70 -2
  9. package/src/core/agents/registry.ts +47 -5
  10. package/src/core/agents/runner.ts +227 -39
  11. package/src/core/agents/trail.ts +141 -0
  12. package/src/core/agents/types.ts +43 -5
  13. package/src/core/agents/watchdog.ts +70 -0
  14. package/src/core/background/isolated-agent.ts +6 -2
  15. package/src/core/backup/plan.ts +4 -0
  16. package/src/core/config/index.ts +17 -4
  17. package/src/core/engine/gateway-actions/agents/control.ts +9 -2
  18. package/src/core/engine/gateway-actions/agents/preflight.ts +17 -1
  19. package/src/core/engine/gateway-actions/agents/report.ts +81 -24
  20. package/src/core/engine/gateway-actions/index.ts +3 -0
  21. package/src/core/mcp-hub/guest-scope.ts +3 -1
  22. package/src/core/mesh/devices/service.ts +7 -0
  23. package/src/core/mesh/links/bridge-links.ts +20 -0
  24. package/src/core/secrets/actions.ts +18 -0
  25. package/src/core/secrets/drop.ts +176 -0
  26. package/src/core/secrets/index.ts +11 -0
  27. package/src/core/secrets/service.ts +248 -0
  28. package/src/core/secrets/store.ts +100 -0
  29. package/src/core/tools/index.ts +2 -0
  30. package/src/core/tools/ops/agents.ts +30 -12
  31. package/src/core/tools/ops/secrets.ts +36 -0
  32. package/src/core/tools/types.ts +2 -1
  33. package/src/frontend/discord/commands/definitions.ts +21 -0
  34. package/src/frontend/discord/commands/router.ts +3 -0
  35. package/src/frontend/discord/commands/secret.ts +26 -0
  36. package/src/frontend/discord/handlers/messages.ts +6 -4
  37. package/src/frontend/native/bridge/routes/host.ts +10 -0
  38. package/src/frontend/native/bridge/routes/pre-auth.ts +82 -1
  39. package/src/frontend/native/bridge/routes/table.ts +5 -0
  40. package/src/frontend/native/bridge/server.ts +32 -4
  41. package/src/frontend/native/commands/definitions.ts +6 -0
  42. package/src/frontend/native/commands/index.ts +12 -0
  43. package/src/frontend/native/surface/handlers.ts +9 -0
  44. package/src/frontend/telegram/actions/chat-info.ts +22 -5
  45. package/src/frontend/telegram/commands/definitions.ts +4 -0
  46. package/src/frontend/telegram/commands/index.ts +3 -0
  47. package/src/frontend/telegram/commands/secret.ts +24 -0
  48. package/src/frontend/telegram/handlers/messages.ts +7 -5
  49. package/src/frontend/whatsapp/commands.ts +16 -1
  50. package/src/frontend/whatsapp/messages/media-store.ts +4 -2
  51. package/src/storage/media-index.ts +43 -11
  52. package/src/storage/repositories/media-index-repo.ts +12 -1
  53. package/src/storage/sql/media-index.sql +4 -0
  54. package/src/storage/sql/statements.generated.ts +2 -0
  55. package/src/util/log.ts +1 -0
  56. package/src/util/paths.ts +6 -0
@@ -87,6 +87,21 @@ export interface AgentRecord {
87
87
  readonly children: readonly string[];
88
88
  /** Messages waiting to be drained by `check_inbox`. */
89
89
  readonly inboxDepth: number;
90
+ /**
91
+ * What the run had been doing when it settled — set on every settlement
92
+ * the runner makes, shown to the parent when the run did not end `done`.
93
+ */
94
+ readonly trail?: AgentTrail;
95
+ }
96
+
97
+ /** A settled run's last interim messages, progress notes and changed files. */
98
+ export interface AgentTrail {
99
+ /** Last `message_parent` notes, oldest first. */
100
+ readonly messages: readonly string[];
101
+ /** Last assistant texts, oldest first. */
102
+ readonly notes: readonly string[];
103
+ /** Files it wrote or edited (best-effort, from the run log). */
104
+ readonly files: readonly string[];
90
105
  }
91
106
 
92
107
  /** What `spawnAgent` is asked for. */
@@ -94,12 +109,23 @@ export interface AgentSpawnSpec {
94
109
  readonly brief: string;
95
110
  readonly label: string;
96
111
  readonly parent: AgentParent;
97
- /** Defaults to the parent's backend. */
112
+ /**
113
+ * Unset: a child of another agent inherits its parent's backend (and,
114
+ * with no model either, its model); a top-level spawn starts from the
115
+ * chat's backend and may be routed.
116
+ */
98
117
  readonly backendId?: string;
99
- /** Defaults to the resolved backend's own default model. */
118
+ /**
119
+ * Defaults to the parent agent's model when the backend is inherited from
120
+ * one, else the resolved backend's own default model.
121
+ */
100
122
  readonly model?: string;
101
123
  readonly reasoningEffort?: ReasoningEffortLevel;
102
- /** Hard wall-clock cap. Defaults to `agents.defaultTimeoutMs`. */
124
+ /**
125
+ * Hard wall-clock cap. Unset = `agents.defaultTimeoutMs`, and with that
126
+ * unset too, no cap at all — the no-progress watchdog is what ends a run
127
+ * that has gone quiet.
128
+ */
103
129
  readonly timeoutMs?: number;
104
130
  /**
105
131
  * Append the pre-flight lane instruction (run `npm run preflight` before
@@ -130,6 +156,18 @@ export interface AgentCaps {
130
156
  readonly maxConcurrent: number;
131
157
  /** Deepest `depth` an agent may have — 2 means chat → A → B. */
132
158
  readonly maxDepth: number;
133
- /** Default hard timeout for one run. */
134
- readonly defaultTimeoutMs: number;
159
+ /** Hard timeout for a spawn that sets none. Unset = no hard timeout. */
160
+ readonly defaultTimeoutMs?: number;
161
+ /** Ceiling on any run's hard timeout, requested or not. Unset = none. */
162
+ readonly maxTimeoutMs?: number;
163
+ /**
164
+ * No-progress watchdog step N: ping the agent after N ms of silence, warn
165
+ * its parent after 2N, kill it after 3N. 0 disables the watchdog.
166
+ */
167
+ readonly stallTimeoutMs: number;
168
+ /**
169
+ * Backends a sub-agent may run on. Unset or empty = any backend with a
170
+ * background capability. Enforced by `spawnAgent` on the final choice.
171
+ */
172
+ readonly allowedBackends?: readonly string[];
135
173
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * No-progress watchdog for a sub-agent run.
3
+ *
4
+ * Replaces the old "everyone gets a 15-minute hard cap" safety net with one
5
+ * that only fires on an agent that has actually gone quiet. With
6
+ * `stallMs = N`, measured from the last sign of life (a run-log line or an
7
+ * assistant text — see `RunTrail`):
8
+ *
9
+ * - after N → the agent is pinged (a message in its own mailbox);
10
+ * - after 2N → its parent is told, so it can intervene or kill early;
11
+ * - after 3N → the run is aborted and settles as `timed_out` ("stalled").
12
+ *
13
+ * Any activity resets the ladder. The watchdog owns one unref'd interval and
14
+ * nothing else; every action is a callback, so it is trivially testable.
15
+ */
16
+
17
+ export interface WatchdogActions {
18
+ /** Last sign of life, epoch ms. */
19
+ readonly lastActivityAt: () => number;
20
+ readonly pingAgent: (idleMs: number) => void;
21
+ readonly warnParent: (idleMs: number, killInMs: number) => void;
22
+ readonly kill: (idleMs: number) => void;
23
+ }
24
+
25
+ export interface WatchdogHandle {
26
+ stop(): void;
27
+ /** Evaluate now — exposed for tests; the interval calls it too. */
28
+ check(now?: number): void;
29
+ }
30
+
31
+ /** Start a watchdog. `stallMs <= 0` returns an inert handle. */
32
+ export function startWatchdog(
33
+ stallMs: number,
34
+ actions: WatchdogActions,
35
+ checkEveryMs: number = Math.max(1_000, Math.min(60_000, stallMs / 4)),
36
+ ): WatchdogHandle {
37
+ if (!(stallMs > 0)) return { stop: () => {}, check: () => {} };
38
+ // 0 = quiet, 1 = pinged, 2 = parent warned, 3 = killed.
39
+ let stage = 0;
40
+ let stageAnchor = actions.lastActivityAt();
41
+
42
+ const check = (now: number = Date.now()): void => {
43
+ if (stage >= 3) return;
44
+ const last = actions.lastActivityAt();
45
+ if (last !== stageAnchor) {
46
+ // Signs of life since the last escalation — start over.
47
+ stageAnchor = last;
48
+ stage = 0;
49
+ }
50
+ const idle = now - last;
51
+ if (stage < 1 && idle >= stallMs) {
52
+ stage = 1;
53
+ actions.pingAgent(idle);
54
+ }
55
+ if (stage < 2 && idle >= 2 * stallMs) {
56
+ stage = 2;
57
+ actions.warnParent(idle, Math.max(0, 3 * stallMs - idle));
58
+ }
59
+ if (stage < 3 && idle >= 3 * stallMs) {
60
+ stage = 3;
61
+ actions.kill(idle);
62
+ stop();
63
+ }
64
+ };
65
+
66
+ const timer = setInterval(() => check(), checkEveryMs);
67
+ timer.unref();
68
+ const stop = (): void => clearInterval(timer);
69
+ return { stop, check };
70
+ }
@@ -37,8 +37,11 @@ export interface IsolatedRunOptions {
37
37
  readonly background: BackgroundRunner;
38
38
  /** Fully-built one-shot params (must carry an `abortController`). */
39
39
  readonly params: OneShotAgentParams;
40
- /** Hard timeout before the run is aborted. */
41
- readonly timeoutMs: number;
40
+ /**
41
+ * Hard timeout before the run is aborted. Unset = none: the run is only
42
+ * ended by its own completion or by an abort of `params.abortController`.
43
+ */
44
+ readonly timeoutMs?: number;
42
45
  /** Bounded grace for the backend to honour the abort (default 30s). */
43
46
  readonly abortGraceMs?: number;
44
47
  /**
@@ -91,6 +94,7 @@ export async function runIsolatedAgent(
91
94
  const agentPromise = background.runOneShotAgent(params);
92
95
 
93
96
  const timeoutPromise = new Promise<never>((_, reject) => {
97
+ if (timeoutMs === undefined) return;
94
98
  timer = setTimeout(() => {
95
99
  timeoutError = new IsolatedAgentTimeoutError(timeoutMs);
96
100
  try {
@@ -75,6 +75,10 @@ export const HOME_INCLUDES: readonly string[] = [
75
75
  "prompts",
76
76
  "data",
77
77
  "keys",
78
+ // Operator secrets (~/.talon/secrets, the secret drop's folder). Held to
79
+ // the same rule as keys/ and workspace/secrets: in the snapshot, so
80
+ // encrypt backups (docs/backup-security.md).
81
+ "secrets",
78
82
  "google",
79
83
  "plugins",
80
84
  "mesh-devices.json",
@@ -642,18 +642,31 @@ const configSchema = z.object({
642
642
  * - `maxDepth` — how far delegation may nest. 0 = chats only, 2 (the
643
643
  * default) = chat → agent → agent.
644
644
  * - `defaultTimeoutMs` — hard wall-clock cap when a spawn doesn't pass
645
- * its own. Per-spawn values are clamped to [30s, 60min].
645
+ * its own. Unset (the default) = no hard cap: a run ends when it is
646
+ * done, killed, or stalled (see `stallTimeoutMs`).
647
+ * - `maxTimeoutMs` — optional global ceiling on every run's hard cap,
648
+ * including spawns that pass none. Unset = no ceiling. Per-spawn
649
+ * values are floored at 30s.
650
+ * - `stallTimeoutMs` — the no-progress watchdog step N (default 15
651
+ * min): after N with no tool call or output the agent is pinged,
652
+ * after 2N its parent is warned, after 3N it is killed (`timed_out`).
653
+ * 0 disables it.
654
+ * - `allowedBackends` — optional allowlist of backend ids sub-agents
655
+ * may run on. Unset = any backend with a background capability. A
656
+ * spawn that names (or inherits) a backend outside it is refused.
646
657
  */
647
658
  agents: z
648
659
  .object({
649
660
  maxConcurrent: z.number().int().min(1).max(64).default(6),
650
661
  maxDepth: z.number().int().min(0).max(5).default(2),
651
- defaultTimeoutMs: z
662
+ defaultTimeoutMs: z.number().int().min(30_000).optional(),
663
+ maxTimeoutMs: z.number().int().min(30_000).optional(),
664
+ stallTimeoutMs: z
652
665
  .number()
653
666
  .int()
654
- .min(30_000)
655
- .max(3_600_000)
667
+ .min(0)
656
668
  .default(15 * 60 * 1000),
669
+ allowedBackends: z.array(z.string().min(1)).optional(),
657
670
  })
658
671
  .optional(),
659
672
  /**
@@ -16,6 +16,7 @@ import {
16
16
  agentIdFromContextLabel,
17
17
  agentRegistry,
18
18
  clampTimeout,
19
+ describeTimeout,
19
20
  deliverToAgent,
20
21
  getAgentCaps,
21
22
  killAgent,
@@ -210,7 +211,8 @@ export const agentControlHandlers: SharedActionHandlers = {
210
211
  preflight: parsed.preflight,
211
212
  });
212
213
  if (!outcome.ok) return { ok: false, error: outcome.error };
213
- const timeoutS = Math.round(clampTimeout(parsed.timeoutMs) / 1000);
214
+ const timeout = describeTimeout(clampTimeout(parsed.timeoutMs));
215
+ const stall = getAgentCaps().stallTimeoutMs;
214
216
  log("gateway", `spawn_agent: "${parsed.label}" [${outcome.agentId}]`);
215
217
  return {
216
218
  ok: true,
@@ -218,7 +220,12 @@ export const agentControlHandlers: SharedActionHandlers = {
218
220
  `Spawned agent "${parsed.label}" (id: ${outcome.agentId})\n` +
219
221
  `Backend: ${outcome.backendId}/${outcome.model}` +
220
222
  `${outcome.routing ? ` (routed: ${outcome.routing})` : ""}\n` +
221
- `Timeout: ${timeoutS}s\n` +
223
+ `Timeout: ${timeout}` +
224
+ (stall > 0
225
+ ? ` (watchdog: pinged after ${describeTimeout(stall)} quiet, ` +
226
+ `killed after ${describeTimeout(3 * stall)})`
227
+ : "") +
228
+ `\n` +
222
229
  (parsed.preflight ? `Pre-flight lane: on\n` : "") +
223
230
  `It runs in the background. You will be woken with its report — ` +
224
231
  `carry on with what you were doing.`,
@@ -28,6 +28,10 @@ const PREFLIGHT_TIMEOUT_MS = 600_000;
28
28
  const KILL_GRACE_MS = 5_000;
29
29
  /** Lines of each failing step's log to hand back. */
30
30
  const FAIL_TAIL_LINES = 30;
31
+ /** How to give a checkout dependencies without a fresh ~1.2 GB `npm ci`. */
32
+ const LINK_HINT =
33
+ "No node_modules in this checkout: run `node scripts/worktree.mjs link` " +
34
+ "in it (hardlinks a shared install, ~0 extra disk), then retry.";
31
35
 
32
36
  interface PreflightStep {
33
37
  readonly name: string;
@@ -64,6 +68,15 @@ function repoRoot(dir: string): Promise<string> {
64
68
  });
65
69
  }
66
70
 
71
+ async function exists(path: string): Promise<boolean> {
72
+ try {
73
+ await stat(path);
74
+ return true;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
67
80
  async function isFile(path: string): Promise<boolean> {
68
81
  try {
69
82
  return (await stat(path)).isFile();
@@ -193,11 +206,14 @@ async function runPreflight(
193
206
  }
194
207
  const summary = await readSummary(root);
195
208
  if (!summary) {
209
+ const hint = (await exists(join(root, "node_modules")))
210
+ ? ""
211
+ : `\n${LINK_HINT}`;
196
212
  return {
197
213
  ok: false,
198
214
  error:
199
215
  `Pre-flight exited ${run.code} without writing .preflight/last.json. ` +
200
- `Output:\n${run.output.slice(-2_000)}`,
216
+ `Output:\n${run.output.slice(-2_000)}${hint}`,
201
217
  };
202
218
  }
203
219
  // A red lane is a successful tool call with a red verdict — the model is
@@ -18,12 +18,40 @@ import {
18
18
  deliverMessage,
19
19
  deliverToAgent,
20
20
  describeParent,
21
+ recordInterimMessage,
21
22
  } from "../../../agents/index.js";
22
23
  import { logError } from "../../../../util/log.js";
23
24
  import type { ActionResult } from "../../../types.js";
24
25
  import type { AgentRecord } from "../../../agents/index.js";
25
26
  import type { SharedActionHandlers } from "../types.js";
26
27
 
28
+ /** How `other` relates to `self` within their shared tree. */
29
+ function relation(self: AgentRecord, other: AgentRecord): string {
30
+ if (self.parent.kind === "agent" && self.parent.agentId === other.id) {
31
+ return "your parent";
32
+ }
33
+ if (other.parent.kind === "agent" && other.parent.agentId === self.id) {
34
+ return "your child";
35
+ }
36
+ const sameParent =
37
+ self.parent.kind === other.parent.kind &&
38
+ (self.parent.kind === "chat"
39
+ ? other.parent.kind === "chat" &&
40
+ other.parent.chatId === self.parent.chatId
41
+ : other.parent.kind === "agent" &&
42
+ other.parent.agentId === self.parent.agentId);
43
+ return sameParent ? "sibling" : `in your tree, depth ${other.depth}`;
44
+ }
45
+
46
+ /** One agent in a `list_peers` listing. */
47
+ function renderPeer(peer: AgentRecord, rel?: string): string {
48
+ return (
49
+ `- ${peer.label} [${peer.state}]${rel ? ` — ${rel}` : ""}\n` +
50
+ ` ID: ${peer.id}\n` +
51
+ ` Working on: ${peer.brief.slice(0, 160).replace(/\s+/g, " ")}…`
52
+ );
53
+ }
54
+
27
55
  function notAnAgent(tool: string): ActionResult {
28
56
  return {
29
57
  ok: false,
@@ -72,6 +100,8 @@ export const agentReportHandlers: SharedActionHandlers = {
72
100
  if (!record) return notAnAgent("message_parent");
73
101
  const text = String(body.text ?? "").trim();
74
102
  if (!text) return { ok: false, error: "Missing text" };
103
+ // Kept for the parent's wake-up should the run be cut short.
104
+ recordInterimMessage(record.id, text);
75
105
  // Fire-and-forget: waking a chat runs a whole turn, and this tool call
76
106
  // must not block for the length of the parent's reply.
77
107
  void deliverMessage(record, text).catch((err: unknown) =>
@@ -87,64 +117,91 @@ export const agentReportHandlers: SharedActionHandlers = {
87
117
  };
88
118
  },
89
119
 
90
- list_peers: (_body, _chatId, _backend, chatKey) => {
120
+ list_peers: (body, _chatId, _backend, chatKey) => {
91
121
  const record = callingAgent(chatKey);
92
122
  if (!record) return notAnAgent("list_peers");
123
+ const scope = body.scope === undefined ? "siblings" : String(body.scope);
124
+ if (scope !== "siblings" && scope !== "tree") {
125
+ return {
126
+ ok: false,
127
+ error: `Unknown scope "${scope}". Use "siblings" or "tree".`,
128
+ };
129
+ }
130
+ if (scope === "tree") {
131
+ const tree = agentRegistry.treeOf(record.id);
132
+ if (tree.length === 0) {
133
+ return {
134
+ ok: true,
135
+ text: "No other live agents in your tree. Report to your parent as usual.",
136
+ };
137
+ }
138
+ return {
139
+ ok: true,
140
+ text:
141
+ `${tree.length} other live agent(s) in your tree (everything ` +
142
+ `working for the same chat). Any of them can be reached with ` +
143
+ `message_peer:\n\n` +
144
+ tree
145
+ .map((other) => renderPeer(other, relation(record, other)))
146
+ .join("\n"),
147
+ };
148
+ }
93
149
  const peers = agentRegistry.peersOf(record.id);
94
150
  if (peers.length === 0) {
95
151
  return {
96
152
  ok: true,
97
153
  text:
98
154
  "No peers — you are the only agent your parent has running. " +
99
- "Report to your parent as usual.",
155
+ 'Report to your parent as usual (list_peers with scope "tree" ' +
156
+ "shows the rest of your agent tree).",
100
157
  };
101
158
  }
102
- const rendered = peers
103
- .map(
104
- (peer) =>
105
- `- ${peer.label} [${peer.state}]\n ID: ${peer.id}\n Working on: ${peer.brief.slice(0, 160).replace(/\s+/g, " ")}…`,
106
- )
107
- .join("\n");
108
159
  return {
109
160
  ok: true,
110
- text: `${peers.length} peer(s) running alongside you:\n\n${rendered}`,
161
+ text:
162
+ `${peers.length} peer(s) running alongside you:\n\n` +
163
+ peers.map((peer) => renderPeer(peer)).join("\n"),
111
164
  };
112
165
  },
113
166
 
114
167
  message_peer: (body, _chatId, _backend, chatKey) => {
115
168
  const record = callingAgent(chatKey);
116
169
  if (!record) return notAnAgent("message_peer");
117
- const id = String(body.agent_id ?? "").trim();
118
- if (!id) return { ok: false, error: "Missing agent_id" };
170
+ const target = String(body.agent_id ?? body.label ?? "").trim();
171
+ if (!target) return { ok: false, error: "Missing agent_id (or label)" };
119
172
  const text = String(body.text ?? "").trim();
120
173
  if (!text) return { ok: false, error: "Missing text" };
121
- // Peers only. An agent may address a sibling, never an arbitrary id:
122
- // resolving through peersOf is what stops one swarm reaching into
123
- // another, and it means a wrong id fails closed rather than delivering.
124
- const peer = agentRegistry
125
- .peersOf(record.id)
126
- .find((candidate) => candidate.id === id);
127
- if (!peer) {
174
+ // Resolved through the caller's own tree: an agent can reach any live
175
+ // agent working for the same chat, never one working for another — a
176
+ // wrong or foreign id fails closed rather than delivering.
177
+ const found = agentRegistry.findInTree(record.id, target);
178
+ if (!found.ok) {
128
179
  return {
129
180
  ok: false,
130
181
  error:
131
- `No peer "${id}". You can only message agents spawned by the same ` +
132
- `parent as you — call list_peers to see them.`,
182
+ found.candidates.length > 1
183
+ ? `"${target}" is ambiguous — ${found.candidates.length} live ` +
184
+ `agents in your tree have that label ` +
185
+ `(${found.candidates.map((c) => c.id).join(", ")}). Use the id.`
186
+ : `No live agent "${target}" in your tree. You can message any ` +
187
+ `live agent working for the same chat as you — call ` +
188
+ `list_peers with scope "tree" to see them.`,
133
189
  };
134
190
  }
135
- if (!deliverToAgent(record.id, id, text)) {
191
+ const peer = found.record;
192
+ if (!deliverToAgent(record.id, peer.id, text)) {
136
193
  return {
137
194
  ok: false,
138
195
  error:
139
- `Could not deliver to "${peer.label}" (${id}): it has already ` +
196
+ `Could not deliver to "${peer.label}" (${peer.id}): it has already ` +
140
197
  `settled, or its inbox is full.`,
141
198
  };
142
199
  }
143
200
  return {
144
201
  ok: true,
145
202
  text:
146
- `Queued for peer "${peer.label}" (${id}). It reads its inbox at its ` +
147
- `own milestones, so this is not an interrupt.`,
203
+ `Queued for "${peer.label}" (${peer.id}, ${relation(record, peer)}). ` +
204
+ `It reads its inbox at its own milestones, so this is not an interrupt.`,
148
205
  };
149
206
  },
150
207
 
@@ -22,6 +22,7 @@
22
22
  * - `mesh` — companion device mesh (presence + location)
23
23
  * - `cross-send` — explicit-target sends through any enabled frontend
24
24
  * - `backup` — snapshots and checkpoints (chat-free; no restore)
25
+ * - `secrets` — secret-drop links (core/secrets; needs the chat)
25
26
  */
26
27
 
27
28
  import type { ActionResult } from "../../types.js";
@@ -49,6 +50,7 @@ import {
49
50
  } from "./whatsapp-account.js";
50
51
  import { nativeActionRefusal, nativeHandlers } from "./native/index.js";
51
52
  import { backupChatFreeActions, backupHandlers } from "./backup/index.js";
53
+ import { secretHandlers } from "../../secrets/actions.js";
52
54
 
53
55
  // Null-prototype so a request `action` of "toString" / "constructor" / etc.
54
56
  // can't resolve an inherited Object.prototype method — `handlers[action]` only
@@ -70,6 +72,7 @@ const handlers: SharedActionHandlers = Object.assign(Object.create(null), {
70
72
  ...whatsappAccountHandlers,
71
73
  ...nativeHandlers,
72
74
  ...backupHandlers,
75
+ ...secretHandlers,
73
76
  });
74
77
 
75
78
  /**
@@ -83,12 +83,14 @@ export const GUEST_TOOL_ALLOWLIST: ReadonlySet<string> = new Set([
83
83
 
84
84
  /**
85
85
  * Tools whose output is a live credential (an install command or pairing
86
- * link embedding a bridge bearer token). Operator-only, and their output is delivered
86
+ * link embedding a bridge bearer token, or a secret-drop link). Operator-only, and their output is delivered
87
87
  * to the operator's private chat, never into a shared one.
88
88
  */
89
89
  export const OPERATOR_PRIVATE_OUTPUT_TOOLS: ReadonlySet<string> = new Set([
90
90
  "make_node_install_link",
91
91
  "make_companion_pair_link",
92
+ // A secret-drop link writes into ~/.talon/secrets for whoever opens it.
93
+ "request_secret",
92
94
  ]);
93
95
 
94
96
  /** Parameters that name another chat. A guest may only name its own. */
@@ -863,6 +863,13 @@ export class MeshService {
863
863
  return this.links.makeCompanionPairLink(label, bridgeUrl);
864
864
  }
865
865
 
866
+ /** The bridge base URL a bridge-served link should use (secret drop). */
867
+ bridgeBaseUrl(
868
+ explicit?: unknown,
869
+ ): ReturnType<BridgeLinks["bridgeBaseUrlFor"]> {
870
+ return this.links.bridgeBaseUrlFor(explicit);
871
+ }
872
+
866
873
  /** `make_companion_pair_link`: the pairing link as a tool result. */
867
874
  makeCompanionPairLinkText(
868
875
  label?: unknown,
@@ -371,6 +371,26 @@ export class BridgeLinks {
371
371
  };
372
372
  }
373
373
 
374
+ /**
375
+ * The base URL a device should open a bridge-served link at (secret
376
+ * drop): the same answer the pairing and install links use.
377
+ */
378
+ bridgeBaseUrlFor(
379
+ explicit?: unknown,
380
+ ): { ok: true; url: string } | { ok: false; text: string } {
381
+ const info = this.bridgeInfo;
382
+ if (!info) {
383
+ return {
384
+ ok: false,
385
+ text: "The native bridge isn't running, so there is nothing to serve the link. Enable the native frontend first.",
386
+ };
387
+ }
388
+ const base = this.bridgeBaseUrl(info, explicit);
389
+ return typeof base === "string"
390
+ ? { ok: true, url: base }
391
+ : { ok: false, text: base.error };
392
+ }
393
+
374
394
  /** GET /node/install — serve a grant's installer script (single-use). */
375
395
  /**
376
396
  * An auto grant is pinned here first, from the os/arch its one-liner
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Gateway action for `request_secret`. Needs the chat (the receipt goes
3
+ * back to it), so it is not chat-free.
4
+ */
5
+
6
+ import type { SharedActionHandlers } from "../engine/gateway-actions/types.js";
7
+ import { requestSecretDrop } from "./service.js";
8
+
9
+ export const secretHandlers: SharedActionHandlers = {
10
+ request_secret: (body, _chatId, _backend, chatKey) => {
11
+ const minted = requestSecretDrop({
12
+ name: body.name,
13
+ purpose: body.purpose,
14
+ chatKey,
15
+ });
16
+ return { ok: minted.ok, text: minted.text };
17
+ },
18
+ };