talon-agent 5.29.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 (48) hide show
  1. package/package.json +2 -1
  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 +169 -31
  11. package/src/core/agents/trail.ts +141 -0
  12. package/src/core/agents/types.ts +29 -3
  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 +13 -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 +26 -8
  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/native/bridge/routes/host.ts +10 -0
  37. package/src/frontend/native/bridge/routes/pre-auth.ts +82 -1
  38. package/src/frontend/native/bridge/routes/table.ts +5 -0
  39. package/src/frontend/native/bridge/server.ts +32 -4
  40. package/src/frontend/native/commands/definitions.ts +6 -0
  41. package/src/frontend/native/commands/index.ts +12 -0
  42. package/src/frontend/native/surface/handlers.ts +9 -0
  43. package/src/frontend/telegram/commands/definitions.ts +4 -0
  44. package/src/frontend/telegram/commands/index.ts +3 -0
  45. package/src/frontend/telegram/commands/secret.ts +24 -0
  46. package/src/frontend/whatsapp/commands.ts +16 -1
  47. package/src/util/log.ts +1 -0
  48. package/src/util/paths.ts +6 -0
@@ -642,7 +642,15 @@ 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.
646
654
  * - `allowedBackends` — optional allowlist of backend ids sub-agents
647
655
  * may run on. Unset = any backend with a background capability. A
648
656
  * spawn that names (or inherits) a backend outside it is refused.
