talon-agent 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/prompts/heartbeat.md +18 -6
- package/src/__tests__/heartbeat.test.ts +21 -0
- package/src/__tests__/reload-plugins.test.ts +199 -0
- package/src/__tests__/sessions.test.ts +155 -121
- package/src/backend/claude-sdk/index.ts +198 -62
- package/src/bootstrap.ts +3 -103
- package/src/core/gateway-actions.ts +42 -1
- package/src/core/heartbeat.ts +8 -5
- package/src/core/plugin.ts +147 -0
- package/src/core/tools/admin.ts +22 -0
- package/src/core/tools/index.ts +2 -0
- package/src/core/tools/types.ts +2 -1
- package/src/frontend/teams/index.ts +9 -10
- package/src/frontend/telegram/commands.ts +11 -10
- package/src/storage/sessions.ts +34 -40
|
@@ -41,34 +41,15 @@ export function initAgent(
|
|
|
41
41
|
delete process.env.CLAUDECODE;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
export
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
): Promise<QueryResult> {
|
|
50
|
-
if (!config)
|
|
51
|
-
throw new Error("Agent not initialized. Call initAgent() first.");
|
|
44
|
+
/** Update the system prompt on the live config. Used by plugin hot-reload
|
|
45
|
+
* so the next message picks up new plugin tool descriptions. */
|
|
46
|
+
export function updateSystemPrompt(prompt: string): void {
|
|
47
|
+
if (config) config.systemPrompt = prompt;
|
|
48
|
+
}
|
|
52
49
|
|
|
53
|
-
|
|
54
|
-
chatId,
|
|
55
|
-
text,
|
|
56
|
-
senderName,
|
|
57
|
-
isGroup,
|
|
58
|
-
onTextBlock,
|
|
59
|
-
onStreamDelta,
|
|
60
|
-
onToolUse,
|
|
61
|
-
} = params;
|
|
62
|
-
const session = getSession(chatId);
|
|
63
|
-
const t0 = Date.now();
|
|
50
|
+
// ── Shared options builder ───────────────────────────────────────────────────
|
|
64
51
|
|
|
65
|
-
|
|
66
|
-
// memory, and workspace listing are fresh
|
|
67
|
-
if (session.turns === 0) {
|
|
68
|
-
rebuildSystemPrompt(config, getPluginPromptAdditions());
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Per-chat settings override global config
|
|
52
|
+
function buildSdkOptions(chatId: string) {
|
|
72
53
|
const chatSettings = getChatSettings(chatId);
|
|
73
54
|
const activeModel = chatSettings.model ?? config.model;
|
|
74
55
|
const activeEffort = chatSettings.effort ?? "adaptive";
|
|
@@ -90,13 +71,18 @@ export async function handleMessage(
|
|
|
90
71
|
thinking: { type: "adaptive" as const },
|
|
91
72
|
};
|
|
92
73
|
|
|
74
|
+
const supports1m =
|
|
75
|
+
!activeModel.includes("haiku") && !activeModel.includes("[1m]");
|
|
76
|
+
const sdkModel = supports1m ? `${activeModel}[1m]` : activeModel;
|
|
77
|
+
|
|
78
|
+
const session = getSession(chatId);
|
|
79
|
+
|
|
93
80
|
const options = {
|
|
94
|
-
model:
|
|
81
|
+
model: sdkModel,
|
|
95
82
|
systemPrompt: config.systemPrompt,
|
|
96
83
|
cwd: config.workspace,
|
|
97
84
|
permissionMode: "bypassPermissions" as const,
|
|
98
85
|
allowDangerouslySkipPermissions: true,
|
|
99
|
-
betas: ["context-1m-2025-08-07"],
|
|
100
86
|
...(config.claudeBinary
|
|
101
87
|
? { pathToClaudeCodeExecutable: config.claudeBinary }
|
|
102
88
|
: {}),
|
|
@@ -114,16 +100,11 @@ export async function handleMessage(
|
|
|
114
100
|
"TaskOutput",
|
|
115
101
|
"TaskStop",
|
|
116
102
|
"AskUserQuestion",
|
|
117
|
-
// Always disable Claude Code built-in web tools — fetch_url is always
|
|
118
|
-
// available, and Brave Search MCP replaces WebSearch when configured.
|
|
119
103
|
"WebSearch",
|
|
120
104
|
"WebFetch",
|
|
121
105
|
],
|
|
122
106
|
...thinkingConfig,
|
|
123
107
|
mcpServers: {
|
|
124
|
-
// Register unified MCP tools server — one per messaging frontend.
|
|
125
|
-
// Terminal frontend relies on Claude Code built-in tools (Read, Write,
|
|
126
|
-
// Bash, etc.) and doesn't need a custom MCP tools server.
|
|
127
108
|
...(() => {
|
|
128
109
|
const allFrontends = Array.isArray(config.frontend)
|
|
129
110
|
? config.frontend
|
|
@@ -134,12 +115,10 @@ export async function handleMessage(
|
|
|
134
115
|
string,
|
|
135
116
|
{ command: string; args: string[]; env: Record<string, string> }
|
|
136
117
|
> = {};
|
|
137
|
-
// Resolve tsx from the package root (3 levels up from src/backend/claude-sdk/)
|
|
138
118
|
const tsxImport = resolve(
|
|
139
119
|
import.meta.dirname ?? ".",
|
|
140
120
|
"../../../node_modules/tsx/dist/esm/index.mjs",
|
|
141
121
|
);
|
|
142
|
-
// Unified MCP server in core/tools/
|
|
143
122
|
const mcpServerPath = resolve(
|
|
144
123
|
import.meta.dirname ?? ".",
|
|
145
124
|
"../../core/tools/mcp-server.ts",
|
|
@@ -163,7 +142,6 @@ export async function handleMessage(
|
|
|
163
142
|
}
|
|
164
143
|
return servers;
|
|
165
144
|
})(),
|
|
166
|
-
// Brave Search MCP server — provides brave_web_search and brave_local_search
|
|
167
145
|
...(config.braveApiKey
|
|
168
146
|
? {
|
|
169
147
|
"brave-search": {
|
|
@@ -181,6 +159,107 @@ export async function handleMessage(
|
|
|
181
159
|
...(session.sessionId ? { resume: session.sessionId } : {}),
|
|
182
160
|
};
|
|
183
161
|
|
|
162
|
+
return { options, activeModel, session };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Session warm-up ─────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Cold-start a session by spawning an SDK subprocess in streaming input mode,
|
|
169
|
+
* calling getContextUsage() to populate contextWindow and baseline contextTokens,
|
|
170
|
+
* then tearing it down. Fire-and-forget — does not block the caller.
|
|
171
|
+
*/
|
|
172
|
+
export async function warmSession(chatId: string): Promise<void> {
|
|
173
|
+
if (!config) return;
|
|
174
|
+
const abort = new AbortController();
|
|
175
|
+
try {
|
|
176
|
+
rebuildSystemPrompt(config, getPluginPromptAdditions());
|
|
177
|
+
const { options } = buildSdkOptions(chatId);
|
|
178
|
+
|
|
179
|
+
// Streaming input mode: pass an async iterable that never yields a user message
|
|
180
|
+
const neverYield = async function* (): AsyncGenerator<never> {
|
|
181
|
+
await new Promise<never>((_, reject) => {
|
|
182
|
+
abort.signal.addEventListener("abort", () =>
|
|
183
|
+
reject(new Error("aborted")),
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const q = query({
|
|
189
|
+
prompt: neverYield(),
|
|
190
|
+
options: {
|
|
191
|
+
...options,
|
|
192
|
+
abortController: abort,
|
|
193
|
+
} as Parameters<typeof query>[0]["options"],
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Drain the stream in the background so the SDK's internal message loop
|
|
197
|
+
// doesn't stall — control responses are processed in readMessages() which
|
|
198
|
+
// needs the inputStream consumer to not back-pressure.
|
|
199
|
+
const drainPromise = (async () => {
|
|
200
|
+
try {
|
|
201
|
+
for await (const _ of q) {
|
|
202
|
+
// discard SDK messages; we only care about the control response
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
// expected: abort causes the stream to end with an error
|
|
206
|
+
}
|
|
207
|
+
})();
|
|
208
|
+
|
|
209
|
+
// Race getContextUsage against a timeout so /reset doesn't hang
|
|
210
|
+
const timeout = new Promise<never>((_, reject) =>
|
|
211
|
+
setTimeout(() => reject(new Error("warm-up timed out")), 15_000),
|
|
212
|
+
);
|
|
213
|
+
const ctx = await Promise.race([q.getContextUsage(), timeout]);
|
|
214
|
+
const session = getSession(chatId);
|
|
215
|
+
if (ctx.maxTokens > 0) session.usage.contextWindow = ctx.maxTokens;
|
|
216
|
+
if (ctx.totalTokens > 0) session.usage.contextTokens = ctx.totalTokens;
|
|
217
|
+
log(
|
|
218
|
+
"agent",
|
|
219
|
+
`[${chatId}] warm-up: context ${ctx.totalTokens}/${ctx.maxTokens} (${ctx.percentage.toFixed(1)}%) model=${ctx.model}`,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
abort.abort();
|
|
223
|
+
await drainPromise;
|
|
224
|
+
} catch (err) {
|
|
225
|
+
abort.abort();
|
|
226
|
+
// Non-fatal — /status will just show 0 until first real message
|
|
227
|
+
logWarn(
|
|
228
|
+
"agent",
|
|
229
|
+
`[${chatId}] warm-up failed: ${err instanceof Error ? err.message : err}`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Main handler ─────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
export async function handleMessage(
|
|
237
|
+
params: QueryParams,
|
|
238
|
+
_retried = false,
|
|
239
|
+
): Promise<QueryResult> {
|
|
240
|
+
if (!config)
|
|
241
|
+
throw new Error("Agent not initialized. Call initAgent() first.");
|
|
242
|
+
|
|
243
|
+
const {
|
|
244
|
+
chatId,
|
|
245
|
+
text,
|
|
246
|
+
senderName,
|
|
247
|
+
isGroup,
|
|
248
|
+
onTextBlock,
|
|
249
|
+
onStreamDelta,
|
|
250
|
+
onToolUse,
|
|
251
|
+
} = params;
|
|
252
|
+
const session = getSession(chatId);
|
|
253
|
+
const t0 = Date.now();
|
|
254
|
+
|
|
255
|
+
// Rebuild system prompt on first turn of a new/reset session so identity,
|
|
256
|
+
// memory, and workspace listing are fresh
|
|
257
|
+
if (session.turns === 0) {
|
|
258
|
+
rebuildSystemPrompt(config, getPluginPromptAdditions());
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const { options, activeModel } = buildSdkOptions(chatId);
|
|
262
|
+
|
|
184
263
|
const msgIdHint = params.messageId ? ` [msg_id:${params.messageId}]` : "";
|
|
185
264
|
const nowTag = `[${formatFullDatetime()}]`;
|
|
186
265
|
|
|
@@ -199,11 +278,16 @@ export async function handleMessage(
|
|
|
199
278
|
let currentBlockText = "";
|
|
200
279
|
let allResponseText = "";
|
|
201
280
|
let newSessionId: string | undefined;
|
|
202
|
-
let inputTokens = 0;
|
|
203
|
-
let outputTokens = 0;
|
|
204
|
-
let cacheRead = 0;
|
|
205
|
-
let cacheWrite = 0;
|
|
206
281
|
let toolCalls = 0;
|
|
282
|
+
// Populated from SDK result message
|
|
283
|
+
let contextTokens = 0; // actual context fill from last iteration
|
|
284
|
+
let contextWindow: number | undefined;
|
|
285
|
+
let numApiCalls = 0;
|
|
286
|
+
// Cumulative token counts from SDK modelUsage (aggregated across models)
|
|
287
|
+
let sdkInputTokens = 0;
|
|
288
|
+
let sdkOutputTokens = 0;
|
|
289
|
+
let sdkCacheRead = 0;
|
|
290
|
+
let sdkCacheWrite = 0;
|
|
207
291
|
|
|
208
292
|
// Streaming throttle
|
|
209
293
|
let lastStreamUpdate = 0;
|
|
@@ -298,15 +382,64 @@ export async function handleMessage(
|
|
|
298
382
|
}
|
|
299
383
|
}
|
|
300
384
|
|
|
301
|
-
// Final result
|
|
385
|
+
// Final result — read all data from SDK result fields
|
|
302
386
|
if (type === "result") {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
387
|
+
numApiCalls =
|
|
388
|
+
((msg as Record<string, unknown>).num_turns as number) ?? 0;
|
|
389
|
+
|
|
390
|
+
// Context fill from last API iteration (only available in raw usage)
|
|
391
|
+
const usage = msg.usage as
|
|
392
|
+
| {
|
|
393
|
+
iterations?: Array<{
|
|
394
|
+
input_tokens: number;
|
|
395
|
+
cache_read_input_tokens: number;
|
|
396
|
+
cache_creation_input_tokens: number;
|
|
397
|
+
}>;
|
|
398
|
+
}
|
|
399
|
+
| undefined;
|
|
400
|
+
if (
|
|
401
|
+
usage &&
|
|
402
|
+
Array.isArray(usage.iterations) &&
|
|
403
|
+
usage.iterations.length > 0
|
|
404
|
+
) {
|
|
405
|
+
const last = usage.iterations[usage.iterations.length - 1];
|
|
406
|
+
contextTokens =
|
|
407
|
+
(last.input_tokens ?? 0) +
|
|
408
|
+
(last.cache_read_input_tokens ?? 0) +
|
|
409
|
+
(last.cache_creation_input_tokens ?? 0);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Token counts, context window from SDK modelUsage (aggregated per model)
|
|
413
|
+
type MU = {
|
|
414
|
+
inputTokens?: number;
|
|
415
|
+
outputTokens?: number;
|
|
416
|
+
cacheReadInputTokens?: number;
|
|
417
|
+
cacheCreationInputTokens?: number;
|
|
418
|
+
contextWindow?: number;
|
|
419
|
+
};
|
|
420
|
+
const modelUsage = (msg as Record<string, unknown>).modelUsage as
|
|
421
|
+
| Record<string, MU>
|
|
422
|
+
| undefined;
|
|
423
|
+
if (modelUsage) {
|
|
424
|
+
for (const mu of Object.values(modelUsage)) {
|
|
425
|
+
sdkInputTokens += mu.inputTokens ?? 0;
|
|
426
|
+
sdkOutputTokens += mu.outputTokens ?? 0;
|
|
427
|
+
sdkCacheRead += mu.cacheReadInputTokens ?? 0;
|
|
428
|
+
sdkCacheWrite += mu.cacheCreationInputTokens ?? 0;
|
|
429
|
+
if (
|
|
430
|
+
mu.contextWindow &&
|
|
431
|
+
mu.contextWindow > 0 &&
|
|
432
|
+
contextWindow === undefined
|
|
433
|
+
) {
|
|
434
|
+
contextWindow = mu.contextWindow;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
309
437
|
}
|
|
438
|
+
log(
|
|
439
|
+
"agent",
|
|
440
|
+
`SDK result: modelUsage=${JSON.stringify(modelUsage)}, contextWindow=${contextWindow}, contextTokens=${contextTokens}, numApiCalls=${numApiCalls}`,
|
|
441
|
+
);
|
|
442
|
+
|
|
310
443
|
// If we still have unsent text and no streaming captured it
|
|
311
444
|
if (
|
|
312
445
|
!allResponseText &&
|
|
@@ -368,12 +501,15 @@ export async function handleMessage(
|
|
|
368
501
|
if (newSessionId) setSessionId(chatId, newSessionId);
|
|
369
502
|
incrementTurns(chatId);
|
|
370
503
|
recordUsage(chatId, {
|
|
371
|
-
inputTokens,
|
|
372
|
-
outputTokens,
|
|
373
|
-
cacheRead,
|
|
374
|
-
cacheWrite,
|
|
504
|
+
inputTokens: sdkInputTokens,
|
|
505
|
+
outputTokens: sdkOutputTokens,
|
|
506
|
+
cacheRead: sdkCacheRead,
|
|
507
|
+
cacheWrite: sdkCacheWrite,
|
|
375
508
|
durationMs,
|
|
376
509
|
model: activeModel,
|
|
510
|
+
contextTokens,
|
|
511
|
+
contextWindow,
|
|
512
|
+
numApiCalls,
|
|
377
513
|
});
|
|
378
514
|
|
|
379
515
|
// Set a descriptive session name from the first message
|
|
@@ -393,21 +529,21 @@ export async function handleMessage(
|
|
|
393
529
|
// The remaining currentBlockText is the final response text
|
|
394
530
|
allResponseText += currentBlockText;
|
|
395
531
|
|
|
396
|
-
const totalPrompt =
|
|
532
|
+
const totalPrompt = sdkInputTokens + sdkCacheRead + sdkCacheWrite;
|
|
397
533
|
const cacheHitPct =
|
|
398
|
-
totalPrompt > 0 ? Math.round((
|
|
534
|
+
totalPrompt > 0 ? Math.round((sdkCacheRead / totalPrompt) * 100) : 0;
|
|
399
535
|
|
|
400
536
|
log(
|
|
401
537
|
"agent",
|
|
402
|
-
`[${chatId}] -> (${durationMs}ms, in=${
|
|
538
|
+
`[${chatId}] -> (${durationMs}ms, in=${sdkInputTokens} out=${sdkOutputTokens} cache=${cacheHitPct}%` +
|
|
403
539
|
`${toolCalls > 0 ? ` tools=${toolCalls}` : ""})`,
|
|
404
540
|
);
|
|
405
541
|
traceMessage(chatId, "out", allResponseText, {
|
|
406
542
|
durationMs,
|
|
407
|
-
inputTokens,
|
|
408
|
-
outputTokens,
|
|
409
|
-
cacheRead,
|
|
410
|
-
cacheWrite,
|
|
543
|
+
inputTokens: sdkInputTokens,
|
|
544
|
+
outputTokens: sdkOutputTokens,
|
|
545
|
+
cacheRead: sdkCacheRead,
|
|
546
|
+
cacheWrite: sdkCacheWrite,
|
|
411
547
|
toolCalls,
|
|
412
548
|
model: activeModel,
|
|
413
549
|
});
|
|
@@ -415,9 +551,9 @@ export async function handleMessage(
|
|
|
415
551
|
return {
|
|
416
552
|
text: allResponseText.trim(),
|
|
417
553
|
durationMs,
|
|
418
|
-
inputTokens,
|
|
419
|
-
outputTokens,
|
|
420
|
-
cacheRead,
|
|
421
|
-
cacheWrite,
|
|
554
|
+
inputTokens: sdkInputTokens,
|
|
555
|
+
outputTokens: sdkOutputTokens,
|
|
556
|
+
cacheRead: sdkCacheRead,
|
|
557
|
+
cacheWrite: sdkCacheWrite,
|
|
422
558
|
};
|
|
423
559
|
}
|
package/src/bootstrap.ts
CHANGED
|
@@ -65,7 +65,7 @@ export async function bootstrap(
|
|
|
65
65
|
config.mempalace?.enabled === true ||
|
|
66
66
|
config.playwright?.enabled === true;
|
|
67
67
|
if (hasPlugins) {
|
|
68
|
-
const { loadPlugins,
|
|
68
|
+
const { loadPlugins, loadBuiltinPlugins, getPluginPromptAdditions } =
|
|
69
69
|
await import("./core/plugin.js");
|
|
70
70
|
|
|
71
71
|
// External plugins
|
|
@@ -76,108 +76,8 @@ export async function bootstrap(
|
|
|
76
76
|
await loadPlugins(config.plugins, frontends);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
// Built-in
|
|
80
|
-
|
|
81
|
-
const { createGitHubPlugin } = await import("./plugins/github/index.js");
|
|
82
|
-
const { getPlugin } = await import("./core/plugin.js");
|
|
83
|
-
const githubConfig = config.github as unknown as Record<string, unknown>;
|
|
84
|
-
const gh = createGitHubPlugin({ token: config.github.token });
|
|
85
|
-
registerPlugin(gh, githubConfig);
|
|
86
|
-
|
|
87
|
-
if (getPlugin("github")) {
|
|
88
|
-
try {
|
|
89
|
-
const GITHUB_INIT_TIMEOUT_MS = 15_000;
|
|
90
|
-
await Promise.race([
|
|
91
|
-
gh.init?.(githubConfig),
|
|
92
|
-
new Promise((_, reject) =>
|
|
93
|
-
setTimeout(
|
|
94
|
-
() => reject(new Error("GitHub init timed out after 15s")),
|
|
95
|
-
GITHUB_INIT_TIMEOUT_MS,
|
|
96
|
-
),
|
|
97
|
-
),
|
|
98
|
-
]);
|
|
99
|
-
} catch (err) {
|
|
100
|
-
log(
|
|
101
|
-
"github",
|
|
102
|
-
`Init warning: ${err instanceof Error ? err.message : err}`,
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Built-in: MemPalace
|
|
109
|
-
if (config.mempalace?.enabled) {
|
|
110
|
-
const { createMempalacePlugin } =
|
|
111
|
-
await import("./plugins/mempalace/index.js");
|
|
112
|
-
const { getPlugin } = await import("./core/plugin.js");
|
|
113
|
-
const { dirs, files: pathFiles } = await import("./util/paths.js");
|
|
114
|
-
const pythonPath =
|
|
115
|
-
config.mempalace.pythonPath ?? pathFiles.mempalacePython;
|
|
116
|
-
const palacePath = config.mempalace.palacePath ?? dirs.palace;
|
|
117
|
-
const mempalaceConfig = config.mempalace as unknown as Record<
|
|
118
|
-
string,
|
|
119
|
-
unknown
|
|
120
|
-
>;
|
|
121
|
-
const mp = createMempalacePlugin({ pythonPath, palacePath });
|
|
122
|
-
registerPlugin(mp, mempalaceConfig);
|
|
123
|
-
|
|
124
|
-
// Only call init if registration succeeded (validation passed)
|
|
125
|
-
if (getPlugin("mempalace")) {
|
|
126
|
-
try {
|
|
127
|
-
const MEMPALACE_INIT_TIMEOUT_MS = 30_000;
|
|
128
|
-
await Promise.race([
|
|
129
|
-
mp.init?.(mempalaceConfig),
|
|
130
|
-
new Promise((_, reject) =>
|
|
131
|
-
setTimeout(
|
|
132
|
-
() => reject(new Error("MemPalace init timed out after 30s")),
|
|
133
|
-
MEMPALACE_INIT_TIMEOUT_MS,
|
|
134
|
-
),
|
|
135
|
-
),
|
|
136
|
-
]);
|
|
137
|
-
} catch (err) {
|
|
138
|
-
log(
|
|
139
|
-
"mempalace",
|
|
140
|
-
`Init warning: ${err instanceof Error ? err.message : err}`,
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Built-in: Playwright
|
|
147
|
-
if (config.playwright?.enabled) {
|
|
148
|
-
const { createPlaywrightPlugin } =
|
|
149
|
-
await import("./plugins/playwright/index.js");
|
|
150
|
-
const { getPlugin } = await import("./core/plugin.js");
|
|
151
|
-
const playwrightConfig = config.playwright as unknown as Record<
|
|
152
|
-
string,
|
|
153
|
-
unknown
|
|
154
|
-
>;
|
|
155
|
-
const pw = createPlaywrightPlugin({
|
|
156
|
-
browser: config.playwright.browser,
|
|
157
|
-
headless: config.playwright.headless,
|
|
158
|
-
});
|
|
159
|
-
registerPlugin(pw, playwrightConfig);
|
|
160
|
-
|
|
161
|
-
if (getPlugin("playwright")) {
|
|
162
|
-
try {
|
|
163
|
-
const PW_INIT_TIMEOUT_MS = 15_000;
|
|
164
|
-
await Promise.race([
|
|
165
|
-
pw.init?.(playwrightConfig),
|
|
166
|
-
new Promise((_, reject) =>
|
|
167
|
-
setTimeout(
|
|
168
|
-
() => reject(new Error("Playwright init timed out after 15s")),
|
|
169
|
-
PW_INIT_TIMEOUT_MS,
|
|
170
|
-
),
|
|
171
|
-
),
|
|
172
|
-
]);
|
|
173
|
-
} catch (err) {
|
|
174
|
-
log(
|
|
175
|
-
"playwright",
|
|
176
|
-
`Init warning: ${err instanceof Error ? err.message : err}`,
|
|
177
|
-
);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
79
|
+
// Built-in plugins (GitHub, MemPalace, Playwright) — shared with hot-reload
|
|
80
|
+
await loadBuiltinPlugins(config);
|
|
181
81
|
|
|
182
82
|
rebuildSystemPrompt(config, getPluginPromptAdditions());
|
|
183
83
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared gateway actions — platform-agnostic handlers that work with any frontend.
|
|
3
3
|
*
|
|
4
|
-
* Handles: cron CRUD, fetch_url, in-memory history queries.
|
|
4
|
+
* Handles: cron CRUD, fetch_url, plugin reload, in-memory history queries.
|
|
5
5
|
* Returns null if the action isn't recognized (so the gateway delegates to the frontend).
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -310,6 +310,47 @@ export async function handleSharedAction(
|
|
|
310
310
|
return { ok: true, text: `Deleted cron job "${job.name}" (${jobId})` };
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
// ── Plugin hot-reload ──────────────────────────────────────────────
|
|
314
|
+
case "reload_plugins": {
|
|
315
|
+
try {
|
|
316
|
+
const { reloadPlugins, getPluginPromptAdditions } =
|
|
317
|
+
await import("./plugin.js");
|
|
318
|
+
const { rebuildSystemPrompt } = await import("../util/config.js");
|
|
319
|
+
|
|
320
|
+
// reloadPlugins reads + validates config internally — no double read.
|
|
321
|
+
// Frontends are derived from config if not explicitly provided.
|
|
322
|
+
const { names, config: freshConfig } = await reloadPlugins();
|
|
323
|
+
|
|
324
|
+
// Rebuild system prompt on the freshConfig, then update the backend's
|
|
325
|
+
// live config reference so subsequent messages use the new prompt
|
|
326
|
+
rebuildSystemPrompt(freshConfig, getPluginPromptAdditions());
|
|
327
|
+
try {
|
|
328
|
+
const { updateSystemPrompt } =
|
|
329
|
+
await import("../backend/claude-sdk/index.js");
|
|
330
|
+
updateSystemPrompt(freshConfig.systemPrompt);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
// Non-fatal — OpenCode backend doesn't expose updateSystemPrompt
|
|
333
|
+
log(
|
|
334
|
+
"gateway",
|
|
335
|
+
`reload_plugins: could not update backend prompt: ${err instanceof Error ? err.message : err}`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
log("gateway", `reload_plugins: ${names.length} plugins loaded`);
|
|
340
|
+
return {
|
|
341
|
+
ok: true,
|
|
342
|
+
text:
|
|
343
|
+
`Plugins reloaded successfully.\n` +
|
|
344
|
+
`Loaded (${names.length}): ${names.length > 0 ? names.join(", ") : "(none)"}`,
|
|
345
|
+
};
|
|
346
|
+
} catch (err) {
|
|
347
|
+
return {
|
|
348
|
+
ok: false,
|
|
349
|
+
error: `Plugin reload failed: ${err instanceof Error ? err.message : err}`,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
313
354
|
default:
|
|
314
355
|
return null; // not a shared action — delegate to frontend
|
|
315
356
|
}
|
package/src/core/heartbeat.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Runs at a configurable interval (default: 60 minutes).
|
|
5
5
|
* The agent reads instructions from ~/.talon/workspace/heartbeat-instructions.md
|
|
6
|
-
* and executes them using filesystem
|
|
6
|
+
* and executes them using filesystem tools and all loaded MCP plugins.
|
|
7
7
|
*
|
|
8
8
|
* Modeled after dream.ts but more general-purpose.
|
|
9
9
|
*/
|
|
@@ -17,6 +17,7 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
|
17
17
|
import { files as pathFiles, dirs } from "../util/paths.js";
|
|
18
18
|
import { log, logError, logWarn } from "../util/log.js";
|
|
19
19
|
import { toYMD } from "../util/time.js";
|
|
20
|
+
import { getPluginMcpServers } from "./plugin.js";
|
|
20
21
|
|
|
21
22
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
22
23
|
|
|
@@ -282,15 +283,15 @@ async function runHeartbeatAgent(
|
|
|
282
283
|
const options = {
|
|
283
284
|
model,
|
|
284
285
|
systemPrompt:
|
|
285
|
-
"You are a background heartbeat agent for Talon.
|
|
286
|
+
"You are a background heartbeat agent for Talon. You have access to filesystem tools and all registered MCP plugins. Follow the user-defined instructions precisely. Be efficient — you have limited time.",
|
|
286
287
|
cwd: workspace,
|
|
287
288
|
permissionMode: "bypassPermissions" as const,
|
|
288
289
|
allowDangerouslySkipPermissions: true,
|
|
289
290
|
...(configRef.claudeBinary
|
|
290
291
|
? { pathToClaudeCodeExecutable: configRef.claudeBinary }
|
|
291
292
|
: {}),
|
|
292
|
-
//
|
|
293
|
-
mcpServers:
|
|
293
|
+
// Load all registered plugin MCP servers (excludes frontend-specific tools like telegram)
|
|
294
|
+
mcpServers: getPluginMcpServers("", "heartbeat"),
|
|
294
295
|
disallowedTools: [
|
|
295
296
|
"EnterPlanMode",
|
|
296
297
|
"ExitPlanMode",
|
|
@@ -315,10 +316,12 @@ async function runHeartbeatAgent(
|
|
|
315
316
|
// running lock is not released while the subprocess is still active.
|
|
316
317
|
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
317
318
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
318
|
-
|
|
319
|
+
const t = setTimeout(
|
|
319
320
|
() => reject(new Error("Heartbeat agent timed out")),
|
|
320
321
|
HEARTBEAT_TIMEOUT_MS,
|
|
321
322
|
);
|
|
323
|
+
t.unref(); // Don't prevent Node.js from exiting cleanly during shutdown
|
|
324
|
+
timeoutHandle = t;
|
|
322
325
|
});
|
|
323
326
|
|
|
324
327
|
const agentPromise = (async () => {
|