talon-agent 1.0.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 +137 -0
- package/bin/talon.js +5 -0
- package/package.json +86 -0
- package/prompts/base.md +13 -0
- package/prompts/custom.md.example +22 -0
- package/prompts/dream.md +41 -0
- package/prompts/identity.md +45 -0
- package/prompts/teams.md +52 -0
- package/prompts/telegram.md +89 -0
- package/prompts/terminal.md +13 -0
- package/src/__tests__/chat-id.test.ts +91 -0
- package/src/__tests__/chat-settings.test.ts +337 -0
- package/src/__tests__/config.test.ts +546 -0
- package/src/__tests__/cron-store.test.ts +440 -0
- package/src/__tests__/daily-log.test.ts +146 -0
- package/src/__tests__/dispatcher.test.ts +383 -0
- package/src/__tests__/errors.test.ts +240 -0
- package/src/__tests__/fuzz.test.ts +302 -0
- package/src/__tests__/gateway-actions.test.ts +1453 -0
- package/src/__tests__/gateway-context.test.ts +102 -0
- package/src/__tests__/gateway-http.test.ts +245 -0
- package/src/__tests__/handlers.test.ts +351 -0
- package/src/__tests__/history-persistence.test.ts +172 -0
- package/src/__tests__/history.test.ts +659 -0
- package/src/__tests__/integration.test.ts +189 -0
- package/src/__tests__/log.test.ts +110 -0
- package/src/__tests__/media-index.test.ts +277 -0
- package/src/__tests__/plugin.test.ts +317 -0
- package/src/__tests__/prompt-builder.test.ts +71 -0
- package/src/__tests__/sessions.test.ts +594 -0
- package/src/__tests__/teams-frontend.test.ts +239 -0
- package/src/__tests__/telegram.test.ts +177 -0
- package/src/__tests__/terminal-commands.test.ts +367 -0
- package/src/__tests__/terminal-frontend.test.ts +141 -0
- package/src/__tests__/terminal-renderer.test.ts +278 -0
- package/src/__tests__/watchdog.test.ts +287 -0
- package/src/__tests__/workspace.test.ts +184 -0
- package/src/backend/claude-sdk/index.ts +438 -0
- package/src/backend/claude-sdk/tools.ts +605 -0
- package/src/backend/opencode/index.ts +252 -0
- package/src/bootstrap.ts +134 -0
- package/src/cli.ts +611 -0
- package/src/core/cron.ts +148 -0
- package/src/core/dispatcher.ts +126 -0
- package/src/core/dream.ts +295 -0
- package/src/core/errors.ts +206 -0
- package/src/core/gateway-actions.ts +267 -0
- package/src/core/gateway.ts +258 -0
- package/src/core/plugin.ts +432 -0
- package/src/core/prompt-builder.ts +43 -0
- package/src/core/pulse.ts +175 -0
- package/src/core/types.ts +85 -0
- package/src/frontend/teams/actions.ts +101 -0
- package/src/frontend/teams/formatting.ts +220 -0
- package/src/frontend/teams/graph.ts +297 -0
- package/src/frontend/teams/index.ts +308 -0
- package/src/frontend/teams/proxy-fetch.ts +28 -0
- package/src/frontend/teams/tools.ts +177 -0
- package/src/frontend/telegram/actions.ts +437 -0
- package/src/frontend/telegram/admin.ts +178 -0
- package/src/frontend/telegram/callbacks.ts +251 -0
- package/src/frontend/telegram/commands.ts +543 -0
- package/src/frontend/telegram/formatting.ts +101 -0
- package/src/frontend/telegram/handlers.ts +1008 -0
- package/src/frontend/telegram/helpers.ts +105 -0
- package/src/frontend/telegram/index.ts +130 -0
- package/src/frontend/telegram/middleware.ts +177 -0
- package/src/frontend/telegram/userbot.ts +546 -0
- package/src/frontend/terminal/commands.ts +303 -0
- package/src/frontend/terminal/index.ts +282 -0
- package/src/frontend/terminal/input.ts +297 -0
- package/src/frontend/terminal/renderer.ts +248 -0
- package/src/index.ts +144 -0
- package/src/login.ts +89 -0
- package/src/storage/chat-settings.ts +218 -0
- package/src/storage/cron-store.ts +165 -0
- package/src/storage/daily-log.ts +97 -0
- package/src/storage/history.ts +278 -0
- package/src/storage/media-index.ts +116 -0
- package/src/storage/sessions.ts +328 -0
- package/src/util/chat-id.ts +21 -0
- package/src/util/config.ts +244 -0
- package/src/util/log.ts +122 -0
- package/src/util/paths.ts +80 -0
- package/src/util/time.ts +86 -0
- package/src/util/trace.ts +35 -0
- package/src/util/watchdog.ts +108 -0
- package/src/util/workspace.ts +208 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway — generic HTTP bridge between MCP tool subprocess and the active frontend.
|
|
3
|
+
*
|
|
4
|
+
* The MCP subprocess (tools.ts) calls POST /action with action bodies.
|
|
5
|
+
* The gateway tries shared actions first (cron, fetch_url, history),
|
|
6
|
+
* then delegates to the active frontend's action handler.
|
|
7
|
+
*
|
|
8
|
+
* No platform-specific imports — frontends register their handler at startup.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
createServer,
|
|
13
|
+
type IncomingMessage,
|
|
14
|
+
type ServerResponse,
|
|
15
|
+
} from "node:http";
|
|
16
|
+
import { classify } from "./errors.js";
|
|
17
|
+
import { getActiveCount } from "./dispatcher.js";
|
|
18
|
+
import { getHealthStatus } from "../util/watchdog.js";
|
|
19
|
+
import { getActiveSessionCount } from "../storage/sessions.js";
|
|
20
|
+
import { log, logError, logDebug } from "../util/log.js";
|
|
21
|
+
import { handleSharedAction } from "./gateway-actions.js";
|
|
22
|
+
import { handlePluginAction } from "./plugin.js";
|
|
23
|
+
import type { FrontendActionHandler } from "./types.js";
|
|
24
|
+
|
|
25
|
+
// ── Per-chat context state ───────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
type ChatContext = { refCount: number; messagesSent: number; stringId?: string };
|
|
28
|
+
|
|
29
|
+
// ── Retry helper (stateless — standalone export) ─────────────────────────────
|
|
30
|
+
|
|
31
|
+
export async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
|
|
32
|
+
let lastError: unknown;
|
|
33
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
34
|
+
try {
|
|
35
|
+
return await fn();
|
|
36
|
+
} catch (err) {
|
|
37
|
+
lastError = err;
|
|
38
|
+
const classified = classify(err);
|
|
39
|
+
if (!classified.retryable) throw err;
|
|
40
|
+
if (attempt < 2) {
|
|
41
|
+
const delayMs = classified.retryAfterMs ?? 1000 * Math.pow(2, attempt);
|
|
42
|
+
log("gateway", `Retry ${attempt + 1}/3 (${classified.reason}) after ${delayMs}ms`);
|
|
43
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw lastError;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Gateway class ────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export class Gateway {
|
|
53
|
+
private chatContexts = new Map<number, ChatContext>();
|
|
54
|
+
private frontendHandler: FrontendActionHandler | null = null;
|
|
55
|
+
private server: ReturnType<typeof createServer> | null = null;
|
|
56
|
+
private port = 0;
|
|
57
|
+
|
|
58
|
+
// ── Frontend handler registration ────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
setFrontendHandler(handler: FrontendActionHandler): void {
|
|
61
|
+
this.frontendHandler = handler;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Per-chat context management ──────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
setContext(chatId: number, stringId?: string): void {
|
|
67
|
+
const ctx = this.chatContexts.get(chatId);
|
|
68
|
+
if (ctx) {
|
|
69
|
+
ctx.refCount++;
|
|
70
|
+
log("gateway", `Context ref++ for chat ${chatId} (refCount=${ctx.refCount})`);
|
|
71
|
+
} else {
|
|
72
|
+
this.chatContexts.set(chatId, { refCount: 1, messagesSent: 0, stringId });
|
|
73
|
+
log("gateway", `Context acquired for chat ${chatId}${stringId ? ` (${stringId})` : ""}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Find a numeric chatId by its string ID (used for Teams-style non-numeric chat IDs). */
|
|
78
|
+
private findContextByStringId(stringId: string): number | null {
|
|
79
|
+
if (!stringId) return null;
|
|
80
|
+
for (const [numId, ctx] of this.chatContexts) {
|
|
81
|
+
if (ctx.stringId === stringId) return numId;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
clearContext(chatId?: number | string): void {
|
|
87
|
+
if (chatId === undefined) return;
|
|
88
|
+
const parsed = typeof chatId === "number" ? chatId : Number(chatId);
|
|
89
|
+
// For non-numeric IDs (e.g. Teams "19:abc..."), Number() returns NaN — look up by string
|
|
90
|
+
const numId = !isNaN(parsed) ? parsed : this.findContextByStringId(String(chatId));
|
|
91
|
+
if (numId === null) return;
|
|
92
|
+
const ctx = this.chatContexts.get(numId);
|
|
93
|
+
if (!ctx) return;
|
|
94
|
+
ctx.refCount = Math.max(0, ctx.refCount - 1);
|
|
95
|
+
if (ctx.refCount <= 0) {
|
|
96
|
+
this.chatContexts.delete(numId);
|
|
97
|
+
log("gateway", `Context released for chat ${numId}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
isChatBusy(chatId: number): boolean {
|
|
102
|
+
return this.chatContexts.has(chatId);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
getMessageCount(chatId: number): number {
|
|
106
|
+
return this.chatContexts.get(chatId)?.messagesSent ?? 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
incrementMessages(chatId: number): void {
|
|
110
|
+
const ctx = this.chatContexts.get(chatId);
|
|
111
|
+
if (ctx) ctx.messagesSent++;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
getPort(): number {
|
|
115
|
+
return this.port;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
getActiveChats(): number {
|
|
119
|
+
return this.chatContexts.size;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Action dispatch ────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
private async handleAction(body: Record<string, unknown>): Promise<unknown> {
|
|
125
|
+
// Route by _chatId from the MCP subprocess request.
|
|
126
|
+
// _chatId may be a string (Teams: "teams_chat_19:...") or numeric string (Telegram: "123456").
|
|
127
|
+
// The context map is keyed by numeric chatId, so try direct parse first,
|
|
128
|
+
// then fall back to searching active contexts.
|
|
129
|
+
const rawChatId = body._chatId ? String(body._chatId) : "";
|
|
130
|
+
const numericId = Number(rawChatId);
|
|
131
|
+
const chatId = !isNaN(numericId) && this.chatContexts.has(numericId)
|
|
132
|
+
? numericId
|
|
133
|
+
: this.findContextByStringId(rawChatId);
|
|
134
|
+
if (!chatId) {
|
|
135
|
+
return { ok: false, error: "No active chat context" };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const action = typeof body.action === "string" ? body.action : "";
|
|
139
|
+
if (!action) return { ok: false, error: "Missing action" };
|
|
140
|
+
const t0 = Date.now();
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
// Try frontend first — it has richer implementations (e.g. userbot history)
|
|
144
|
+
// and falls back to null when it can't handle the action.
|
|
145
|
+
if (this.frontendHandler) {
|
|
146
|
+
const result = await this.frontendHandler(body, chatId);
|
|
147
|
+
if (result) {
|
|
148
|
+
logDebug("gateway", `${action} chat=${chatId} ${Date.now() - t0}ms`);
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Try plugin actions (loaded from external plugin packages)
|
|
154
|
+
const pluginResult = await handlePluginAction(body, String(chatId));
|
|
155
|
+
if (pluginResult) {
|
|
156
|
+
logDebug("gateway", `${action} chat=${chatId} ${Date.now() - t0}ms (plugin)`);
|
|
157
|
+
return pluginResult;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Shared actions last — provides in-memory fallbacks for history, cron, etc.
|
|
161
|
+
const shared = await handleSharedAction(body, chatId);
|
|
162
|
+
if (shared) {
|
|
163
|
+
logDebug("gateway", `${action} chat=${chatId} ${Date.now() - t0}ms (shared)`);
|
|
164
|
+
return shared;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { ok: false, error: `Unknown action: ${action}` };
|
|
168
|
+
} catch (err) {
|
|
169
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
170
|
+
logError("gateway", `${action} chat=${chatId} failed: ${msg}`);
|
|
171
|
+
return { ok: false, error: `${action}: ${msg}` };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── HTTP server ──────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
async start(port = 19876): Promise<number> {
|
|
178
|
+
if (this.server) return this.port;
|
|
179
|
+
|
|
180
|
+
const httpServer = createServer(
|
|
181
|
+
async (req: IncomingMessage, res: ServerResponse) => {
|
|
182
|
+
if (req.method === "GET" && req.url === "/health") {
|
|
183
|
+
const w = getHealthStatus();
|
|
184
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
185
|
+
res.end(JSON.stringify({
|
|
186
|
+
ok: w.healthy,
|
|
187
|
+
uptime: Math.round(process.uptime()),
|
|
188
|
+
memory: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
|
|
189
|
+
bridge: { activeChats: this.chatContexts.size },
|
|
190
|
+
queue: getActiveCount(),
|
|
191
|
+
sessions: getActiveSessionCount(),
|
|
192
|
+
messages: w.totalMessagesProcessed,
|
|
193
|
+
errors: w.recentErrorCount,
|
|
194
|
+
lastActivity: w.msSinceLastMessage < 60000
|
|
195
|
+
? "just now"
|
|
196
|
+
: `${Math.round(w.msSinceLastMessage / 60000)}m ago`,
|
|
197
|
+
}));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (req.method !== "POST" || req.url !== "/action") {
|
|
202
|
+
res.writeHead(404);
|
|
203
|
+
res.end("Not found");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const chunks: Buffer[] = [];
|
|
209
|
+
for await (const chunk of req) chunks.push(chunk as Buffer);
|
|
210
|
+
let body: Record<string, unknown>;
|
|
211
|
+
try {
|
|
212
|
+
body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
|
213
|
+
} catch {
|
|
214
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
215
|
+
res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const result = await this.handleAction(body);
|
|
219
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
220
|
+
res.end(JSON.stringify(result));
|
|
221
|
+
} catch (err) {
|
|
222
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
223
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
224
|
+
res.end(JSON.stringify({ ok: false, error: msg }));
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
return new Promise<number>((resolve, reject) => {
|
|
230
|
+
let attempt = 0;
|
|
231
|
+
const tryPort = (p: number) => {
|
|
232
|
+
httpServer.once("error", (err: NodeJS.ErrnoException) => {
|
|
233
|
+
if (err.code === "EADDRINUSE" && attempt < 5) {
|
|
234
|
+
attempt++;
|
|
235
|
+
httpServer.removeAllListeners("error");
|
|
236
|
+
tryPort(p + 1);
|
|
237
|
+
} else {
|
|
238
|
+
reject(err);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
httpServer.listen(p, "127.0.0.1", () => {
|
|
242
|
+
this.server = httpServer;
|
|
243
|
+
this.port = p;
|
|
244
|
+
log("gateway", `Action gateway on :${p}`);
|
|
245
|
+
resolve(p);
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
tryPort(port);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async stop(): Promise<void> {
|
|
253
|
+
return new Promise((resolve) => {
|
|
254
|
+
if (!this.server) { resolve(); return; }
|
|
255
|
+
this.server.close(() => { this.server = null; this.port = 0; resolve(); });
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin system — extensible tool integration for Talon.
|
|
3
|
+
*
|
|
4
|
+
* Design principles:
|
|
5
|
+
* - Interface Segregation: plugins implement only what they need
|
|
6
|
+
* - Open/Closed: lifecycle hooks allow extension without core changes
|
|
7
|
+
* - Dependency Inversion: plugins receive config, don't depend on globals
|
|
8
|
+
* - Single Responsibility: loader, registry, and routing are separated
|
|
9
|
+
* - Error Isolation: one plugin failing doesn't take down others
|
|
10
|
+
*
|
|
11
|
+
* Plugin config in talon.json:
|
|
12
|
+
* "plugins": [
|
|
13
|
+
* { "path": "/path/to/plugin", "config": { ... } }
|
|
14
|
+
* ]
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { resolve } from "node:path";
|
|
18
|
+
import { existsSync } from "node:fs";
|
|
19
|
+
import { log, logError, logWarn } from "../util/log.js";
|
|
20
|
+
import type { ActionResult } from "./types.js";
|
|
21
|
+
|
|
22
|
+
// ── Plugin interfaces ──────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/** Configuration entry for a plugin in talon.json. */
|
|
25
|
+
export interface PluginEntry {
|
|
26
|
+
path: string;
|
|
27
|
+
config?: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Core plugin interface — only `name` is required.
|
|
32
|
+
* All other capabilities are optional (Interface Segregation).
|
|
33
|
+
*/
|
|
34
|
+
export interface TalonPlugin {
|
|
35
|
+
/** Unique plugin identifier. Used as MCP server name prefix. */
|
|
36
|
+
readonly name: string;
|
|
37
|
+
|
|
38
|
+
/** Human-readable description for status/diagnostics. */
|
|
39
|
+
readonly description?: string;
|
|
40
|
+
|
|
41
|
+
/** Semver version string. */
|
|
42
|
+
readonly version?: string;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Frontend whitelist — which frontends this plugin is active for.
|
|
46
|
+
* If unset, the plugin is available on all frontends.
|
|
47
|
+
* Example: ["telegram"] — only loads when Telegram frontend is active.
|
|
48
|
+
*/
|
|
49
|
+
readonly frontends?: readonly string[];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Called once after the plugin is loaded and validated.
|
|
53
|
+
* Use for one-time setup (connections, caches, etc).
|
|
54
|
+
* Receives the resolved plugin config.
|
|
55
|
+
*/
|
|
56
|
+
init?(config: Record<string, unknown>): Promise<void> | void;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Called during graceful shutdown. Clean up resources.
|
|
60
|
+
*/
|
|
61
|
+
destroy?(): Promise<void> | void;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Absolute path to the MCP server script (spawned as subprocess).
|
|
65
|
+
* Omit if the plugin only provides action handlers without MCP tools.
|
|
66
|
+
*/
|
|
67
|
+
mcpServerPath?: string;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Map plugin config to env vars for the MCP subprocess and action handlers.
|
|
71
|
+
* Called once at load time. Values are set on process.env for the main
|
|
72
|
+
* process and passed to the MCP subprocess.
|
|
73
|
+
*/
|
|
74
|
+
getEnvVars?(config: Record<string, unknown>): Record<string, string>;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Handle a gateway action. Return null if not recognized.
|
|
78
|
+
* Actions are tried in plugin load order, first non-null wins.
|
|
79
|
+
*/
|
|
80
|
+
handleAction?(
|
|
81
|
+
body: Record<string, unknown>,
|
|
82
|
+
chatId: string,
|
|
83
|
+
): Promise<ActionResult | null>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Contribute additional context to the system prompt.
|
|
87
|
+
* Called during config loading. Return text to append.
|
|
88
|
+
*/
|
|
89
|
+
getSystemPromptAddition?(config: Record<string, unknown>): string;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Validate plugin config at load time.
|
|
93
|
+
* Return an array of error messages, or empty/undefined if valid.
|
|
94
|
+
*/
|
|
95
|
+
validateConfig?(config: Record<string, unknown>): string[] | undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Plugin registry ────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/** A loaded and validated plugin instance with its resolved config. */
|
|
101
|
+
export interface LoadedPlugin {
|
|
102
|
+
readonly plugin: TalonPlugin;
|
|
103
|
+
readonly config: Record<string, unknown>;
|
|
104
|
+
readonly envVars: Record<string, string>;
|
|
105
|
+
readonly path: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
class PluginRegistry {
|
|
109
|
+
private readonly plugins: LoadedPlugin[] = [];
|
|
110
|
+
|
|
111
|
+
get all(): readonly LoadedPlugin[] {
|
|
112
|
+
return this.plugins;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get count(): number {
|
|
116
|
+
return this.plugins.length;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
register(loaded: LoadedPlugin): void {
|
|
120
|
+
// Guard against duplicate names
|
|
121
|
+
const existing = this.plugins.find(
|
|
122
|
+
(p) => p.plugin.name === loaded.plugin.name,
|
|
123
|
+
);
|
|
124
|
+
if (existing) {
|
|
125
|
+
logWarn(
|
|
126
|
+
"plugin",
|
|
127
|
+
`Duplicate plugin name "${loaded.plugin.name}" — skipping (already loaded from ${existing.path})`,
|
|
128
|
+
);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
this.plugins.push(loaded);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
getByName(name: string): LoadedPlugin | undefined {
|
|
135
|
+
return this.plugins.find((p) => p.plugin.name === name);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async destroyAll(): Promise<void> {
|
|
139
|
+
for (const { plugin } of this.plugins) {
|
|
140
|
+
try {
|
|
141
|
+
await plugin.destroy?.();
|
|
142
|
+
} catch (err) {
|
|
143
|
+
logError(
|
|
144
|
+
"plugin",
|
|
145
|
+
`${plugin.name} destroy error: ${err instanceof Error ? err.message : err}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Module-level singleton
|
|
153
|
+
const registry = new PluginRegistry();
|
|
154
|
+
|
|
155
|
+
/** Internal deps — exposed as an object so tests can replace properties.
|
|
156
|
+
* Direct function exports can't be mocked for internal callers in ESM. */
|
|
157
|
+
export const _deps = {
|
|
158
|
+
importModule: async (path: string): Promise<Record<string, unknown>> =>
|
|
159
|
+
import(path),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// ── Loader ─────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/** Candidate entry point paths, checked in order. */
|
|
165
|
+
const ENTRY_CANDIDATES = [
|
|
166
|
+
"src/index.ts",
|
|
167
|
+
"dist/index.js",
|
|
168
|
+
"index.ts",
|
|
169
|
+
"index.js",
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Load and validate plugins from config entries.
|
|
174
|
+
* Plugins that fail to load are logged and skipped — they don't block others.
|
|
175
|
+
* @param activeFrontends — currently active frontends (e.g. ["terminal"]). Plugins
|
|
176
|
+
* with a `frontends` whitelist are skipped if none match.
|
|
177
|
+
*/
|
|
178
|
+
export async function loadPlugins(
|
|
179
|
+
pluginConfigs: PluginEntry[],
|
|
180
|
+
activeFrontends?: string[],
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
for (const entry of pluginConfigs) {
|
|
183
|
+
try {
|
|
184
|
+
await loadSinglePlugin(entry, activeFrontends);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
logError(
|
|
187
|
+
"plugin",
|
|
188
|
+
`Failed to load plugin at ${entry.path}: ${err instanceof Error ? err.message : err}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function loadSinglePlugin(
|
|
195
|
+
entry: PluginEntry,
|
|
196
|
+
activeFrontends?: string[],
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
const pluginDir = resolve(entry.path);
|
|
199
|
+
|
|
200
|
+
// Resolve entry point
|
|
201
|
+
const entryPoint = resolveEntryPoint(pluginDir);
|
|
202
|
+
if (!entryPoint) {
|
|
203
|
+
logError(
|
|
204
|
+
"plugin",
|
|
205
|
+
`No entry point found in ${pluginDir} (tried: ${ENTRY_CANDIDATES.join(", ")})`,
|
|
206
|
+
);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Import and extract plugin module
|
|
211
|
+
const mod = await _deps.importModule(entryPoint);
|
|
212
|
+
const plugin = extractPlugin(mod);
|
|
213
|
+
if (!plugin) {
|
|
214
|
+
logError(
|
|
215
|
+
"plugin",
|
|
216
|
+
`Invalid plugin at ${pluginDir}: must export an object with a "name" property`,
|
|
217
|
+
);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Check frontend whitelist — skip if plugin specifies frontends and none match
|
|
222
|
+
if (plugin.frontends && plugin.frontends.length > 0 && activeFrontends) {
|
|
223
|
+
const match = activeFrontends.some((fe) => plugin.frontends!.includes(fe));
|
|
224
|
+
if (!match) {
|
|
225
|
+
log(
|
|
226
|
+
"plugin",
|
|
227
|
+
`Skipped: ${plugin.name} (requires ${plugin.frontends.join("/")} frontend)`,
|
|
228
|
+
);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const config = entry.config ?? {};
|
|
234
|
+
|
|
235
|
+
// Validate config if the plugin provides validation
|
|
236
|
+
const errors = plugin.validateConfig?.(config);
|
|
237
|
+
if (errors && errors.length > 0) {
|
|
238
|
+
logError(
|
|
239
|
+
"plugin",
|
|
240
|
+
`Plugin "${plugin.name}" config validation failed:\n ${errors.join("\n ")}`,
|
|
241
|
+
);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Resolve env vars
|
|
246
|
+
const envVars = plugin.getEnvVars?.(config) ?? {};
|
|
247
|
+
|
|
248
|
+
// Set env vars on main process for action handlers
|
|
249
|
+
for (const [k, v] of Object.entries(envVars)) {
|
|
250
|
+
process.env[k] = v;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Register before init (so other plugins can discover it)
|
|
254
|
+
const loaded: LoadedPlugin = { plugin, config, envVars, path: pluginDir };
|
|
255
|
+
registry.register(loaded);
|
|
256
|
+
|
|
257
|
+
// Run init hook
|
|
258
|
+
const INIT_TIMEOUT = 30_000;
|
|
259
|
+
try {
|
|
260
|
+
await Promise.race([
|
|
261
|
+
plugin.init?.(config),
|
|
262
|
+
new Promise((_, reject) =>
|
|
263
|
+
setTimeout(() => reject(new Error("init timeout (30s)")), INIT_TIMEOUT),
|
|
264
|
+
),
|
|
265
|
+
]);
|
|
266
|
+
} catch (err) {
|
|
267
|
+
logError(
|
|
268
|
+
"plugin",
|
|
269
|
+
`Plugin "${plugin.name}" init failed: ${err instanceof Error ? err.message : err}`,
|
|
270
|
+
);
|
|
271
|
+
// Still registered — tools may work even if init partially failed
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const version = plugin.version ? ` v${plugin.version}` : "";
|
|
275
|
+
const desc = plugin.description ? ` — ${plugin.description}` : "";
|
|
276
|
+
log("plugin", `Loaded: ${plugin.name}${version}${desc}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function resolveEntryPoint(pluginDir: string): string | null {
|
|
280
|
+
for (const candidate of ENTRY_CANDIDATES) {
|
|
281
|
+
const full = resolve(pluginDir, candidate);
|
|
282
|
+
if (existsSync(full)) return full;
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function extractPlugin(mod: Record<string, unknown>): TalonPlugin | null {
|
|
288
|
+
// Support: export default { ... } or module.exports = { ... }
|
|
289
|
+
const candidate = mod.default ?? mod;
|
|
290
|
+
if (!candidate || typeof candidate !== "object") return null;
|
|
291
|
+
const plugin = candidate as Record<string, unknown>;
|
|
292
|
+
// Validate required field types
|
|
293
|
+
if (typeof plugin.name !== "string" || !plugin.name) return null;
|
|
294
|
+
// Validate optional fields are the right types if present
|
|
295
|
+
if (
|
|
296
|
+
plugin.handleAction !== undefined &&
|
|
297
|
+
typeof plugin.handleAction !== "function"
|
|
298
|
+
)
|
|
299
|
+
return null;
|
|
300
|
+
if (plugin.init !== undefined && typeof plugin.init !== "function")
|
|
301
|
+
return null;
|
|
302
|
+
if (
|
|
303
|
+
plugin.getSystemPromptAddition !== undefined &&
|
|
304
|
+
typeof plugin.getSystemPromptAddition !== "function"
|
|
305
|
+
)
|
|
306
|
+
return null;
|
|
307
|
+
if (
|
|
308
|
+
plugin.mcpServerPath !== undefined &&
|
|
309
|
+
typeof plugin.mcpServerPath !== "string"
|
|
310
|
+
)
|
|
311
|
+
return null;
|
|
312
|
+
if (plugin.frontends !== undefined && !Array.isArray(plugin.frontends))
|
|
313
|
+
return null;
|
|
314
|
+
return candidate as TalonPlugin;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
/** Get all loaded plugins. */
|
|
320
|
+
export function getLoadedPlugins(): readonly LoadedPlugin[] {
|
|
321
|
+
return registry.all;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Get a plugin by name. */
|
|
325
|
+
export function getPlugin(name: string): LoadedPlugin | undefined {
|
|
326
|
+
return registry.getByName(name);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Number of loaded plugins. */
|
|
330
|
+
export function getPluginCount(): number {
|
|
331
|
+
return registry.count;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Destroy all plugins (called during shutdown). */
|
|
335
|
+
export async function destroyPlugins(): Promise<void> {
|
|
336
|
+
await registry.destroyAll();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Collect system prompt additions from all plugins.
|
|
341
|
+
* Called during config/prompt assembly.
|
|
342
|
+
*/
|
|
343
|
+
export function getPluginPromptAdditions(): string[] {
|
|
344
|
+
const additions: string[] = [];
|
|
345
|
+
for (const { plugin, config } of registry.all) {
|
|
346
|
+
try {
|
|
347
|
+
const addition = plugin.getSystemPromptAddition?.(config);
|
|
348
|
+
if (addition?.trim()) additions.push(addition.trim());
|
|
349
|
+
} catch (err) {
|
|
350
|
+
logError(
|
|
351
|
+
"plugin",
|
|
352
|
+
`${plugin.name} prompt addition error: ${err instanceof Error ? err.message : err}`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return additions;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Action routing ─────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Route an action through all loaded plugins.
|
|
363
|
+
* Returns the first non-null result. Errors from individual plugins
|
|
364
|
+
* are caught and returned as error results — they don't cascade.
|
|
365
|
+
*/
|
|
366
|
+
export async function handlePluginAction(
|
|
367
|
+
body: Record<string, unknown>,
|
|
368
|
+
chatId: string,
|
|
369
|
+
): Promise<ActionResult | null> {
|
|
370
|
+
for (const { plugin } of registry.all) {
|
|
371
|
+
if (!plugin.handleAction) continue;
|
|
372
|
+
try {
|
|
373
|
+
const result = await plugin.handleAction(body, chatId);
|
|
374
|
+
if (result) return result;
|
|
375
|
+
} catch (err) {
|
|
376
|
+
logError(
|
|
377
|
+
"plugin",
|
|
378
|
+
`${plugin.name} action error: ${err instanceof Error ? err.message : err}`,
|
|
379
|
+
);
|
|
380
|
+
return {
|
|
381
|
+
ok: false,
|
|
382
|
+
error: `Plugin ${plugin.name}: ${err instanceof Error ? err.message : err}`,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── MCP server config ──────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
/** MCP server configuration for the Claude Agent SDK. */
|
|
392
|
+
export interface McpServerConfig {
|
|
393
|
+
command: string;
|
|
394
|
+
args: string[];
|
|
395
|
+
env: Record<string, string>;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Build MCP server entries for all plugins that provide an MCP server.
|
|
400
|
+
* Plugins without `mcpServerPath` are skipped.
|
|
401
|
+
*/
|
|
402
|
+
export function getPluginMcpServers(
|
|
403
|
+
bridgeUrl: string,
|
|
404
|
+
chatId: string,
|
|
405
|
+
): Record<string, McpServerConfig> {
|
|
406
|
+
const servers: Record<string, McpServerConfig> = {};
|
|
407
|
+
|
|
408
|
+
// Resolve tsx from Talon's own node_modules (not cwd which may be ~/.talon/workspace/)
|
|
409
|
+
const tsxPath = resolve(
|
|
410
|
+
import.meta.dirname ?? ".",
|
|
411
|
+
"../../node_modules/tsx/dist/esm/index.mjs",
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
for (const { plugin, envVars } of registry.all) {
|
|
415
|
+
if (!plugin.mcpServerPath) continue;
|
|
416
|
+
|
|
417
|
+
servers[`${plugin.name}-tools`] = {
|
|
418
|
+
command: process.platform === "win32" ? "npx" : "node",
|
|
419
|
+
args:
|
|
420
|
+
process.platform === "win32"
|
|
421
|
+
? ["tsx", plugin.mcpServerPath]
|
|
422
|
+
: ["--import", tsxPath, plugin.mcpServerPath],
|
|
423
|
+
env: {
|
|
424
|
+
TALON_BRIDGE_URL: bridgeUrl,
|
|
425
|
+
TALON_CHAT_ID: chatId,
|
|
426
|
+
...envVars,
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return servers;
|
|
432
|
+
}
|