@@ -651,11 +659,12 @@ const configSchema = z.object({
651
659
  .object({
652
660
  maxConcurrent: z.number().int().min(1).max(64).default(6),
653
661
  maxDepth: z.number().int().min(0).max(5).default(2),
654
- defaultTimeoutMs: z
662
+ defaultTimeoutMs: z.number().int().min(30_000).optional(),
663
+ maxTimeoutMs: z.number().int().min(30_000).optional(),
664
+ stallTimeoutMs: z
655
665
  .number()
656
666
  .int()
657
- .min(30_000)
658
- .max(3_600_000)
667
+ .min(0)
659
668
  .default(15 * 60 * 1000),
660
669
  allowedBackends: z.array(z.string().min(1)).optional(),
661
670
  })
@@ -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
+ };
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Secret drop: single-use, expiring grants that let the operator paste a
3
+ * password into a form served by the native bridge instead of into a chat.
4
+ *
5
+ * Same trust model as companion pairing (mesh/links/companion-pairing.ts):
6
+ * the bridge serves the grant pre-auth, and the random 192-bit grant in the
7
+ * query is the whole authorization. Differences:
8
+ *
9
+ * - GET only shows the form and does NOT spend the grant. Chat apps
10
+ * fetch links to build previews, and a preview must not burn the link
11
+ * before the human opens it.
12
+ * - POST spends it, once, whether or not the write then succeeds — a
13
+ * failed write asks for a fresh link rather than leaving a live one
14
+ * open to retries.
15
+ *
16
+ * The grant records which chat asked, so the receipt ("stored ✓ as <name>")
17
+ * goes back there. The value itself never leaves `consume` except into the
18
+ * file.
19
+ */
20
+
21
+ import { randomBytes } from "node:crypto";
22
+
23
+ /** Unused grants die after this long. */
24
+ export const SECRET_GRANT_TTL_MS = 15 * 60 * 1000;
25
+
26
+ export type SecretDropGrant = {
27
+ token: string;
28
+ /** Validated secret name — the file under ~/.talon/secrets. */
29
+ name: string;
30
+ /** Shown on the form so the operator knows what is being asked for. */
31
+ purpose?: string;
32
+ /** Chat that asked, for the receipt. */
33
+ chatKey: string;
34
+ /** Frontend that chat lives on. */
35
+ frontend: string;
36
+ createdAt: number;
37
+ };
38
+
39
+ export class SecretDropStore {
40
+ private readonly grants = new Map<string, SecretDropGrant>();
41
+
42
+ constructor(
43
+ private readonly ttlMs = SECRET_GRANT_TTL_MS,
44
+ private readonly now: () => number = Date.now,
45
+ ) {}
46
+
47
+ create(grant: Omit<SecretDropGrant, "token" | "createdAt">): SecretDropGrant {
48
+ this.sweep();
49
+ const full: SecretDropGrant = {
50
+ ...grant,
51
+ ...(grant.purpose ? { purpose: cleanPurpose(grant.purpose) } : {}),
52
+ token: randomBytes(24).toString("base64url"),
53
+ createdAt: this.now(),
54
+ };
55
+ this.grants.set(full.token, full);
56
+ return full;
57
+ }
58
+
59
+ /** A live grant, without spending it (the GET form). */
60
+ peek(token: string): SecretDropGrant | null {
61
+ this.sweep();
62
+ return this.grants.get(token) ?? null;
63
+ }
64
+
65
+ /** Spend a live grant (the POST). Null when unknown, expired or used. */
66
+ consume(token: string): SecretDropGrant | null {
67
+ this.sweep();
68
+ const grant = this.grants.get(token);
69
+ if (!grant) return null;
70
+ this.grants.delete(token);
71
+ return grant;
72
+ }
73
+
74
+ private sweep(): void {
75
+ const cutoff = this.now() - this.ttlMs;
76
+ for (const [token, grant] of this.grants) {
77
+ if (grant.createdAt <= cutoff) this.grants.delete(token);
78
+ }
79
+ }
80
+ }
81
+
82
+ /** Purposes come from the model: one line, no markup, bounded. */
83
+ function cleanPurpose(purpose: string): string {
84
+ return purpose
85
+ .replace(/[\r\n\t]+/g, " ")
86
+ .trim()
87
+ .slice(0, 200);
88
+ }
89
+
90
+ /** Minimal HTML escape for values interpolated into the pages. */
91
+ function esc(s: string): string {
92
+ return s
93
+ .replace(/&/g, "&amp;")
94
+ .replace(/</g, "&lt;")
95
+ .replace(/>/g, "&gt;")
96
+ .replace(/"/g, "&quot;")
97
+ .replace(/'/g, "&#39;");
98
+ }
99
+
100
+ const STYLE = `:root { color-scheme: light dark; }
101
+ body { margin: 0; padding: 24px; font: 15px/1.5 system-ui, sans-serif; }
102
+ main { max-width: 32rem; margin: 0 auto; }
103
+ h1 { font-size: 1.25rem; margin: 0 0 .25rem; }
104
+ p.sub { margin: 0 0 1.25rem; opacity: .7; }
105
+ textarea { box-sizing: border-box; width: 100%; min-height: 6rem; padding: 10px;
106
+ font: 14px ui-monospace, monospace; border-radius: 8px; }
107
+ button { margin-top: 12px; width: 100%; padding: 14px; border: 0; border-radius: 10px;
108
+ background: #2f6fed; color: #fff; font-weight: 600; font-size: 1rem; }
109
+ footer { margin-top: 1.5rem; font-size: .8rem; opacity: .6; }`;
110
+
111
+ /**
112
+ * One self-contained page, no script and no external assets. `no-referrer`
113
+ * keeps the grant out of any Referer and makes the browser send
114
+ * `Origin: null` on the POST; the bridge also accepts its own origin there.
115
+ */
116
+ function page(title: string, body: string): string {
117
+ return `<!doctype html>
118
+ <html lang="en">
119
+ <head>
120
+ <meta charset="utf-8">
121
+ <meta name="viewport" content="width=device-width, initial-scale=1">
122
+ <meta name="referrer" content="no-referrer">
123
+ <meta name="robots" content="noindex">
124
+ <title>${esc(title)}</title>
125
+ <style>
126
+ ${STYLE}
127
+ </style>
128
+ </head>
129
+ <body>
130
+ <main>
131
+ ${body}
132
+ </main>
133
+ </body>
134
+ </html>
135
+ `;
136
+ }
137
+
138
+ /** The paste form for a live grant. */
139
+ export function secretDropForm(grant: SecretDropGrant): string {
140
+ const minutes = Math.round(SECRET_GRANT_TTL_MS / 60_000);
141
+ return page(
142
+ "Talon secret drop",
143
+ ` <h1>Store a secret</h1>
144
+ <p class="sub">Saved as <b>${esc(grant.name)}</b> on the Talon host.${
145
+ grant.purpose ? `<br>For: ${esc(grant.purpose)}` : ""
146
+ }</p>
147
+ <form method="post" action="?grant=${esc(grant.token)}" autocomplete="off">
148
+ <textarea name="value" required autofocus spellcheck="false"
149
+ autocapitalize="off" autocorrect="off" aria-label="Secret value"></textarea>
150
+ <button type="submit">Store</button>
151
+ </form>
152
+ <footer>The value goes straight to a file readable only by Talon's user. It is
153
+ not posted to the chat or shown to the model. This link works once and expires
154
+ ${minutes} minutes after it was made.</footer>`,
155
+ );
156
+ }
157
+
158
+ /** What the browser sees after a POST. */
159
+ export function secretDropResult(
160
+ ok: boolean,
161
+ name: string | null,
162
+ error?: string,
163
+ ): string {
164
+ return ok && name
165
+ ? page(
166
+ "Stored",
167
+ ` <h1>Stored ✓</h1>
168
+ <p class="sub">Saved as <b>${esc(name)}</b>. You can close this page.</p>`,
169
+ )
170
+ : page(
171
+ "Not stored",
172
+ ` <h1>Not stored</h1>
173
+ <p class="sub">${esc(error ?? "This link is unknown, expired, or already used.")}</p>
174
+ <footer>Ask for a fresh link with /secret &lt;name&gt;.</footer>`,
175
+ );
176
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Secrets — the ~/.talon/secrets folder and the drop links that fill it
3
+ * without a password ever crossing a chat.
4
+ */
5
+
6
+ export {
7
+ isLiveSecretDrop,
8
+ openSecretDropForm,
9
+ secretCommandReply,
10
+ submitSecretDrop,
11
+ } from "./service.js";