talon-agent 5.0.1 → 5.2.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 (107) hide show
  1. package/README.md +3 -1
  2. package/bin/talon.js +35 -0
  3. package/package.json +3 -3
  4. package/prompts/identity.md +10 -2
  5. package/prompts/system/agent-brief.md +43 -0
  6. package/src/app.ts +19 -26
  7. package/src/backend/builtins.ts +26 -7
  8. package/src/backend/claude-sdk/handler.ts +191 -69
  9. package/src/backend/claude-sdk/one-shot.ts +30 -7
  10. package/src/backend/claude-sdk/stream.ts +9 -0
  11. package/src/backend/codex/one-shot.ts +18 -4
  12. package/src/backend/remote-server/index.ts +6 -4
  13. package/src/backend/remote-server/model-catalog/index.ts +4 -10
  14. package/src/backend/remote-server/model-catalog/provider.ts +3 -3
  15. package/src/backend/remote-server/one-shot.ts +16 -3
  16. package/src/backend/remote-server/profiles/bind.ts +225 -0
  17. package/src/backend/remote-server/profiles/index.ts +10 -0
  18. package/src/backend/remote-server/profiles/kilo.ts +82 -0
  19. package/src/backend/remote-server/profiles/opencode.ts +61 -0
  20. package/src/backend/remote-server/server-bindings.ts +3 -4
  21. package/src/backend/runtime/one-shot-hooks.ts +45 -0
  22. package/src/bootstrap.ts +15 -1
  23. package/src/cli/chat.ts +5 -0
  24. package/src/cli/events.ts +9 -0
  25. package/src/core/agent-runtime/agent-host.ts +7 -6
  26. package/src/core/agent-runtime/capabilities.ts +3 -0
  27. package/src/core/agents/context.ts +48 -0
  28. package/src/core/agents/delivery.ts +167 -0
  29. package/src/core/agents/index.ts +37 -0
  30. package/src/core/agents/prompt.ts +116 -0
  31. package/src/core/agents/registry.ts +426 -0
  32. package/src/core/agents/runner.ts +448 -0
  33. package/src/core/agents/types.ts +124 -0
  34. package/src/core/background/cron/job-oneshot.ts +7 -12
  35. package/src/core/background/cron/job-prompt.ts +1 -1
  36. package/src/core/background/{cron/isolated-agent.ts → isolated-agent.ts} +45 -24
  37. package/src/core/background/run-log.ts +33 -0
  38. package/src/core/bus/events.ts +49 -2
  39. package/src/core/config/index.ts +25 -0
  40. package/src/core/engine/gateway-actions/agents/control.ts +299 -0
  41. package/src/core/engine/gateway-actions/agents/index.ts +31 -0
  42. package/src/core/engine/gateway-actions/agents/report.ts +107 -0
  43. package/src/core/engine/gateway-actions/index.ts +30 -0
  44. package/src/core/engine/gateway-actions/native/exec-remote.ts +1 -1
  45. package/src/core/engine/gateway-actions/native/exec.ts +1 -1
  46. package/src/core/engine/gateway-actions/native/read.ts +1 -1
  47. package/src/core/engine/gateway-actions/native/search.ts +1 -1
  48. package/src/core/engine/gateway-actions/native/teleport.ts +1 -1
  49. package/src/core/engine/gateway-actions/native/write.ts +1 -1
  50. package/src/core/engine/gateway-routes.ts +12 -0
  51. package/src/core/engine/gateway.ts +96 -25
  52. package/src/core/frontend-runtime/capabilities.ts +18 -0
  53. package/src/core/frontend-runtime/index.ts +4 -0
  54. package/src/core/frontend-runtime/lifecycle.ts +33 -0
  55. package/src/core/frontend-runtime/registry.ts +3 -3
  56. package/src/core/frontend-runtime/run-loop.ts +59 -0
  57. package/src/core/mcp-hub/children.ts +21 -5
  58. package/src/core/mesh/{registry.ts → devices/registry.ts} +4 -4
  59. package/src/core/mesh/{service.ts → devices/service.ts} +13 -10
  60. package/src/core/mesh/{teleport.ts → devices/teleport.ts} +2 -2
  61. package/src/core/mesh/index.ts +6 -2
  62. package/src/core/mesh/{bridge-links.ts → links/bridge-links.ts} +1 -1
  63. package/src/core/mesh/{companion-pairing.ts → links/companion-pairing.ts} +1 -1
  64. package/src/core/mesh/{node-binaries.ts → links/node-binaries.ts} +5 -5
  65. package/src/core/mesh/{node-provision.ts → links/node-provision.ts} +1 -1
  66. package/src/core/mesh/{common.ts → tool-surface.ts} +7 -2
  67. package/src/core/mesh/{device-files.ts → transfers/device-files.ts} +5 -5
  68. package/src/core/prompt/embedded-prompts.ts +38 -36
  69. package/src/core/tasks/types.ts +2 -2
  70. package/src/core/tools/index.ts +5 -0
  71. package/src/core/tools/ops/agents.ts +195 -0
  72. package/src/core/tools/ops/bridge.ts +4 -0
  73. package/src/core/tools/types.ts +1 -0
  74. package/src/core/types.ts +12 -1
  75. package/src/frontend/discord/commands/info.ts +1 -1
  76. package/src/frontend/discord/render.ts +1 -1
  77. package/src/frontend/native/bridge/routes/mesh.ts +1 -1
  78. package/src/frontend/native/index.ts +2 -0
  79. package/src/frontend/presentation/reports.ts +1 -1
  80. package/src/frontend/teams/index.ts +4 -3
  81. package/src/frontend/telegram/commands/info.ts +61 -20
  82. package/src/frontend/telegram/index.ts +27 -4
  83. package/src/frontend/telegram/render/reports.ts +1 -1
  84. package/src/frontend/terminal/index.ts +6 -2
  85. package/src/frontend/whatsapp/connection/connection.ts +62 -11
  86. package/src/frontend/whatsapp/index.ts +25 -1
  87. package/src/frontend/whatsapp/runtime.ts +7 -0
  88. package/src/util/log.ts +1 -0
  89. package/src/backend/kilo/factory.ts +0 -53
  90. package/src/backend/kilo/handler/index.ts +0 -2
  91. package/src/backend/kilo/handler/message.ts +0 -44
  92. package/src/backend/kilo/index.ts +0 -61
  93. package/src/backend/kilo/model-provider.ts +0 -36
  94. package/src/backend/kilo/models/index.ts +0 -55
  95. package/src/backend/kilo/one-shot.ts +0 -42
  96. package/src/backend/kilo/server.ts +0 -98
  97. package/src/backend/kilo/sessions.ts +0 -37
  98. package/src/backend/opencode/factory.ts +0 -53
  99. package/src/backend/opencode/handler/index.ts +0 -2
  100. package/src/backend/opencode/handler/message.ts +0 -44
  101. package/src/backend/opencode/index.ts +0 -42
  102. package/src/backend/opencode/model-provider.ts +0 -36
  103. package/src/backend/opencode/models/index.ts +0 -54
  104. package/src/backend/opencode/one-shot.ts +0 -42
  105. package/src/backend/opencode/server.ts +0 -80
  106. package/src/backend/opencode/sessions.ts +0 -35
  107. /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
