talon-agent 3.4.1 → 3.6.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/README.md CHANGED
@@ -126,7 +126,7 @@ index.ts Composition root
126
126
  +-- util/ Config, logging, workspace, paths, time
127
127
  ```
128
128
 
129
- **Dependency rule:** `core/` imports nothing from `frontend/` or `backend/`. Frontends and backends depend on core types, never on each other. All five backends (Claude SDK, Kilo, OpenCode, Codex, OpenAI Agents) implement the same `Backend` capability interface from `core/agent-runtime/capabilities.ts`. Kilo and OpenCode additionally share the `remote-server/` infrastructure because they wrap forks of the same upstream HTTP agent server.
129
+ **Dependency rule:** `core/` imports nothing from `frontend/` or `backend/`. Frontends and backends depend on core types, never on each other. All five backends (Claude SDK, Kilo, OpenCode, Codex, OpenAI Agents) implement the same `Backend` capability interface from `core/agent-runtime/capabilities.ts`. Frontends mirror this: each implements the `Frontend` contract from `core/frontend-runtime/capabilities.ts` and self-registers in the frontend registry (identity + chat-id routing in a descriptor, lazy `create` in a per-frontend `factory.ts`) — see [docs/frontends.md](docs/frontends.md). Kilo and OpenCode additionally share the `remote-server/` infrastructure because they wrap forks of the same upstream HTTP agent server.
130
130
 
131
131
  **Prompts:** everything the model reads at session start is assembled by `core/prompt/` from the files in `prompts/` — see [prompts/README.md](prompts/README.md) for the assembly order, file ownership (user-editable vs package-owned templates), and the per-backend delivery contracts.
132
132
 
@@ -150,7 +150,7 @@ The Kilo and OpenCode backends share infrastructure (`backend/remote-server/`) s
150
150
 
151
151
  ## Desktop & mobile app
152
152
 
153
- The `desktop` frontend turns the daemon into a **client bridge** — a versioned HTTP + Server-Sent-Events JSON API (the _Talon Client Bridge Protocol_, `src/frontend/desktop/protocol.ts`) that any GUI client can speak. The reference client is **[Talon Companion](apps/companion/)**, a single Flutter codebase that runs on **Windows, macOS, Linux, and Android**.
153
+ The `desktop` frontend turns the daemon into a **client bridge** — a versioned HTTP + Server-Sent-Events JSON API (the _Talon Client Bridge Protocol_, `src/frontend/native/protocol.ts`) that any GUI client can speak. The reference client is **[Talon Companion](apps/companion/)**, a single Flutter codebase that runs on **Windows, macOS, Linux, and Android**. The protocol has three independent implementations (daemon, companion, [talon-node](apps/node/)); shared wire fixtures in [protocol/](protocol/) are replayed by all three test suites so a drift on any side fails its CI — see [protocol/README.md](protocol/README.md).
154
154
 
155
155
  ```jsonc
156
156
  // ~/.talon/config.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.4.1",
3
+ "version": "3.6.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -2,6 +2,6 @@
2
2
 
3
3
  For outcomes that outlive this conversation ("get the release out", "keep chasing that refund until it lands"): commit to a goal with `add_goal`. Goals survive restarts, and the background heartbeat agent re-reads every open goal on each of its runs, makes incremental progress, and records what it did — so a goal keeps moving even when nobody is chatting. Record progress with `update_goal` (always leave a `progress_note` — the next run starts from it), and close goals out with status `completed` or `abandoned`. Manage with `list_goals`, `delete_goal`.
4
4
 
5
- Rule of thumb: time-driven → cron; condition-watching → trigger; outcome-driven, needs judgment across multiple sessions → goal. When the user asks you to pursue or keep track of something long-running, create a goal rather than relying on conversation memory. Limit: {{maxOpenGoals}} open goals per chat.
5
+ Rule of thumb: time-driven → cron; condition-watching → trigger; outcome-driven, needs judgment across multiple sessions → goal. When the user asks you to pursue or keep track of something long-running, create a goal rather than relying on conversation memory. There is no cap on open goals create as many as you genuinely need, and keep the list healthy by closing finished ones (`completed`/`abandoned`).
6
6
 
7
7
  **Never promise without a mechanism.** If you tell someone you'll do something later — "I'll check back on that", "I'll remind you tomorrow", "I'll keep an eye on it" — create the goal, cron job, or trigger in the SAME turn. A promise that lives only in conversation text evaporates when the turn ends; the user hears a commitment, so back it with the machinery that actually keeps it.
package/src/app.ts CHANGED
@@ -31,7 +31,15 @@ import {
31
31
  } from "./core/vfs/index.js";
32
32
  import { bootstrap, initBackendAndDispatcher } from "./bootstrap.js";
33
33
  import { Gateway } from "./core/engine/gateway.js";
34
+ import {
35
+ createFrontendById,
36
+ getFrontendDescriptor,
37
+ } from "./core/frontend-runtime/index.js";
34
38
  import type { Frontend } from "./bootstrap.js";
39
+ // Attach every built-in frontend's create() to its registry descriptor.
40
+ // Adding a frontend is strictly additive: drop a factory.ts under the
41
+ // new frontend dir and list it in frontend/factories.ts.
42
+ import "./frontend/factories.js";
35
43
 
36
44
  // ── Bootstrap ────────────────────────────────────────────────────────────────
37
45
 
@@ -55,45 +63,13 @@ gateway.onStarted((port) =>
55
63
  );
56
64
  gateway.onShutdownRequest((reason) => void gracefulShutdown(reason));
57
65
 
