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.
- package/package.json +3 -2
- package/prompts/system/agent-brief.md +9 -6
- package/src/backend/claude-sdk/constants.ts +22 -0
- package/src/backend/claude-sdk/models/discovery.ts +3 -0
- package/src/backend/claude-sdk/one-shot.ts +3 -1
- package/src/backend/claude-sdk/options.ts +7 -1
- package/src/core/agents/index.ts +2 -0
- package/src/core/agents/prompt.ts +70 -2
- package/src/core/agents/registry.ts +47 -5
- package/src/core/agents/runner.ts +227 -39
- package/src/core/agents/trail.ts +141 -0
- package/src/core/agents/types.ts +43 -5
- package/src/core/agents/watchdog.ts +70 -0
- package/src/core/background/isolated-agent.ts +6 -2
- package/src/core/backup/plan.ts +4 -0
- package/src/core/config/index.ts +17 -4
- package/src/core/engine/gateway-actions/agents/control.ts +9 -2
- package/src/core/engine/gateway-actions/agents/preflight.ts +17 -1
- package/src/core/engine/gateway-actions/agents/report.ts +81 -24
- package/src/core/engine/gateway-actions/index.ts +3 -0
- package/src/core/mcp-hub/guest-scope.ts +3 -1
- package/src/core/mesh/devices/service.ts +7 -0
- package/src/core/mesh/links/bridge-links.ts +20 -0
- package/src/core/secrets/actions.ts +18 -0
- package/src/core/secrets/drop.ts +176 -0
- package/src/core/secrets/index.ts +11 -0
- package/src/core/secrets/service.ts +248 -0
- package/src/core/secrets/store.ts +100 -0
- package/src/core/tools/index.ts +2 -0
- package/src/core/tools/ops/agents.ts +30 -12
- package/src/core/tools/ops/secrets.ts +36 -0
- package/src/core/tools/types.ts +2 -1
- package/src/frontend/discord/commands/definitions.ts +21 -0
- package/src/frontend/discord/commands/router.ts +3 -0
- package/src/frontend/discord/commands/secret.ts +26 -0
- package/src/frontend/discord/handlers/messages.ts +6 -4
- package/src/frontend/native/bridge/routes/host.ts +10 -0
- package/src/frontend/native/bridge/routes/pre-auth.ts +82 -1
- package/src/frontend/native/bridge/routes/table.ts +5 -0
- package/src/frontend/native/bridge/server.ts +32 -4
- package/src/frontend/native/commands/definitions.ts +6 -0
- package/src/frontend/native/commands/index.ts +12 -0
- package/src/frontend/native/surface/handlers.ts +9 -0
- package/src/frontend/telegram/actions/chat-info.ts +22 -5
- package/src/frontend/telegram/commands/definitions.ts +4 -0
- package/src/frontend/telegram/commands/index.ts +3 -0
- package/src/frontend/telegram/commands/secret.ts +24 -0
- package/src/frontend/telegram/handlers/messages.ts +7 -5
- package/src/frontend/whatsapp/commands.ts +16 -1
- package/src/frontend/whatsapp/messages/media-store.ts +4 -2
- package/src/storage/media-index.ts +43 -11
- package/src/storage/repositories/media-index-repo.ts +12 -1
- package/src/storage/sql/media-index.sql +4 -0
- package/src/storage/sql/statements.generated.ts +2 -0
- package/src/util/log.ts +1 -0
- package/src/util/paths.ts +6 -0
package/src/core/agents/types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
134
|
-
readonly defaultTimeoutMs
|
|
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
|
-
/**
|
|
41
|
-
|
|
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 {
|
package/src/core/backup/plan.ts
CHANGED
|
@@ -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",
|
package/src/core/config/index.ts
CHANGED
|
@@ -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.
|
|
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(
|
|
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
|
|
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: ${
|
|
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: (
|
|
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
|
-
|
|
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:
|
|
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
|
|
118
|
-
if (!
|
|
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
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
const
|
|
125
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
};
|