- // Chat-free actions (the device mesh) short-circuit routing entirely:
255
- // they read daemon-wide state, ignore chatId, and are the only command
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, so `start()` blocks for the process
69
- * lifetime. The composition root starts such frontends without
70
- * awaiting them when they run alongside others.
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
+ }
@@ -100,6 +100,16 @@ export function formatChildExit(
100
100
  return lines.length > 0 ? `${head}; stderr: ${lines.join(" | ")}` : head;
101
101
  }
102
102
 
103
+ /**
104
+ * Human-readable form of a child key for log lines. Keys join server name
105
+ * and chat id with a NUL byte (see `childKey` in index.ts) — unambiguous
106
+ * as a Map key, but it renders as `\u0000` in the JSON log.
107
+ */
108
+ function describeKey(key: string): string {
109
+ const nul = key.indexOf("\u0000");
110
+ return nul === -1 ? key : `${key.slice(0, nul)} chat=${key.slice(nul + 1)}`;
111
+ }
112
+
103
113
  /**
104
114
  * Bookkeeping for a child process that has gone away, asked or not.
105
115
  * Wired as the transport's `onclose` BEFORE `client.connect` so the SDK
@@ -125,7 +135,7 @@ function onChildClosed(key: string, transport: HubChildTransport): void {
125
135
  : "died before registration";
126
136
  logWarn(
127
137
  "gateway",
128
- `hub child ${key} ${phase} (pid ${transport.pid ?? "?"}): ${formatChildExit(record)}`,
138
+ `hub child ${describeKey(key)} ${phase} (pid ${transport.pid ?? "?"}): ${formatChildExit(record)}`,
129
139
  );
130
140
  }
131
141
 
@@ -199,7 +209,10 @@ async function spawnChild(key: string, spec: ChildSpec): Promise<ChildHandle> {
199
209
  try {
200
210
  await client.close();
201
211
  } catch (err) {
202
- logWarn("gateway", `hub child ${key} close failed: ${String(err)}`);
212
+ logWarn(
213
+ "gateway",
214
+ `hub child ${describeKey(key)} close failed: ${String(err)}`,
215
+ );
203
216
  }
204
217
  })());
205
218
  })(),
@@ -226,7 +239,10 @@ async function spawnChild(key: string, spec: ChildSpec): Promise<ChildHandle> {
226
239
  };
227
240
 
228
241
  children.set(key, entry);
229
- log("gateway", `hub child started: ${key} (pid ${transport.pid ?? "?"})`);
242
+ log(
243
+ "gateway",
244
+ `hub child started: ${describeKey(key)} (pid ${transport.pid ?? "?"})`,
245
+ );
230
246
  return entry.handle;
231
247
  }
232
248
 
@@ -327,9 +343,9 @@ function reapIdle(): void {
327
343
  if (entry.lastActivity >= cutoff) continue;
328
344
  children.delete(key);
329
345
  entry.close().catch((err) => {
330
- logError("gateway", `hub reap of ${key} failed`, err);
346
+ logError("gateway", `hub reap of ${describeKey(key)} failed`, err);
331
347
  });
332
- log("gateway", `hub child reaped (idle): ${key}`);
348
+ log("gateway", `hub child reaped (idle): ${describeKey(key)}`);
333
349
  }
334
350
  }
335
351
 
@@ -8,9 +8,9 @@
8
8
  */
9
9
 
10
10
  import { resolve } from "node:path";
11
- import { logWarn } from "../../util/log.js";
12
- import { dirs } from "../../util/paths.js";
13
- import { readArray, writePrivateJson } from "./persist.js";
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 "./types.js";
21
+ } from "../types.js";
22
22
 
23
23
  /**
24
24
  * A device is offline once it misses several heartbeats. The companion