58
- const configuredFrontends = [
59
- ...new Set(getFrontends(config)),
60
- ] as Frontend["name"][];
61
-
62
- async function createFrontend(name: Frontend["name"]): Promise<Frontend> {
63
- switch (name) {
64
- case "terminal": {
65
- const { createTerminalFrontend } =
66
- await import("./frontend/terminal/index.js");
67
- return createTerminalFrontend(config, gateway);
68
- }
69
- case "teams": {
70
- const { createTeamsFrontend } = await import("./frontend/teams/index.js");
71
- return createTeamsFrontend(config, gateway);
72
- }
73
- case "discord": {
74
- const { createDiscordFrontend } =
75
- await import("./frontend/discord/index.js");
76
- return createDiscordFrontend(config, gateway);
77
- }
78
- case "native": {
79
- const { createNativeFrontend } =
80
- await import("./frontend/native/index.js");
81
- return createNativeFrontend(config, gateway);
82
- }
83
- case "telegram":
84
- default: {
85
- const { createTelegramFrontend } =
86
- await import("./frontend/telegram/index.js");
87
- return createTelegramFrontend(config, gateway);
88
- }
89
- }
90
- }
66
+ const configuredFrontends = [...new Set(getFrontends(config))];
91
67
 
92
68
  const frontends: Frontend[] = [];
93
69
  for (const name of configuredFrontends) {
94
- const frontend = await createFrontend(name);
70
+ const frontend = await createFrontendById(name, config, gateway);
95
71
  frontends.push(frontend);
96
- log("bot", `Frontend: ${name[0].toUpperCase()}${name.slice(1)}`);
72
+ log("bot", `Frontend: ${getFrontendDescriptor(name)?.label ?? name}`);
97
73
  }
98
74
 
99
75
  // ── Create backend + wire dispatcher ─────────────────────────────────────────
