talon-agent 5.1.0 → 5.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/bin/talon.js +35 -0
- package/package.json +2 -2
- package/prompts/identity.md +10 -2
- package/prompts/system/agent-brief.md +43 -0
- package/src/app.ts +60 -58
- package/src/backend/builtins.ts +26 -7
- package/src/backend/claude-sdk/one-shot.ts +13 -5
- package/src/backend/remote-server/index.ts +6 -4
- package/src/backend/remote-server/model-catalog/index.ts +4 -10
- package/src/backend/remote-server/model-catalog/provider.ts +3 -3
- package/src/backend/remote-server/profiles/bind.ts +225 -0
- package/src/backend/remote-server/profiles/index.ts +10 -0
- package/src/backend/remote-server/profiles/kilo.ts +82 -0
- package/src/backend/remote-server/profiles/opencode.ts +61 -0
- package/src/backend/remote-server/server-bindings.ts +3 -4
- package/src/bootstrap.ts +15 -1
- package/src/cli/chat.ts +5 -0
- package/src/cli/events.ts +9 -0
- package/src/core/agents/context.ts +48 -0
- package/src/core/agents/delivery.ts +167 -0
- package/src/core/agents/index.ts +37 -0
- package/src/core/agents/prompt.ts +116 -0
- package/src/core/agents/registry.ts +426 -0
- package/src/core/agents/runner.ts +448 -0
- package/src/core/agents/types.ts +124 -0
- package/src/core/background/cron/job-oneshot.ts +7 -12
- package/src/core/background/cron/job-prompt.ts +1 -1
- package/src/core/background/{cron/isolated-agent.ts → isolated-agent.ts} +45 -24
- package/src/core/background/run-log.ts +33 -0
- package/src/core/bus/events.ts +49 -2
- package/src/core/config/index.ts +25 -0
- package/src/core/daemon/crash.ts +82 -0
- package/src/core/engine/gateway-actions/agents/control.ts +299 -0
- package/src/core/engine/gateway-actions/agents/index.ts +31 -0
- package/src/core/engine/gateway-actions/agents/report.ts +107 -0
- package/src/core/engine/gateway-actions/index.ts +30 -0
- package/src/core/engine/gateway-actions/native/exec-remote.ts +1 -1
- package/src/core/engine/gateway-actions/native/exec.ts +1 -1
- package/src/core/engine/gateway-actions/native/read.ts +1 -1
- package/src/core/engine/gateway-actions/native/search.ts +1 -1
- package/src/core/engine/gateway-actions/native/teleport.ts +1 -1
- package/src/core/engine/gateway-actions/native/write.ts +1 -1
- package/src/core/engine/gateway-routes.ts +12 -0
- package/src/core/engine/gateway.ts +96 -25
- package/src/core/frontend-runtime/capabilities.ts +18 -0
- package/src/core/frontend-runtime/index.ts +4 -0
- package/src/core/frontend-runtime/lifecycle.ts +33 -0
- package/src/core/frontend-runtime/registry.ts +3 -3
- package/src/core/frontend-runtime/run-loop.ts +59 -0
- package/src/core/mesh/{registry.ts → devices/registry.ts} +4 -4
- package/src/core/mesh/{service.ts → devices/service.ts} +13 -10
- package/src/core/mesh/{teleport.ts → devices/teleport.ts} +2 -2
- package/src/core/mesh/index.ts +6 -2
- package/src/core/mesh/{bridge-links.ts → links/bridge-links.ts} +1 -1
- package/src/core/mesh/{companion-pairing.ts → links/companion-pairing.ts} +1 -1
- package/src/core/mesh/{node-binaries.ts → links/node-binaries.ts} +5 -5
- package/src/core/mesh/{node-provision.ts → links/node-provision.ts} +1 -1
- package/src/core/mesh/{common.ts → tool-surface.ts} +7 -2
- package/src/core/mesh/{device-files.ts → transfers/device-files.ts} +5 -5
- package/src/core/prompt/embedded-prompts.ts +38 -36
- package/src/core/tasks/types.ts +2 -2
- package/src/core/tools/index.ts +5 -0
- package/src/core/tools/ops/agents.ts +195 -0
- package/src/core/tools/ops/bridge.ts +4 -0
- package/src/core/tools/types.ts +1 -0
- package/src/core/types.ts +1 -1
- package/src/frontend/discord/commands/info.ts +1 -1
- package/src/frontend/discord/render.ts +1 -1
- package/src/frontend/native/bridge/routes/mesh.ts +1 -1
- package/src/frontend/native/index.ts +2 -0
- package/src/frontend/presentation/reports.ts +1 -1
- package/src/frontend/teams/index.ts +4 -3
- package/src/frontend/telegram/commands/info.ts +61 -20
- package/src/frontend/telegram/index.ts +27 -4
- package/src/frontend/telegram/render/reports.ts +1 -1
- package/src/frontend/terminal/index.ts +6 -2
- package/src/frontend/whatsapp/connection/connection.ts +62 -11
- package/src/frontend/whatsapp/index.ts +25 -1
- package/src/frontend/whatsapp/runtime.ts +7 -0
- package/src/util/log.ts +264 -18
- package/src/backend/kilo/factory.ts +0 -53
- package/src/backend/kilo/handler/index.ts +0 -2
- package/src/backend/kilo/handler/message.ts +0 -44
- package/src/backend/kilo/index.ts +0 -61
- package/src/backend/kilo/model-provider.ts +0 -36
- package/src/backend/kilo/models/index.ts +0 -55
- package/src/backend/kilo/one-shot.ts +0 -42
- package/src/backend/kilo/server.ts +0 -98
- package/src/backend/kilo/sessions.ts +0 -37
- package/src/backend/opencode/factory.ts +0 -53
- package/src/backend/opencode/handler/index.ts +0 -2
- package/src/backend/opencode/handler/message.ts +0 -44
- package/src/backend/opencode/index.ts +0 -42
- package/src/backend/opencode/model-provider.ts +0 -36
- package/src/backend/opencode/models/index.ts +0 -54
- package/src/backend/opencode/one-shot.ts +0 -42
- package/src/backend/opencode/server.ts +0 -80
- package/src/backend/opencode/sessions.ts +0 -35
- /package/src/core/mesh/{transfers.ts → transfers/transfers.ts} +0 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-side sub-agent actions — the three tools that only mean something
|
|
3
|
+
* inside a sub-agent run.
|
|
4
|
+
*
|
|
5
|
+
* The calling agent is identified from the gateway chat key (`agent:<id>`),
|
|
6
|
+
* which the backend derives from the run's `contextLabel` and the MCP hub
|
|
7
|
+
* binds to the tool session. Nothing is taken from the model's parameters:
|
|
8
|
+
* an agent cannot claim to be a different agent, because it never names one.
|
|
9
|
+
*
|
|
10
|
+
* Called from a chat (or any other context) these are refused rather than
|
|
11
|
+
* silently no-oping — a model that thinks it reported when it did not is the
|
|
12
|
+
* one failure mode worth being loud about.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
agentIdFromContextLabel,
|
|
17
|
+
agentRegistry,
|
|
18
|
+
deliverMessage,
|
|
19
|
+
describeParent,
|
|
20
|
+
} from "../../../agents/index.js";
|
|
21
|
+
import { logError } from "../../../../util/log.js";
|
|
22
|
+
import type { ActionResult } from "../../../types.js";
|
|
23
|
+
import type { AgentRecord } from "../../../agents/index.js";
|
|
24
|
+
import type { SharedActionHandlers } from "../types.js";
|
|
25
|
+
|
|
26
|
+
function notAnAgent(tool: string): ActionResult {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
error:
|
|
30
|
+
`${tool} is only callable inside a sub-agent run. This context is a ` +
|
|
31
|
+
`chat, which has no parent to report to.`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The live agent behind this chat key, or null when the caller isn't one. */
|
|
36
|
+
function callingAgent(chatKey: string): AgentRecord | null {
|
|
37
|
+
const id = agentIdFromContextLabel(chatKey);
|
|
38
|
+
if (!id || !agentRegistry.isLive(id)) return null;
|
|
39
|
+
return agentRegistry.get(id);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const agentReportHandlers: SharedActionHandlers = {
|
|
43
|
+
report_result: (body, _chatId, _backend, chatKey) => {
|
|
44
|
+
const record = callingAgent(chatKey);
|
|
45
|
+
if (!record) return notAnAgent("report_result");
|
|
46
|
+
const summary = String(body.summary ?? "").trim();
|
|
47
|
+
if (!summary) return { ok: false, error: "Missing summary" };
|
|
48
|
+
const details = body.details ? String(body.details).trim() : undefined;
|
|
49
|
+
const stored = agentRegistry.report(record.id, {
|
|
50
|
+
summary,
|
|
51
|
+
...(details ? { details } : {}),
|
|
52
|
+
});
|
|
53
|
+
if (!stored) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
error:
|
|
57
|
+
"You have already reported a result. It is recorded and will be " +
|
|
58
|
+
"delivered when your run ends — finish up instead of reporting again.",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
ok: true,
|
|
63
|
+
text:
|
|
64
|
+
`Result recorded. It is delivered to ${describeParent(record.parent)} ` +
|
|
65
|
+
`when your run ends — you can stop now.`,
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
message_parent: (body, _chatId, _backend, chatKey) => {
|
|
70
|
+
const record = callingAgent(chatKey);
|
|
71
|
+
if (!record) return notAnAgent("message_parent");
|
|
72
|
+
const text = String(body.text ?? "").trim();
|
|
73
|
+
if (!text) return { ok: false, error: "Missing text" };
|
|
74
|
+
// Fire-and-forget: waking a chat runs a whole turn, and this tool call
|
|
75
|
+
// must not block for the length of the parent's reply.
|
|
76
|
+
void deliverMessage(record, text).catch((err: unknown) =>
|
|
77
|
+
logError(
|
|
78
|
+
"agents",
|
|
79
|
+
`message_parent delivery failed for ${record.id}`,
|
|
80
|
+
err,
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
return {
|
|
84
|
+
ok: true,
|
|
85
|
+
text: `Note sent to ${describeParent(record.parent)}. Carry on — this did not end your run.`,
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
check_inbox: (_body, _chatId, _backend, chatKey) => {
|
|
90
|
+
const record = callingAgent(chatKey);
|
|
91
|
+
if (!record) return notAnAgent("check_inbox");
|
|
92
|
+
const messages = agentRegistry.drain(record.id);
|
|
93
|
+
if (messages.length === 0) {
|
|
94
|
+
return { ok: true, text: "Inbox empty — no new instructions." };
|
|
95
|
+
}
|
|
96
|
+
const rendered = messages
|
|
97
|
+
.map((message) => {
|
|
98
|
+
const at = new Date(message.at).toISOString().slice(11, 19);
|
|
99
|
+
return `[${at}] from ${message.from}:\n${message.text}`;
|
|
100
|
+
})
|
|
101
|
+
.join("\n\n");
|
|
102
|
+
return {
|
|
103
|
+
ok: true,
|
|
104
|
+
text: `${messages.length} message(s):\n\n${rendered}`,
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
};
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* - `fetch-url` — fetch a URL (text extraction or binary download)
|
|
12
12
|
* - `cron` — scheduled-job CRUD
|
|
13
13
|
* - `triggers` — long-running watcher-script CRUD
|
|
14
|
+
* - `agents` — sub-agent spawn / inspect / message, and the agent-side
|
|
15
|
+
* report / inbox tools
|
|
14
16
|
* - `goals` — persistent multi-turn objectives
|
|
15
17
|
* - `memory` — remember / recall / forget over the typed memory store
|
|
16
18
|
* - `scripts` — reusable agent-authored scripts
|
|
@@ -28,6 +30,7 @@ import { historyHandlers } from "./history.js";
|
|
|
28
30
|
import { fetchUrlHandlers } from "./fetch-url.js";
|
|
29
31
|
import { cronHandlers } from "./cron.js";
|
|
30
32
|
import { triggerHandlers } from "./triggers.js";
|
|
33
|
+
import { agentContextActions, agentHandlers } from "./agents/index.js";
|
|
31
34
|
import { goalHandlers } from "./goals.js";
|
|
32
35
|
import { memoryHandlers } from "./memory.js";
|
|
33
36
|
import { scriptHandlers } from "./scripts.js";
|
|
@@ -53,6 +56,7 @@ const handlers: SharedActionHandlers = Object.assign(Object.create(null), {
|
|
|
53
56
|
...fetchUrlHandlers,
|
|
54
57
|
...cronHandlers,
|
|
55
58
|
...triggerHandlers,
|
|
59
|
+
...agentHandlers,
|
|
56
60
|
...goalHandlers,
|
|
57
61
|
...memoryHandlers,
|
|
58
62
|
...scriptHandlers,
|
|
@@ -102,6 +106,32 @@ export function isChatFreeAction(action: string): boolean {
|
|
|
102
106
|
return chatFreeActions.has(action);
|
|
103
107
|
}
|
|
104
108
|
|
|
109
|
+
/**
|
|
110
|
+
* True when the action belongs to the sub-agent family, i.e. the gateway may
|
|
111
|
+
* dispatch it for an `agent:<id>` chat key (which has an identity but no
|
|
112
|
+
* chat). Anything else stays chat-routed.
|
|
113
|
+
*/
|
|
114
|
+
export function isAgentContextAction(action: string): boolean {
|
|
115
|
+
return agentContextActions.has(action);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Dispatch a sub-agent action on behalf of a running agent. `contextKey` is
|
|
120
|
+
* the agent's `agent:<id>` label, handed through as the `chatKey` so the
|
|
121
|
+
* handlers can identify the caller; `0` is the explicit "no chat" chatId,
|
|
122
|
+
* same sentinel the chat-free path uses.
|
|
123
|
+
*/
|
|
124
|
+
export async function handleAgentContextAction(
|
|
125
|
+
body: Record<string, unknown>,
|
|
126
|
+
contextKey: string,
|
|
127
|
+
): Promise<ActionResult | null> {
|
|
128
|
+
const action = body.action as string;
|
|
129
|
+
if (!isAgentContextAction(action)) return null;
|
|
130
|
+
const handler = handlers[action];
|
|
131
|
+
if (!handler) return null;
|
|
132
|
+
return handler(body, 0, undefined, contextKey);
|
|
133
|
+
}
|
|
134
|
+
|
|
105
135
|
/**
|
|
106
136
|
* Dispatch a chat-free action. The handler signature still takes a chatId
|
|
107
137
|
* (they all share one type); chat-free handlers ignore it, and `0` is passed
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { getMeshService } from "../../../mesh/index.js";
|
|
12
|
-
import { getTeleport, setTeleportCwd } from "../../../mesh/teleport.js";
|
|
12
|
+
import { getTeleport, setTeleportCwd } from "../../../mesh/devices/teleport.js";
|
|
13
13
|
import { renderExec, type Result } from "./results.js";
|
|
14
14
|
import { shellQuote } from "./shell.js";
|
|
15
15
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { spawn } from "node:child_process";
|
|
12
12
|
import { stat as fsStat } from "node:fs/promises";
|
|
13
|
-
import { getTeleport } from "../../../mesh/teleport.js";
|
|
13
|
+
import { getTeleport } from "../../../mesh/devices/teleport.js";
|
|
14
14
|
import { createOutputCapture } from "../../../../util/exec-output.js";
|
|
15
15
|
import { bashBackground } from "./exec-background.js";
|
|
16
16
|
import { bashTeleported } from "./exec-remote.js";
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { readFile, stat as fsStat } from "node:fs/promises";
|
|
11
11
|
import { extname } from "node:path";
|
|
12
12
|
import { getMeshService } from "../../../mesh/index.js";
|
|
13
|
-
import { getTeleport } from "../../../mesh/teleport.js";
|
|
13
|
+
import { getTeleport } from "../../../mesh/devices/teleport.js";
|
|
14
14
|
import { num, resolvePathParam, str } from "./params.js";
|
|
15
15
|
import type { Result } from "./results.js";
|
|
16
16
|
import type { SharedActionHandlers } from "../types.js";
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { glob as fsGlob, readFile, stat as fsStat } from "node:fs/promises";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
-
import { getTeleport } from "../../../mesh/teleport.js";
|
|
12
|
+
import { getTeleport } from "../../../mesh/devices/teleport.js";
|
|
13
13
|
import { bashTeleported } from "./exec-remote.js";
|
|
14
14
|
import { resolvePathParam, str } from "./params.js";
|
|
15
15
|
import type { Result } from "./results.js";
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { getMeshService } from "../../../mesh/index.js";
|
|
9
|
-
import { clearTeleport, setTeleport } from "../../../mesh/teleport.js";
|
|
9
|
+
import { clearTeleport, setTeleport } from "../../../mesh/devices/teleport.js";
|
|
10
10
|
import type { Result } from "./results.js";
|
|
11
11
|
import type { SharedActionHandlers } from "../types.js";
|
|
12
12
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
11
11
|
import { dirname } from "node:path";
|
|
12
12
|
import { getMeshService } from "../../../mesh/index.js";
|
|
13
|
-
import { getTeleport } from "../../../mesh/teleport.js";
|
|
13
|
+
import { getTeleport } from "../../../mesh/devices/teleport.js";
|
|
14
14
|
import { resolvePathParam, str } from "./params.js";
|
|
15
15
|
import type { Result } from "./results.js";
|
|
16
16
|
import type { SharedActionHandlers } from "../types.js";
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { IncomingMessage, Server, ServerResponse } from "node:http";
|
|
7
7
|
import { bus } from "../bus/index.js";
|
|
8
8
|
import { taskTable } from "../tasks/index.js";
|
|
9
|
+
import { agentRegistry } from "../agents/index.js";
|
|
9
10
|
import { handleHubRequest, HUB_PATH_PREFIX } from "../mcp-hub/index.js";
|
|
10
11
|
import { log, logError } from "../../util/log.js";
|
|
11
12
|
|
|
@@ -95,6 +96,17 @@ const ROUTES: readonly GatewayRoute[] = [
|
|
|
95
96
|
handle: ({ res }) =>
|
|
96
97
|
sendJson(res, 200, { ok: true, tasks: taskTable.list() }),
|
|
97
98
|
},
|
|
99
|
+
{
|
|
100
|
+
// The sub-agent registry — live agents plus the settled ring. Same
|
|
101
|
+
// content-free contract as /tasks: ids, labels, states, never briefs.
|
|
102
|
+
method: "GET",
|
|
103
|
+
path: "/agents",
|
|
104
|
+
handle: ({ res }) =>
|
|
105
|
+
sendJson(res, 200, {
|
|
106
|
+
ok: true,
|
|
107
|
+
agents: agentRegistry.list().map(({ brief: _brief, ...rest }) => rest),
|
|
108
|
+
}),
|
|
109
|
+
},
|
|
98
110
|
{
|
|
99
111
|
// Abort one killable task by id — the transport for `talon kill`.
|
|
100
112
|
method: "POST",
|
|
@@ -19,8 +19,10 @@ import { log, logError, logDebug } from "../../util/log.js";
|
|
|
19
19
|
import {
|
|
20
20
|
handleSharedAction,
|
|
21
21
|
handleChatFreeAction,
|
|
22
|
+
handleAgentContextAction,
|
|
22
23
|
isChatFreeAction,
|
|
23
24
|
} from "./gateway-actions/index.js";
|
|
25
|
+
import { AGENT_CONTEXT_PREFIX } from "../agents/context.js";
|
|
24
26
|
import { registerCrossSendTarget } from "./gateway-actions/cross-send.js";
|
|
25
27
|
import { getHubSessionCount } from "../mcp-hub/index.js";
|
|
26
28
|
import {
|
|
@@ -99,6 +101,11 @@ export class Gateway {
|
|
|
99
101
|
private readonly frontendHandlers = new Map<string, FrontendActionHandler>();
|
|
100
102
|
private readonly chatFrontendOwners = new Map<number, string>();
|
|
101
103
|
private server: ReturnType<typeof createServer> | null = null;
|
|
104
|
+
/**
|
|
105
|
+
* In-flight `start()` bind, so concurrent callers share one HTTP server.
|
|
106
|
+
* Non-null only between the first `start()` call and its bind settling.
|
|
107
|
+
*/
|
|
108
|
+
private starting: Promise<number> | null = null;
|
|
102
109
|
private port = 0;
|
|
103
110
|
private readonly startedAt = new Date().toISOString();
|
|
104
111
|
private startedListeners: Array<(port: number) => void> = [];
|
|
@@ -238,6 +245,50 @@ export class Gateway {
|
|
|
238
245
|
|
|
239
246
|
// ── Action dispatch ────────────────────────────────────────────────────────
|
|
240
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Dispatch the actions that resolve without a chat, or `null` when the
|
|
250
|
+
* request needs one after all.
|
|
251
|
+
*
|
|
252
|
+
* Two families qualify, and they short-circuit routing for the same
|
|
253
|
+
* reason: there is no chat to route to.
|
|
254
|
+
*
|
|
255
|
+
* - **Chat-free** actions (the device mesh, cross-send) read daemon-wide
|
|
256
|
+
* state and ignore chatId. Gating them behind an active chat made the
|
|
257
|
+
* whole mesh unreachable from background runs — and unlike send/react
|
|
258
|
+
* they carry no `chat_id` param to promote.
|
|
259
|
+
* - **Sub-agent** actions arrive from a run whose `contextLabel` is
|
|
260
|
+
* `agent:<id>`; the MCP hub binds that to the tool session and the
|
|
261
|
+
* bridge sends it back as `_chatId`. Such a caller has an identity but
|
|
262
|
+
* no chat, so the key is handed through as the chatKey — which is how
|
|
263
|
+
* `report_result` knows which agent reported. Everything else a
|
|
264
|
+
* sub-agent calls still routes by an explicit `chat_id`, exactly as a
|
|
265
|
+
* heartbeat run does.
|
|
266
|
+
*/
|
|
267
|
+
private async dispatchWithoutChat(
|
|
268
|
+
body: Record<string, unknown>,
|
|
269
|
+
rawChatId: string,
|
|
270
|
+
): Promise<unknown | null> {
|
|
271
|
+
const action = typeof body.action === "string" ? body.action : "";
|
|
272
|
+
const agentContext = rawChatId.startsWith(AGENT_CONTEXT_PREFIX);
|
|
273
|
+
if (!action || (!agentContext && !isChatFreeAction(action))) return null;
|
|
274
|
+
const where = agentContext ? rawChatId : "chat-free";
|
|
275
|
+
const t0 = Date.now();
|
|
276
|
+
try {
|
|
277
|
+
const result = agentContext
|
|
278
|
+
? await handleAgentContextAction(body, rawChatId)
|
|
279
|
+
: await handleChatFreeAction(body);
|
|
280
|
+
if (result) {
|
|
281
|
+
logDebug("gateway", `${action} ${where} ${Date.now() - t0}ms`);
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
} catch (err) {
|
|
285
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
286
|
+
logError("gateway", `${action} (${where}) failed: ${msg}`);
|
|
287
|
+
return { ok: false, error: `${action}: ${msg}` };
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
241
292
|
private async handleAction(body: Record<string, unknown>): Promise<unknown> {
|
|
242
293
|
// Route by _chatId from the MCP subprocess request.
|
|
243
294
|
// _chatId may be a string (Teams: "teams_chat_19:...") or numeric string
|
|
@@ -251,32 +302,12 @@ export class Gateway {
|
|
|
251
302
|
// active-context-required check — the action handler will reach the
|
|
252
303
|
// chat directly via the Telegram Bot API. The legacy context-required
|
|
253
304
|
// path remains for chat-mode calls where `chat_id` is absent.
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
// channel a heartbeat run has to a remote box. Gating them behind an
|
|
257
|
-
// active chat made the whole mesh unreachable from background runs —
|
|
258
|
-
// and unlike send/react they carry no `chat_id` param to promote.
|
|
259
|
-
const requestedAction =
|
|
260
|
-
typeof body.action === "string" ? body.action : undefined;
|
|
261
|
-
if (requestedAction && isChatFreeAction(requestedAction)) {
|
|
262
|
-
const t0 = Date.now();
|
|
263
|
-
try {
|
|
264
|
-
const result = await handleChatFreeAction(body);
|
|
265
|
-
if (result) {
|
|
266
|
-
logDebug(
|
|
267
|
-
"gateway",
|
|
268
|
-
`${requestedAction} chat=none ${Date.now() - t0}ms (chat-free)`,
|
|
269
|
-
);
|
|
270
|
-
return result;
|
|
271
|
-
}
|
|
272
|
-
} catch (err) {
|
|
273
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
274
|
-
logError("gateway", `${requestedAction} (chat-free) failed: ${msg}`);
|
|
275
|
-
return { ok: false, error: `${requestedAction}: ${msg}` };
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
305
|
+
// Actions that need no chat at all (the device mesh, and a sub-agent's
|
|
306
|
+
// own tool family) short-circuit routing — see `dispatchWithoutChat`.
|
|
279
307
|
const rawChatId = body._chatId ? String(body._chatId) : "";
|
|
308
|
+
const unrouted = await this.dispatchWithoutChat(body, rawChatId);
|
|
309
|
+
if (unrouted) return unrouted;
|
|
310
|
+
|
|
280
311
|
const numericId = Number(rawChatId);
|
|
281
312
|
const explicitChatIdProvided = typeof body.chat_id !== "undefined";
|
|
282
313
|
let chatId: number | null = null;
|
|
@@ -359,8 +390,38 @@ export class Gateway {
|
|
|
359
390
|
|
|
360
391
|
// ── HTTP server ──────────────────────────────────────────────────────────
|
|
361
392
|
|
|
393
|
+
/**
|
|
394
|
+
* Bind the action gateway's HTTP server, returning the bound port.
|
|
395
|
+
*
|
|
396
|
+
* Single-flight: every frontend calls this from its own `start()`, and
|
|
397
|
+
* `app.ts` starts the non-stdin frontends concurrently with `Promise.all`,
|
|
398
|
+
* so two or more callers routinely land here at once. All of them await the
|
|
399
|
+
* same bind and get the same port; the FIRST caller's requested port is the
|
|
400
|
+
* one attempted (later callers' `port` arguments are ignored), and
|
|
401
|
+
* `onStarted` listeners fire exactly once. Without this, each caller built
|
|
402
|
+
* its own `http.Server` and `listenWithRetry` walked them onto consecutive
|
|
403
|
+
* ports — one process listening on :19876 AND :19877, `/health` and the
|
|
404
|
+
* pidfile disagreeing about which, and `stop()` leaking the other listener.
|
|
405
|
+
*
|
|
406
|
+
* A failed bind clears the in-flight state, so a later `start()` retries;
|
|
407
|
+
* so does `stop()`, so start-after-stop binds afresh.
|
|
408
|
+
*/
|
|
362
409
|
async start(port = 19876): Promise<number> {
|
|
363
410
|
if (this.server) return this.port;
|
|
411
|
+
if (this.starting) return this.starting;
|
|
412
|
+
const attempt = this.bind(port);
|
|
413
|
+
this.starting = attempt;
|
|
414
|
+
try {
|
|
415
|
+
return await attempt;
|
|
416
|
+
} finally {
|
|
417
|
+
// Only clear our own attempt — never a newer one started after a
|
|
418
|
+
// stop() that raced this bind.
|
|
419
|
+
if (this.starting === attempt) this.starting = null;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** The actual bind. Always called through `start()`'s single-flight guard. */
|
|
424
|
+
private async bind(port: number): Promise<number> {
|
|
364
425
|
const host: GatewayRouteHost = {
|
|
365
426
|
healthSnapshot: () => this.healthSnapshot(),
|
|
366
427
|
requestShutdown: () => {
|
|
@@ -427,6 +488,15 @@ export class Gateway {
|
|
|
427
488
|
}
|
|
428
489
|
|
|
429
490
|
async stop(): Promise<void> {
|
|
491
|
+
// A start() racing this stop() would otherwise hand us back a bound
|
|
492
|
+
// server with nothing left to close it. Let it finish first.
|
|
493
|
+
if (this.starting) {
|
|
494
|
+
try {
|
|
495
|
+
await this.starting;
|
|
496
|
+
} catch {
|
|
497
|
+
// The bind failed — nothing was left listening.
|
|
498
|
+
}
|
|
499
|
+
}
|
|
430
500
|
return new Promise((resolve) => {
|
|
431
501
|
if (!this.server) {
|
|
432
502
|
resolve();
|
|
@@ -435,6 +505,7 @@ export class Gateway {
|
|
|
435
505
|
const server = this.server;
|
|
436
506
|
const settle = (): void => {
|
|
437
507
|
this.server = null;
|
|
508
|
+
this.starting = null;
|
|
438
509
|
this.port = 0;
|
|
439
510
|
resolve();
|
|
440
511
|
};
|
|
@@ -33,6 +33,12 @@ import type { FrontendDescriptor } from "./registry.js";
|
|
|
33
33
|
* The runtime interface every frontend implements (moved here from
|
|
34
34
|
* `bootstrap.ts`; `bootstrap.ts` re-exports it for existing importers).
|
|
35
35
|
* Lifecycle: `create → init → start → stop`.
|
|
36
|
+
*
|
|
37
|
+
* `start()` resolves at STARTED, never at STOPPED. A frontend that kept
|
|
38
|
+
* its run-until-stopped loop as the `start()` promise made the boot end
|
|
39
|
+
* at shutdown: boot metrics, the resource sampler and the "Ready in …"
|
|
40
|
+
* line all fired hours late, and anything the composition root
|
|
41
|
+
* sequenced after the await never ran while the daemon was alive.
|
|
36
42
|
*/
|
|
37
43
|
export type Frontend = {
|
|
38
44
|
/** Registry id of the frontend that created this instance. */
|
|
@@ -42,7 +48,19 @@ export type Frontend = {
|
|
|
42
48
|
sendMessage: (chatId: number, text: string) => Promise<void>;
|
|
43
49
|
getBridgePort: () => number;
|
|
44
50
|
init: () => Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Bring the surface up and resolve once it is LISTENING: bot identity
|
|
53
|
+
* fetched and polling running, socket connecting, server bound, prompt
|
|
54
|
+
* loop drawn. A frontend with a run-until-stopped loop (long-poll,
|
|
55
|
+
* reconnect loop) keeps that promise internally — see
|
|
56
|
+
* `runUntilStopped` in `run-loop.ts` — and awaits it in `stop()`.
|
|
57
|
+
* Rejecting means the frontend never came up; the boot fails loudly.
|
|
58
|
+
*/
|
|
45
59
|
start: () => Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Take the surface down and resolve once it is fully stopped,
|
|
62
|
+
* including the run loop `start()` left running.
|
|
63
|
+
*/
|
|
46
64
|
stop: () => Promise<void>;
|
|
47
65
|
};
|
|
48
66
|
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
import "./builtins.js";
|
|
11
11
|
|
|
12
12
|
export type { Frontend } from "./capabilities.js";
|
|
13
|
+
export { startFrontends } from "./lifecycle.js";
|
|
14
|
+
// `runUntilStopped` (run-loop.js) is imported by the frontends that own a
|
|
15
|
+
// run loop, straight from its module — it is not part of the composition
|
|
16
|
+
// root's surface.
|
|
13
17
|
export {
|
|
14
18
|
getFrontendDescriptor,
|
|
15
19
|
hasFrontend,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frontend lifecycle sequencing for the composition roots (`app.ts`,
|
|
3
|
+
* `cli/chat.ts`).
|
|
4
|
+
*
|
|
5
|
+
* One rule, no per-frontend special cases: `start()` resolves when the
|
|
6
|
+
* frontend is listening (`capabilities.ts`), so bringing the daemon up
|
|
7
|
+
* is "start them all, wait for readiness" — and whatever the caller
|
|
8
|
+
* sequences afterwards (boot metrics, the resource sampler, the "Ready
|
|
9
|
+
* in …" line) runs at the true end of the boot.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { log } from "../../util/log.js";
|
|
13
|
+
import type { Frontend } from "./capabilities.js";
|
|
14
|
+
import { getFrontendDescriptor } from "./registry.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Start every configured frontend in parallel and resolve once they are
|
|
18
|
+
* all listening. Rejects as soon as one fails to come up.
|
|
19
|
+
*/
|
|
20
|
+
export async function startFrontends(
|
|
21
|
+
frontends: readonly Frontend[],
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
const sharesStdin = frontends.some(
|
|
24
|
+
(frontend) => getFrontendDescriptor(frontend.name)?.sharesStdin === true,
|
|
25
|
+
);
|
|
26
|
+
if (sharesStdin && frontends.length > 1) {
|
|
27
|
+
log(
|
|
28
|
+
"bot",
|
|
29
|
+
"Terminal frontend shares stdin with the other frontends; keystrokes here reach the terminal prompt only.",
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
await Promise.all(frontends.map((frontend) => frontend.start()));
|
|
33
|
+
}
|
|
@@ -65,9 +65,9 @@ export type FrontendDescriptor = {
|
|
|
65
65
|
*/
|
|
66
66
|
messaging: boolean;
|
|
67
67
|
/**
|
|
68
|
-
* Reads stdin interactively
|
|
69
|
-
*
|
|
70
|
-
*
|
|
68
|
+
* Reads stdin interactively (terminal only). Its `start()` returns
|
|
69
|
+
* like any other frontend's — this says that keystrokes belong to it,
|
|
70
|
+
* which the composition root notes when it runs alongside others.
|
|
71
71
|
*/
|
|
72
72
|
sharesStdin?: boolean;
|
|
73
73
|
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "started, not stopped" seam for frontends that own a
|
|
3
|
+
* run-until-stopped loop (Telegram's long-poll, WhatsApp's reconnect
|
|
4
|
+
* loop).
|
|
5
|
+
*
|
|
6
|
+
* Those loops resolve when the frontend STOPS, which is the opposite of
|
|
7
|
+
* what `start()` promises (`capabilities.ts`). This splits one loop into
|
|
8
|
+
* the two signals the lifecycle actually needs: `ready`, which settles
|
|
9
|
+
* the moment the loop reports it is listening, and `stopped`, the loop
|
|
10
|
+
* itself — kept by the frontend and awaited in `stop()`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** A split run loop: readiness for `start()`, the loop itself for `stop()`. */
|
|
14
|
+
export type RunHandle = {
|
|
15
|
+
/** Resolves when the loop signalled readiness; rejects if it failed first. */
|
|
16
|
+
ready: Promise<void>;
|
|
17
|
+
/** Resolves when the loop has ended. Never rejects — see `onError`. */
|
|
18
|
+
stopped: Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Run `loop`, handing it the callback that marks the frontend listening.
|
|
23
|
+
*
|
|
24
|
+
* A loop that ends — or throws — before it ever signalled readiness
|
|
25
|
+
* settles `ready` anyway: a boot must fail or proceed, never hang on a
|
|
26
|
+
* surface that already gave up. `onError` receives a failure the loop
|
|
27
|
+
* hits after readiness, which nothing else would ever observe.
|
|
28
|
+
*/
|
|
29
|
+
export function runUntilStopped(
|
|
30
|
+
loop: (signalReady: () => void) => Promise<void>,
|
|
31
|
+
onError: (err: unknown) => void,
|
|
32
|
+
): RunHandle {
|
|
33
|
+
let resolveReady!: () => void;
|
|
34
|
+
let rejectReady!: (err: unknown) => void;
|
|
35
|
+
const ready = new Promise<void>((resolve, reject) => {
|
|
36
|
+
resolveReady = resolve;
|
|
37
|
+
rejectReady = reject;
|
|
38
|
+
});
|
|
39
|
+
let readyState: "pending" | "resolved" | "rejected" = "pending";
|
|
40
|
+
const markReady = (): void => {
|
|
41
|
+
if (readyState !== "pending") return;
|
|
42
|
+
readyState = "resolved";
|
|
43
|
+
resolveReady();
|
|
44
|
+
};
|
|
45
|
+
const running = loop(markReady);
|
|
46
|
+
// Settle `ready` on the loop's own outcome too: a loop that ends (or
|
|
47
|
+
// fails) before signalling readiness must not leave the boot hanging.
|
|
48
|
+
running.then(markReady, (err: unknown) => {
|
|
49
|
+
if (readyState !== "pending") return;
|
|
50
|
+
readyState = "rejected";
|
|
51
|
+
rejectReady(err);
|
|
52
|
+
});
|
|
53
|
+
const stopped = running.catch((err: unknown) => {
|
|
54
|
+
// A failure before readiness is already the caller's — `start()`
|
|
55
|
+
// rejects with it. Only report what nobody else would see.
|
|
56
|
+
if (readyState !== "rejected") onError(err);
|
|
57
|
+
});
|
|
58
|
+
return { ready, stopped };
|
|
59
|
+
}
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { resolve } from "node:path";
|
|
11
|
-
import { logWarn } from "
|
|
12
|
-
import { dirs } from "
|
|
13
|
-
import { readArray, writePrivateJson } from "
|
|
11
|
+
import { logWarn } from "../../../util/log.js";
|
|
12
|
+
import { dirs } from "../../../util/paths.js";
|
|
13
|
+
import { readArray, writePrivateJson } from "../persist.js";
|
|
14
14
|
import {
|
|
15
15
|
sanitizeCapabilities,
|
|
16
16
|
toDeviceInfo,
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
type DeviceInfo,
|
|
19
19
|
type DeviceLocation,
|
|
20
20
|
type DevicePlatform,
|
|
21
|
-
} from "
|
|
21
|
+
} from "../types.js";
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* A device is offline once it misses several heartbeats. The companion
|
|
@@ -35,24 +35,27 @@
|
|
|
35
35
|
|
|
36
36
|
import { randomUUID } from "node:crypto";
|
|
37
37
|
import type { Readable } from "node:stream";
|
|
38
|
-
import { clampExecOutput } from "
|
|
39
|
-
import { BridgeLinks, type MeshBridgeInfo } from "
|
|
38
|
+
import { clampExecOutput } from "../../../util/exec-output.js";
|
|
39
|
+
import { BridgeLinks, type MeshBridgeInfo } from "../links/bridge-links.js";
|
|
40
40
|
import {
|
|
41
41
|
age,
|
|
42
42
|
formatBytes,
|
|
43
43
|
FS_COMMAND_TIMEOUT_MS,
|
|
44
44
|
requirePath,
|
|
45
45
|
type MeshToolResult,
|
|
46
|
-
} from "
|
|
47
|
-
import { DeviceFiles } from "
|
|
48
|
-
import {
|
|
46
|
+
} from "../tool-surface.js";
|
|
47
|
+
import { DeviceFiles } from "../transfers/device-files.js";
|
|
48
|
+
import {
|
|
49
|
+
resolveNodeBinary,
|
|
50
|
+
type NodeBinaryResolver,
|
|
51
|
+
} from "../links/node-binaries.js";
|
|
49
52
|
import { MeshRegistry } from "./registry.js";
|
|
50
53
|
import type {
|
|
51
54
|
DeviceCommand,
|
|
52
55
|
DeviceCommandResult,
|
|
53
56
|
DeviceInfo,
|
|
54
57
|
DeviceLocation,
|
|
55
|
-
} from "
|
|
58
|
+
} from "../types.js";
|
|
56
59
|
|
|
57
60
|
/** How a transport pushes mesh traffic to connected companion devices. */
|
|
58
61
|
export type MeshTransport = {
|
|
@@ -62,7 +65,7 @@ export type MeshTransport = {
|
|
|
62
65
|
command(command: DeviceCommand): void;
|
|
63
66
|
};
|
|
64
67
|
|
|
65
|
-
export type { MeshToolResult } from "
|
|
68
|
+
export type { MeshToolResult } from "../tool-surface.js";
|
|
66
69
|
|
|
67
70
|
/** Outcome of pinging one device (see {@link MeshService.pingAll}). */
|
|
68
71
|
export type MeshPingResult = {
|
|
@@ -88,7 +91,7 @@ export type MeshServiceOptions = {
|
|
|
88
91
|
nodeBinaryResolver?: NodeBinaryResolver;
|
|
89
92
|
};
|
|
90
93
|
|
|
91
|
-
export type { MeshBridgeInfo } from "
|
|
94
|
+
export type { MeshBridgeInfo } from "../links/bridge-links.js";
|
|
92
95
|
|
|
93
96
|
const DEFAULT_FRESH_FIX_TIMEOUT_MS = 8_000;
|
|
94
97
|
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
|
@@ -604,7 +607,7 @@ export class MeshService {
|
|
|
604
607
|
return { ok: true, text: `${p} — ${fields.join(" · ")}` };
|
|
605
608
|
}
|
|
606
609
|
|
|
607
|
-
// ── File transfer + self-update (see device-files.ts)
|
|
610
|
+
// ── File transfer + self-update (see transfers/device-files.ts) ───────────
|
|
608
611
|
|
|
609
612
|
/** POST /devices/file — a device streams a pull's file body up. */
|
|
610
613
|
acceptFileUpload(
|
|
@@ -681,7 +684,7 @@ export class MeshService {
|
|
|
681
684
|
return this.files.updateNodeBinary(query, localBinaryPath, remotePath);
|
|
682
685
|
}
|
|
683
686
|
|
|
684
|
-
// ── Provisioning + pairing (see bridge-links.ts)
|
|
687
|
+
// ── Provisioning + pairing (see links/bridge-links.ts) ────────────────────
|
|
685
688
|
|
|
686
689
|
/** `get_node_binary`: materialize a talon-node binary on the daemon host. */
|
|
687
690
|
getNodeBinary(os: unknown, arch: unknown): Promise<MeshToolResult> {
|