@@ -249,20 +225,22 @@ async function main(): Promise<void> {
249
225
  startWatchdog(config.workspace);
250
226
  startUploadCleanup(config.workspace);
251
227
 
252
- const terminalFrontends = frontends.filter(
253
- (frontend) => frontend.name === "terminal",
228
+ // A stdin-reading frontend (terminal) blocks in start() for the
229
+ // process lifetime run it without awaiting alongside the others.
230
+ const stdinFrontends = frontends.filter(
231
+ (frontend) => getFrontendDescriptor(frontend.name)?.sharesStdin === true,
254
232
  );
255
233
  const blockingFrontends = frontends.filter(
256
- (frontend) => frontend.name !== "terminal",
234
+ (frontend) => !stdinFrontends.includes(frontend),
257
235
  );
258
- if (terminalFrontends.length > 0 && frontends.length > 1) {
236
+ if (stdinFrontends.length > 0 && frontends.length > 1) {
259
237
  log(
260
238
  "bot",
261
239
  "Terminal frontend shares stdin with the other frontends; it will run alongside them without blocking startup.",
262
240
  );
263
241
  }
264
242
  await Promise.all(blockingFrontends.map((frontend) => frontend.start()));
265
- for (const frontend of terminalFrontends) {
243
+ for (const frontend of stdinFrontends) {
266
244
  void frontend
267
245
  .start()
268
246
  .catch((err) =>
@@ -2,44 +2,41 @@
2
2
  * Frontend-list helpers shared across backends.
3
3
  *
4
4
  * Every backend needs "which frontends get an MCP tool server?" at
5
- * spawn time; before this helper, claude-sdk, openai-agents, and codex
6
- * each carried their own copy of the same normalise-and-filter logic.
5
+ * spawn time. Identity, chat-id ownership, and the messaging trait are
6
+ * owned by the frontend registry (`core/frontend-runtime`) these
7
+ * helpers are thin views over it, kept for their call-site-friendly
8
+ * shapes.
7
9
  */
8
10
 
9
11
  import {
10
- isNativeChatId,
11
- isTeamsChatId,
12
- isDiscordChatId,
13
- isTelegramChatId,
14
- } from "../../util/chat-id.js";
12
+ getFrontendDescriptor,
13
+ resolveOwnerFrontendId,
14
+ } from "../../core/frontend-runtime/routing.js";
15
15
 
16
16
  /**
17
17
  * Normalise a config `frontend` value (string or array, possibly
18
18
  * undefined) to the list of messaging frontends — i.e. those that
19
19
  * need an MCP tool server spawned. `terminal` is excluded: it has no
20
- * outbound messaging surface (the agent runs to stdout).
20
+ * outbound messaging surface (the agent runs to stdout). Unknown ids
21
+ * are kept — an unregistered frontend must fail loudly downstream,
22
+ * not silently lose its tools.
21
23
  */
22
24
  export function nonTerminalFrontends(
23
25
  frontend: string | readonly string[] | undefined,
24
26
  ): readonly string[] {
25
27
  if (!frontend) return [];
26
28
  const all = Array.isArray(frontend) ? frontend : [frontend as string];
27
- return all.filter((f) => f !== "terminal");
29
+ return all.filter((f) => getFrontendDescriptor(f)?.messaging !== false);
28
30
  }
29
31
 
30
32
  /**
31
33
  * The messaging frontend that owns a chat, inferred from the chat-id
32
- * shape (the same convention the gateway uses to route actions):
33
- * native `d_*`, teams `teams_chat_*`, discord `discord_*`, telegram
34
- * numeric. Returns null for cross-surface contexts — the heartbeat
34
+ * shape (the same registry matchers the gateway uses to route
35
+ * actions). Returns null for cross-surface contexts — the heartbeat
35
36
  * sentinel, isolated one-shots, terminal sessions.
36
37
  */
37
38
  export function frontendForChatId(chatId: string): string | null {
38
- if (isNativeChatId(chatId)) return "native";
39
- if (isTeamsChatId(chatId)) return "teams";
40
- if (isDiscordChatId(chatId)) return "discord";
41
- if (isTelegramChatId(chatId)) return "telegram";
42
- return null;
39
+ return resolveOwnerFrontendId(chatId);
43
40
  }
44
41
 
45
42
  /**
package/src/bootstrap.ts CHANGED
@@ -34,70 +34,37 @@ import { initDream, maybeStartDream } from "./core/background/dream.js";
34
34
  import { initHeartbeat } from "./core/background/heartbeat/index.js";
35
35
  import { log, logWarn, logDebug } from "./util/log.js";
36
36
  import type { TalonConfig } from "./util/config.js";
37
- import {
38
- isNativeChatId,
39
- isDiscordChatId,
40
- isTelegramChatId,
41
- isTerminalChatId,
42
- isTeamsChatId,
43
- } from "./util/chat-id.js";
37
+ import { resolveFrontendIdAmong } from "./core/frontend-runtime/routing.js";
38
+ import type { Frontend } from "./core/frontend-runtime/index.js";
44
39
  import type { ContextManager } from "./core/types.js";
45
40
  import type { Backend } from "./core/agent-runtime/capabilities.js";
46
41
 
47
42
  // ── Types ────────────────────────────────────────────────────────────────────
48
43
 
49
- export type Frontend = {
50
- name: "telegram" | "terminal" | "teams" | "discord" | "native";
51
- context: ContextManager;
52
- sendTyping: (chatId: number) => Promise<void>;
53
- sendMessage: (chatId: number, text: string) => Promise<void>;
54
- getBridgePort: () => number;
55
- init: () => Promise<void>;
56
- start: () => Promise<void>;
57
- stop: () => Promise<void>;
58
- };
44
+ // The Frontend contract moved to core/frontend-runtime/capabilities.ts
45
+ // (the frontend counterpart of agent-runtime). Re-exported so existing
46
+ // importers keep working.
47
+ export type { Frontend } from "./core/frontend-runtime/index.js";
59
48
 
60
49
  type FrontendSelection = Frontend | Frontend[];
61
50
 
62
51
  function normalizeFrontends(frontend: FrontendSelection): Frontend[] {
63
52
  const list = Array.isArray(frontend) ? frontend : [frontend];
64
- const byName = new Map<Frontend["name"], Frontend>();
53
+ const byName = new Map<string, Frontend>();
65
54
  for (const item of list) byName.set(item.name, item);
66
55
  return [...byName.values()];
67
56
  }
68
57
 
69
- function resolveFrontendName(
70
- chatId: string | undefined,
71
- frontends: Frontend[],
72
- ): Frontend["name"] {
73
- if (frontends.length === 1) return frontends[0].name;
74
- if (chatId) {
75
- if (
76
- isTerminalChatId(chatId) &&
77
- frontends.some((f) => f.name === "terminal")
78
- )
79
- return "terminal";
80
- if (isNativeChatId(chatId) && frontends.some((f) => f.name === "native"))
81
- return "native";
82
- if (isTeamsChatId(chatId) && frontends.some((f) => f.name === "teams"))
83
- return "teams";
84
- if (isDiscordChatId(chatId) && frontends.some((f) => f.name === "discord"))
85
- return "discord";
86
- if (
87
- isTelegramChatId(chatId) &&
88
- frontends.some((f) => f.name === "telegram")
89
- )
90
- return "telegram";
91
- }
92
- const firstNonTerminal = frontends.find((f) => f.name !== "terminal");
93
- return firstNonTerminal?.name ?? frontends[0].name;
94
- }
95
-
96
58
  function resolveFrontend(
97
59
  chatId: string | undefined,
98
60
  frontends: Frontend[],
99
61
  ): Frontend {
100
- const name = resolveFrontendName(chatId, frontends);
62
+ // Chat-id ownership and fallback order live in the frontend registry
63
+ // (one source of truth shared with gateway routing and MCP scoping).
64
+ const name = resolveFrontendIdAmong(
65
+ chatId,
66
+ frontends.map((f) => f.name),
67
+ );
101
68
  const resolved = frontends.find((frontend) => frontend.name === name);
102
69
  if (!resolved) {
103
70
  throw new Error(`No frontend available for ${chatId ?? "unknown chat"}`);
package/src/cli/chat.ts CHANGED
@@ -9,8 +9,9 @@ export async function startChat(): Promise<void> {
9
9
  const { bootstrap, initBackendAndDispatcher } =
10
10
  await import("../bootstrap.js");
11
11
  const { flushDatabase } = await import("../storage/db.js");
12
- const { createTerminalFrontend } =
13
- await import("../frontend/terminal/index.js");
12
+ const { createFrontendById } =
13
+ await import("../core/frontend-runtime/index.js");
14
+ await import("../frontend/terminal/factory.js");
14
15
  const { Gateway } = await import("../core/engine/gateway.js");
15
16
 
16
17
  const { config } = await bootstrap({ frontendNames: ["terminal"] });
@@ -25,7 +26,7 @@ export async function startChat(): Promise<void> {
25
26
  rebuildSystemPrompt(config, getPluginPromptAdditions());
26
27
 
27
28
  const gateway = new Gateway("chat");
28
- const frontend = createTerminalFrontend(config, gateway);
29
+ const frontend = await createFrontendById("terminal", config, gateway);
29
30
  await frontend.init();
30
31
  const { backend } = await initBackendAndDispatcher(config, frontend);
31
32
  gateway.backend = backend;
@@ -34,9 +34,13 @@ import type { TalonConfig } from "../../util/config.js";
34
34
 
35
35
  // ── Types ───────────────────────────────────────────────────────────────────
36
36
 
37
- /** Frontend identifiers that can be passed to a backend's init step. */
38
- export type FrontendName =
39
- "telegram" | "terminal" | "teams" | "discord" | "native";
37
+ /**
38
+ * Frontend identifier passed to a backend's init step. An open set:
39
+ * ids come from the frontend registry (`core/frontend-runtime`), where
40
+ * the built-ins (telegram, terminal, teams, discord, native) register
41
+ * their descriptors and plugin frontends can add more.
42
+ */
43
+ export type FrontendName = string;
40
44
 
41
45
  /** Per-init context — runtime dependencies the backend may need at startup. */
42
46
  export interface BackendInitContext {
@@ -5,7 +5,6 @@
5
5
 
6
6
  import {
7
7
  addGoal,
8
- countOpenGoalsForChat,
9
8
  deleteGoal,
10
9
  formatGoal,
11
10
  generateGoalId,
@@ -18,7 +17,6 @@ import {
18
17
  validateProgressNote,
19
18
  validateTitle,
20
19
  GOAL_STATUSES,
21
- MAX_OPEN_GOALS_PER_CHAT,
22
20
  OPEN_GOAL_STATUSES,
23
21
  type Goal,
24
22
  type GoalStatus,
@@ -51,13 +49,8 @@ export const goalHandlers: SharedActionHandlers = {
51
49
  }
52
50
 
53
51
  const chatIdStr = String(chatId);
54
- const openCount = countOpenGoalsForChat(chatIdStr);
55
- if (openCount >= MAX_OPEN_GOALS_PER_CHAT) {
56
- return {
57
- ok: false,
58
- error: `Per-chat open-goal cap reached (${MAX_OPEN_GOALS_PER_CHAT}). Complete or abandon one before adding another.`,
59
- };
60
- }
52
+ // No open-goal cap: goals grow freely. The heartbeat re-reads every open
53
+ // goal, so keep them tidy by closing finished ones (completed/abandoned).
61
54
 
62
55
  const now = Date.now();
63
56
  const goal: Goal = {
@@ -32,13 +32,7 @@ import { taskTable } from "../tasks/index.js";
32
32
  import type { FrontendActionHandler } from "../types.js";
33
33
  import { BOT_MESSAGE_ACTIONS, noteBotMessage } from "../soul/taps.js";
34
34
  import type { Backend } from "../agent-runtime/capabilities.js";
35
- import {
36
- isNativeChatId,
37
- isDiscordChatId,
38
- isTelegramChatId,
39
- isTerminalChatId,
40
- isTeamsChatId,
41
- } from "../../util/chat-id.js";
35
+ import { resolveOwnerFrontendId } from "../frontend-runtime/routing.js";
42
36
 
43
37
  // ── Retry helper (stateless — standalone export) ─────────────────────────────
44
38
 
@@ -169,12 +163,9 @@ export class Gateway {
169
163
  ): string | null {
170
164
  const owned = this.chatFrontendOwners.get(chatId);
171
165
  if (owned) return owned;
172
- if (isTerminalChatId(rawChatId)) return "terminal";
173
- if (isNativeChatId(rawChatId)) return "native";
174
- if (isTeamsChatId(rawChatId)) return "teams";
175
- if (isDiscordChatId(rawChatId)) return "discord";
176
- if (isTelegramChatId(rawChatId)) return "telegram";
177
- return null;
166
+ // Shape-convention fallback — the frontend registry owns chat-id
167
+ // matchers, including any registered at runtime.
168
+ return resolveOwnerFrontendId(rawChatId, { includeNonMessaging: true });
178
169
  }
179
170
 
180
171
  private resolveFrontendHandler(
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Built-in frontend descriptors.
3
+ *
4
+ * Identity only — no frontend implementation is imported here (core
5
+ * never imports frontend/). The heavy `create` half of each built-in is
6
+ * attached by `src/frontend/<id>/factory.ts` when the composition root
7
+ * imports `src/frontend/factories.ts`.
8
+ *
9
+ * Chat-id shapes are the long-standing conventions from
10
+ * `util/chat-id.ts`. Priorities encode the historical resolution order
11
+ * (terminal, native, teams, discord, telegram) — load-bearing in two
12
+ * places: telegram's numeric matcher is a near-catch-all so it must run
13
+ * last, and the terminal claims the legacy chat id "1" that telegram's
14
+ * matcher would also accept.
15
+ */
16
+
17
+ import {
18
+ registerFrontendDescriptor as registerFrontend,
19
+ setBuiltinRegistrar,
20
+ } from "./registry.js";
21
+ import {
22
+ isDiscordChatId,
23
+ isNativeChatId,
24
+ isTeamsChatId,
25
+ isTelegramChatId,
26
+ isTerminalChatId,
27
+ } from "../../util/chat-id.js";
28
+
29
+ function registerBuiltinFrontends(): void {
30
+ registerFrontend({
31
+ id: "terminal",
32
+ label: "Terminal",
33
+ ownsChatId: isTerminalChatId,
34
+ routePriority: 10,
35
+ messaging: false,
36
+ sharesStdin: true,
37
+ });
38
+ registerFrontend({
39
+ id: "native",
40
+ label: "Native",
41
+ ownsChatId: isNativeChatId,
42
+ routePriority: 20,
43
+ messaging: true,
44
+ });
45
+ registerFrontend({
46
+ id: "teams",
47
+ label: "Teams",
48
+ ownsChatId: isTeamsChatId,
49
+ routePriority: 30,
50
+ messaging: true,
51
+ });
52
+ registerFrontend({
53
+ id: "discord",
54
+ label: "Discord",
55
+ ownsChatId: isDiscordChatId,
56
+ routePriority: 40,
57
+ messaging: true,
58
+ });
59
+ registerFrontend({
60
+ id: "telegram",
61
+ label: "Telegram",
62
+ ownsChatId: isTelegramChatId,
63
+ routePriority: 90,
64
+ messaging: true,
65
+ });
66
+ }
67
+
68
+ setBuiltinRegistrar(registerBuiltinFrontends);
69
+ registerBuiltinFrontends();
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Frontend capability contract — the frontend counterpart of
3
+ * `core/agent-runtime/capabilities.ts`.
4
+ *
5
+ * A frontend is a chat surface (Telegram, Discord, Teams, the terminal,
6
+ * the native client bridge, …) that receives user messages and delivers
7
+ * the agent's replies. Every frontend implements the same `Frontend`
8
+ * runtime interface and describes itself with a `FrontendDescriptor` in
9
+ * the frontend registry (`registry.ts`), so the engine can create, route
10
+ * to, and reason about frontends without knowing any concrete one.
11
+ *
12
+ * The contract is split in two on purpose:
13
+ *
14
+ * - `FrontendDescriptor` (defined in `registry.ts`) — cheap, static
15
+ * identity: id, label, chat-id ownership, routing traits.
16
+ * Descriptors for the built-ins register from core (`builtins.ts`)
17
+ * so every subsystem that only needs "whose chat id is this?"
18
+ * (gateway routing, MCP tool scoping, dispatcher context) can
19
+ * consult the registry without loading any frontend implementation.
20
+ * - `FrontendCreate` — the heavy part. Attached separately by each
21
+ * frontend's `factory.ts` (frontend layer), which dynamically
22
+ * imports the implementation only when that frontend is actually
23
+ * configured. Plugin frontends register a descriptor and create
24
+ * function together at runtime (`registerFrontend` in `create.ts`).
25
+ */
26
+
27
+ import type { ContextManager } from "../types.js";
28
+ import type { TalonConfig } from "../../util/config.js";
29
+ import type { Gateway } from "../engine/gateway.js";
30
+ import type { FrontendDescriptor } from "./registry.js";
31
+
32
+ /**
33
+ * The runtime interface every frontend implements (moved here from
34
+ * `bootstrap.ts`; `bootstrap.ts` re-exports it for existing importers).
35
+ * Lifecycle: `create → init → start → stop`.
36
+ */
37
+ export type Frontend = {
38
+ /** Registry id of the frontend that created this instance. */
39
+ name: string;
40
+ context: ContextManager;
41
+ sendTyping: (chatId: number) => Promise<void>;
42
+ sendMessage: (chatId: number, text: string) => Promise<void>;
43
+ getBridgePort: () => number;
44
+ init: () => Promise<void>;
45
+ start: () => Promise<void>;
46
+ stop: () => Promise<void>;
47
+ };
48
+
49
+ /**
50
+ * Creates the runtime instance for a frontend. Implementations should
51
+ * dynamically import their heavy dependencies here so an unconfigured
52
+ * frontend costs nothing at boot.
53
+ */
54
+ export type FrontendCreate = (
55
+ config: TalonConfig,
56
+ gateway: Gateway,
57
+ ) => Frontend | Promise<Frontend>;
58
+
59
+ /** A fully-registered frontend: identity plus the factory. */
60
+ export type FrontendFactory = FrontendDescriptor & { create: FrontendCreate };
61
+
62
+ export type { FrontendDescriptor } from "./registry.js";
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The typed create seam of the frontend registry.
3
+ *
4
+ * `registry.ts` stores create functions opaquely so routing-only
5
+ * consumers (gateway, backend MCP scoping) never import engine types;
6
+ * this module narrows them back to the `FrontendCreate` contract for
7
+ * the two parties that do care: frontend `factory.ts` modules
8
+ * (attach) and the composition roots (create).
9
+ */
10
+
11
+ import type {
12
+ Frontend,
13
+ FrontendCreate,
14
+ FrontendFactory,
15
+ } from "./capabilities.js";
16
+ import { TalonError } from "../errors.js";
17
+ import {
18
+ attachOpaqueCreate,
19
+ getOpaqueCreate,
20
+ hasFrontend,
21
+ knownIds,
22
+ registerFrontendDescriptor,
23
+ } from "./registry.js";
24
+
25
+ /**
26
+ * Attach the create function to an already-registered descriptor. Each
27
+ * built-in frontend's `factory.ts` calls this; importing that module is
28
+ * what makes the frontend creatable.
29
+ */
30
+ export function attachFrontendCreate(id: string, create: FrontendCreate): void {
31
+ attachOpaqueCreate(id, create);
32
+ }
33
+
34
+ /**
35
+ * Register a complete frontend — descriptor and create together. The
36
+ * one-call path for plugin frontends.
37
+ */
38
+ export function registerFrontend(factory: FrontendFactory): void {
39
+ const { create, ...descriptor } = factory;
40
+ registerFrontendDescriptor(descriptor, create);
41
+ }
42
+
43
+ /**
44
+ * Create a frontend instance by id. Throws when the id is unknown or
45
+ * its factory module was never imported — both are wiring bugs worth a
46
+ * loud, early failure.
47
+ */
48
+ export async function createFrontendById(
49
+ id: string,
50
+ ...args: Parameters<FrontendCreate>
51
+ ): Promise<Frontend> {
52
+ if (!hasFrontend(id)) {
53
+ throw new TalonError(`Unknown frontend "${id}" (known: ${knownIds()})`, {
54
+ reason: "bad_request",
55
+ });
56
+ }
57
+ const create = getOpaqueCreate(id) as FrontendCreate | undefined;
58
+ if (!create) {
59
+ throw new TalonError(
60
+ `Frontend "${id}" has no factory attached — is its factory module imported?`,
61
+ { reason: "bad_request" },
62
+ );
63
+ }
64
+ return create(...args);
65
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Frontend runtime — capability contract + registry (the frontend
3
+ * counterpart of `core/agent-runtime`). Import THIS module, not
4
+ * `registry.js` directly: loading it guarantees the built-in
5
+ * descriptors are registered. Routing-only consumers inside the engine
6
+ * import `routing.js` instead, which offers the same guarantee without
7
+ * the engine-typed create seam.
8
+ */
9
+
10
+ import "./builtins.js";
11
+
12
+ export type {
13
+ Frontend,
14
+ FrontendCreate,
15
+ FrontendDescriptor,
16
+ FrontendFactory,
17
+ } from "./capabilities.js";
18
+ export {
19
+ getFrontendDescriptor,
20
+ hasFrontend,
21
+ listFrontends,
22
+ resetFrontendRegistry,
23
+ resolveFrontendIdAmong,
24
+ resolveOwnerFrontendId,
25
+ } from "./registry.js";
26
+ export {
27
+ attachFrontendCreate,
28
+ createFrontendById,
29
+ registerFrontend,
30
+ } from "./create.js";
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Frontend registry — the frontend counterpart of
3
+ * `core/agent-runtime/backend-registry.ts`.
4
+ *
5
+ * One typed `id → descriptor` map that owns two things the codebase
6
+ * used to hardcode in parallel chains (bootstrap dispatch routing,
7
+ * gateway action routing, backend MCP tool scoping, the composition
8
+ * root's creation switch):
9
+ *
10
+ * 1. **Identity + chat-id ownership.** "Whose chat id is this?" is
11
+ * answered once, here, from the registered descriptors' matchers in
12
+ * `routePriority` order — instead of five copies of the same
13
+ * if/else chain drifting apart.
14
+ * 2. **Creation.** The composition root looks a frontend up by id and
15
+ * calls its attached create — adding a frontend means one new
16
+ * `factory.ts`, no switch churn.
17
+ *
18
+ * Registration is split to respect layering (core never imports
19
+ * frontend/): built-in *descriptors* register from core
20
+ * (`builtins.ts`), and each frontend's `factory.ts` attaches the heavy
21
+ * create via `attachFrontendCreate` (`create.ts`). External plugin
22
+ * frontends register descriptor + create in one call with
23
+ * `registerFrontend`.
24
+ *
25
+ * This module is deliberately self-contained (no imports from
26
+ * `capabilities.ts` or the engine): the create function is stored
27
+ * opaquely here and typed at the `create.ts` seam, so routing-only
28
+ * consumers (gateway, backend MCP scoping) never pull the engine types
29
+ * into an import cycle.
30
+ *
31
+ * The registry is module-scoped; `resetFrontendRegistry()` restores the
32
+ * built-in descriptors for test isolation.
33
+ */
34
+
35
+ import { TalonError } from "../errors.js";
36
+
37
+ /**
38
+ * Static identity + routing traits for one frontend. Registered before
39
+ * config is even loaded, so it must stay dependency-light: no SDK
40
+ * imports, no I/O — predicates and flags only.
41
+ */
42
+ export type FrontendDescriptor = {
43
+ /** Stable identifier — matches `config.frontend` entries. */
44
+ id: string;
45
+ /** Display label for logs and status output (e.g. "Telegram"). */
46
+ label: string;
47
+ /**
48
+ * Whether this frontend owns a chat id, by its id-shape convention
49
+ * (native `d_*`, teams `teams_chat_*`, discord `discord_*`, telegram
50
+ * numeric, terminal `t_*`). Shapes should be disjoint; where two
51
+ * matchers could overlap, `routePriority` breaks the tie.
52
+ */
53
+ ownsChatId: (chatId: string) => boolean;
54
+ /**
55
+ * Tie-break order for chat-id resolution — lower checks first. Broad
56
+ * matchers (telegram accepts any numeric id) belong at the high end
57
+ * so specific shapes always win.
58
+ */
59
+ routePriority: number;
60
+ /**
61
+ * Has an outbound messaging surface — i.e. gets a per-frontend MCP
62
+ * tool server (`<id>-tools`) so the model can address the surface
63
+ * explicitly. False for the terminal: the agent runs to stdout and
64
+ * has nothing to deliver out-of-band.
65
+ */
66
+ messaging: boolean;
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.
71
+ */
72
+ sharesStdin?: boolean;
73
+ };
74
+
75
+ /**
76
+ * The create function as stored here: opaque. `create.ts` narrows it
77
+ * back to the typed `FrontendCreate` — keeping engine types out of
78
+ * this module so routing-only importers stay cycle-free.
79
+ */
80
+ type OpaqueCreate = (...args: never[]) => unknown;
81
+
82
+ type Entry = FrontendDescriptor & { create?: OpaqueCreate };
83
+
84
+ const frontends = new Map<string, Entry>();
85
+
86
+ /**
87
+ * Register a frontend descriptor (optionally with a create function
88
+ * already attached, as plugin frontends do via `registerFrontend` in
89
+ * `create.ts`). Throws on duplicate id — fail at startup rather than
90
+ * silently shadowing an implementation.
91
+ */
92
+ export function registerFrontendDescriptor(
93
+ descriptor: FrontendDescriptor,
94
+ create?: OpaqueCreate,
95
+ ): void {
96
+ if (frontends.has(descriptor.id)) {
97
+ throw new TalonError(
98
+ `Frontend "${descriptor.id}" already registered — duplicate registration`,
99
+ { reason: "bad_request" },
100
+ );
101
+ }
102
+ frontends.set(descriptor.id, { ...descriptor, create });
103
+ }
104
+
105
+ /** @internal typed seam in create.ts — use `attachFrontendCreate`. */
106
+ export function attachOpaqueCreate(id: string, create: OpaqueCreate): void {
107
+ const entry = frontends.get(id);
108
+ if (!entry) {
109
+ throw new TalonError(
110
+ `Cannot attach factory: frontend "${id}" is not registered ` +
111
+ `(known: ${knownIds()})`,
112
+ { reason: "bad_request" },
113
+ );
114
+ }
115
+ if (entry.create) {
116
+ throw new TalonError(`Frontend "${id}" already has a factory attached`, {
117
+ reason: "bad_request",
118
+ });
119
+ }
120
+ entry.create = create;
121
+ }
122
+
123
+ /** @internal typed seam in create.ts — use `createFrontendById`. */
124
+ export function getOpaqueCreate(id: string): OpaqueCreate | undefined {
125
+ return frontends.get(id)?.create;
126
+ }
127
+
128
+ /** Look up a frontend descriptor by id. */
129
+ export function getFrontendDescriptor(
130
+ id: string,
131
+ ): FrontendDescriptor | undefined {
132
+ return frontends.get(id);
133
+ }
134
+
135
+ /** Whether a frontend with this id is registered. */
136
+ export function hasFrontend(id: string): boolean {
137
+ return frontends.has(id);
138
+ }
139
+
140
+ /** All registered descriptors, sorted by id for deterministic output. */
141
+ export function listFrontends(): FrontendDescriptor[] {
142
+ return [...frontends.values()].sort((a, b) => a.id.localeCompare(b.id));
143
+ }
144
+
145
+ /** @internal error-message helper shared with create.ts. */
146
+ export function knownIds(): string {
147
+ return [...frontends.keys()].sort().join(", ") || "none";
148
+ }
149
+
150
+ /**
151
+ * The frontend that owns a chat id by shape convention, or null for
152
+ * cross-surface contexts (the heartbeat sentinel, isolated one-shots).
153
+ * Matchers run in `routePriority` order so specific shapes beat broad
154
+ * ones (telegram's numeric matcher accepts almost anything).
155
+ *
156
+ * By default only messaging frontends are considered — the historical
157
+ * `frontendForChatId` contract used for MCP tool scoping, where
158
+ * terminal sessions deliberately resolve to null. Pass
159
+ * `includeNonMessaging: true` for full routing (gateway actions,
160
+ * dispatch), where terminal ids resolve to the terminal.
161
+ */
162
+ export function resolveOwnerFrontendId(
163
+ chatId: string,
164
+ opts: { includeNonMessaging?: boolean } = {},
165
+ ): string | null {
166
+ for (const entry of byPriority()) {
167
+ if (!opts.includeNonMessaging && !entry.messaging) continue;
168
+ if (entry.ownsChatId(chatId)) return entry.id;
169
+ }
170
+ return null;
171
+ }
172
+
173
+ /**
174
+ * Pick the frontend that should serve a chat from a set of live
175
+ * candidates (the configured frontends of this process). Semantics
176
+ * preserved from the old bootstrap chain: a single candidate always
177
+ * wins; otherwise the chat id's owner wins when it is a candidate;
178
+ * otherwise fall back to the first messaging candidate (a stdin
179
+ * frontend must not swallow cross-surface work), else the first.
180
+ */
181
+ export function resolveFrontendIdAmong(
182
+ chatId: string | undefined,
183
+ candidates: readonly string[],
184
+ ): string | undefined {
185
+ if (candidates.length === 0) return undefined;
186
+ if (candidates.length === 1) return candidates[0];
187
+ if (chatId) {
188
+ for (const entry of byPriority()) {
189
+ if (!candidates.includes(entry.id)) continue;
190
+ if (entry.ownsChatId(chatId)) return entry.id;
191
+ }
192
+ }
193
+ const messaging = candidates.find(
194
+ (id) => frontends.get(id)?.messaging !== false,
195
+ );
196
+ return messaging ?? candidates[0];
197
+ }
198
+
199
+ /**
200
+ * Restore the registry to just the built-in descriptors. Test-only
201
+ * utility — production code should never call this.
202
+ */
203
+ export function resetFrontendRegistry(): void {
204
+ frontends.clear();
205
+ registerBuiltinFrontendsInternal();
206
+ }
207
+
208
+ // ── Internal ────────────────────────────────────────────────────────────────
209
+
210
+ function byPriority(): Entry[] {
211
+ return [...frontends.values()].sort(
212
+ (a, b) => a.routePriority - b.routePriority,
213
+ );
214
+ }
215
+
216
+ // Injected by builtins.ts at module-load time (avoids an import cycle:
217
+ // builtins imports registerFrontendDescriptor from here).
218
+ let registerBuiltinFrontendsInternal: () => void = () => {};
219
+
220
+ /** @internal wiring for builtins.ts — not part of the public API. */
221
+ export function setBuiltinRegistrar(fn: () => void): void {
222
+ registerBuiltinFrontendsInternal = fn;
223
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Chat-id routing view of the frontend registry, for consumers that
3
+ * only need "whose chat id is this?" (gateway action routing, backend
4
+ * MCP tool scoping, dispatch resolution). Importing this module — not
5
+ * `registry.js` directly — guarantees the built-in descriptors are
6
+ * registered, without pulling in the engine-typed create seam.
7
+ */
8
+
9
+ import "./builtins.js";
10
+
11
+ export {
12
+ getFrontendDescriptor,
13
+ resolveFrontendIdAmong,
14
+ resolveOwnerFrontendId,
15
+ type FrontendDescriptor,
16
+ } from "./registry.js";
@@ -58,7 +58,6 @@ import { todayAndYesterday } from "../../util/time.js";
58
58
  import { log } from "../../util/log.js";
59
59
  import { loadSystemTemplate } from "./templates.js";
60
60
  import { renderWorkspaceListing } from "./workspace-listing.js";
61
- import { MAX_OPEN_GOALS_PER_CHAT } from "../../storage/goal-store.js";
62
61
  import { renderSkillsPrompt } from "../../storage/skill-store.js";
63
62
  import { renderStickerLibraryPrompt } from "../../storage/sticker-store.js";
64
63
  import { getSoul } from "../soul/service.js";
@@ -216,9 +215,7 @@ export function assembleSystemPrompt(
216
215
  loadSystemTemplate("workspace"),
217
216
  loadSystemTemplate("cron"),
218
217
  loadSystemTemplate("triggers"),
219
- loadSystemTemplate("goals", {
220
- maxOpenGoals: String(MAX_OPEN_GOALS_PER_CHAT),
221
- }),
218
+ loadSystemTemplate("goals"),
222
219
  loadSystemTemplate("skills"),
223
220
  );
224
221
 
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Discord frontend factory — attaches the create half of the
3
+ * registry entry (descriptor registered in core/frontend-runtime).
4
+ * The implementation (discord.js) loads only when created.
5
+ */
6
+
7
+ import { attachFrontendCreate } from "../../core/frontend-runtime/index.js";
8
+
9
+ attachFrontendCreate("discord", async (config, gateway) => {
10
+ const { createDiscordFrontend } = await import("./index.js");
11
+ return createDiscordFrontend(config, gateway);
12
+ });
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Side-effect barrel: attaches the create function for every built-in
3
+ * frontend to its registry descriptor. The composition roots (app.ts,
4
+ * cli/chat.ts) import this once; adding a frontend is strictly
5
+ * additive — drop a `factory.ts` under the new frontend dir and import
6
+ * it here. Factory modules are dependency-light: each one dynamically
7
+ * imports its implementation only when the frontend is actually
8
+ * created.
9
+ */
10
+
11
+ import "./telegram/factory.js";
12
+ import "./discord/factory.js";
13
+ import "./teams/factory.js";
14
+ import "./native/factory.js";
15
+ import "./terminal/factory.js";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Native (client bridge) frontend factory — attaches the create half
3
+ * of the registry entry (descriptor registered in core/frontend-runtime).
4
+ * The bridge server implementation loads only when created.
5
+ */
6
+
7
+ import { attachFrontendCreate } from "../../core/frontend-runtime/index.js";
8
+
9
+ attachFrontendCreate("native", async (config, gateway) => {
10
+ const { createNativeFrontend } = await import("./index.js");
11
+ return createNativeFrontend(config, gateway);
12
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Teams frontend factory — attaches the create half of the registry
3
+ * entry (descriptor registered in core/frontend-runtime). The
4
+ * implementation (Bot Framework + Graph) loads only when created.
5
+ */
6
+
7
+ import { attachFrontendCreate } from "../../core/frontend-runtime/index.js";
8
+
9
+ attachFrontendCreate("teams", async (config, gateway) => {
10
+ const { createTeamsFrontend } = await import("./index.js");
11
+ return createTeamsFrontend(config, gateway);
12
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Telegram frontend factory — attaches the create half of the
3
+ * registry entry (descriptor registered in core/frontend-runtime).
4
+ * The implementation (grammy + GramJS) loads only when created.
5
+ */
6
+
7
+ import { attachFrontendCreate } from "../../core/frontend-runtime/index.js";
8
+
9
+ attachFrontendCreate("telegram", async (config, gateway) => {
10
+ const { createTelegramFrontend } = await import("./index.js");
11
+ return createTelegramFrontend(config, gateway);
12
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Terminal frontend factory — attaches the create half of the
3
+ * registry entry (descriptor registered in core/frontend-runtime).
4
+ */
5
+
6
+ import { attachFrontendCreate } from "../../core/frontend-runtime/index.js";
7
+
8
+ attachFrontendCreate("terminal", async (config, gateway) => {
9
+ const { createTerminalFrontend } = await import("./index.js");
10
+ return createTerminalFrontend(config, gateway);
11
+ });
@@ -43,7 +43,6 @@ export const GOAL_PRIORITIES: readonly GoalPriority[] = [
43
43
  /** Statuses that count as "open" — shown to the heartbeat and listings. */
44
44
  export const OPEN_GOAL_STATUSES: readonly GoalStatus[] = ["active", "paused"];
45
45
 
46
- export const MAX_OPEN_GOALS_PER_CHAT = 25;
47
46
  export const MAX_TITLE_LENGTH = 200;
48
47
  export const MAX_DESCRIPTION_LENGTH = 2_000;
49
48
  export const MAX_PROGRESS_NOTE_LENGTH = 1_000;