talon-agent 1.50.0 → 1.52.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 +28 -2
- package/package.json +1 -1
- package/src/backend/claude-sdk/one-shot.ts +15 -2
- package/src/backend/codex/one-shot.ts +17 -2
- package/src/backend/kilo/one-shot.ts +4 -2
- package/src/backend/opencode/one-shot.ts +4 -2
- package/src/backend/remote-server/one-shot.ts +21 -2
- package/src/cli/daemon-api.ts +2 -1
- package/src/cli/index.ts +16 -0
- package/src/cli/install-sources.ts +106 -0
- package/src/cli/plugin-entries.ts +104 -0
- package/src/cli/plugin.ts +494 -0
- package/src/cli/skill.ts +252 -0
- package/src/core/agent-runtime/capabilities.ts +4 -1
- package/src/core/background/dream.ts +5 -3
- package/src/core/background/heartbeat/agent.ts +5 -3
- package/src/core/background/isolated-agent.ts +5 -4
- package/src/core/background/job-oneshot.ts +2 -2
- package/src/core/engine/gateway-actions/plugins.ts +36 -19
- package/src/core/engine/gateway.ts +24 -0
- package/src/core/plugin/entries.ts +105 -0
- package/src/core/plugin/loader.ts +14 -2
- package/src/core/plugin/manage.ts +99 -0
- package/src/core/plugin/types.ts +4 -0
- package/src/core/types.ts +12 -0
- package/src/frontend/native/extensions.ts +93 -0
- package/src/frontend/native/index.ts +13 -1
- package/src/frontend/native/protocol.ts +33 -0
- package/src/frontend/native/server.ts +37 -0
- package/src/frontend/native/settings.ts +7 -3
- package/src/storage/skill-store.ts +118 -5
- package/src/util/config.ts +7 -0
- package/src/util/paths.ts +3 -0
package/src/cli/skill.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `talon skill` — install / list / enable / disable / remove SKILL.md
|
|
3
|
+
* workflow bundles.
|
|
4
|
+
*
|
|
5
|
+
* All lifecycle logic lives in storage/skill-store.ts; this module only
|
|
6
|
+
* resolves install sources and renders outcomes. Installs accept a local
|
|
7
|
+
* folder, a git URL, or `owner/repo[/subpath]` (so `talon skill install
|
|
8
|
+
* anthropics/skills/skills/pdf` works). A source folder either IS a skill
|
|
9
|
+
* (has SKILL.md) or is a collection whose immediate children are skills —
|
|
10
|
+
* collections install every child.
|
|
11
|
+
*
|
|
12
|
+
* No daemon round-trip needed: the prompt index re-reads the store on the
|
|
13
|
+
* next prompt assembly, so changes apply to new sessions automatically.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import pc from "picocolors";
|
|
17
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
18
|
+
import { resolve } from "node:path";
|
|
19
|
+
import {
|
|
20
|
+
deleteSkill,
|
|
21
|
+
installSkillFromDir,
|
|
22
|
+
listSkills,
|
|
23
|
+
setSkillEnabled,
|
|
24
|
+
type Skill,
|
|
25
|
+
} from "../storage/skill-store.js";
|
|
26
|
+
import { cloneShallow, resolveSource } from "./install-sources.js";
|
|
27
|
+
|
|
28
|
+
const USAGE = [
|
|
29
|
+
` Usage: ${pc.cyan("talon skill <command>")}`,
|
|
30
|
+
"",
|
|
31
|
+
" Commands:",
|
|
32
|
+
` ${pc.cyan("list")} Show installed skills`,
|
|
33
|
+
` ${pc.cyan("install <source> [--force]")} Add skills from a local folder,`,
|
|
34
|
+
" git URL, or owner/repo[/subpath]",
|
|
35
|
+
` ${pc.cyan("enable <name>")} Restore a skill to the prompt index`,
|
|
36
|
+
` ${pc.cyan("disable <name>")} Hide a skill from the prompt index`,
|
|
37
|
+
` ${pc.cyan("remove <name>")} Delete a skill folder`,
|
|
38
|
+
"",
|
|
39
|
+
].join("\n");
|
|
40
|
+
|
|
41
|
+
function ok(message: string): void {
|
|
42
|
+
console.log(` ${pc.green("●")} ${message}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function fail(message: string): void {
|
|
46
|
+
console.log(` ${pc.red("✖")} ${message}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── list ────────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
function cmdList(): void {
|
|
52
|
+
const skills = listSkills();
|
|
53
|
+
if (skills.length === 0) {
|
|
54
|
+
console.log(
|
|
55
|
+
` ${pc.dim("No skills installed — try")} ${pc.cyan("talon skill install <source>")}\n`,
|
|
56
|
+
);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const header = ["NAME", "STATE", "UPDATED", "DESCRIPTION"];
|
|
61
|
+
const cells = skills.map((skill) => [
|
|
62
|
+
skill.name,
|
|
63
|
+
skill.enabled ? "enabled" : "disabled",
|
|
64
|
+
skill.updatedAt
|
|
65
|
+
? new Date(skill.updatedAt).toISOString().slice(0, 10)
|
|
66
|
+
: "unknown",
|
|
67
|
+
skill.description,
|
|
68
|
+
]);
|
|
69
|
+
const widths = header.map((h, col) =>
|
|
70
|
+
Math.max(h.length, ...cells.map((r) => r[col]!.length)),
|
|
71
|
+
);
|
|
72
|
+
const pad = (row: string[]) =>
|
|
73
|
+
row.map((cell, col) => cell.padEnd(widths[col]!));
|
|
74
|
+
|
|
75
|
+
console.log(` ${pc.dim(pad(header).join(" "))}`);
|
|
76
|
+
skills.forEach((skill, i) => {
|
|
77
|
+
const padded = pad(cells[i]!);
|
|
78
|
+
const state = cells[i]![1]!;
|
|
79
|
+
padded[1] =
|
|
80
|
+
(skill.enabled ? pc.green(state) : pc.dim(state)) +
|
|
81
|
+
" ".repeat(widths[1]! - state.length);
|
|
82
|
+
console.log(` ${padded.join(" ")}`);
|
|
83
|
+
});
|
|
84
|
+
console.log();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── install ─────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The folders to install from a source dir: itself when it is a skill,
|
|
91
|
+
* else every immediate child that is one.
|
|
92
|
+
*/
|
|
93
|
+
function collectSkillDirs(dir: string): string[] {
|
|
94
|
+
if (existsSync(resolve(dir, "SKILL.md"))) return [dir];
|
|
95
|
+
try {
|
|
96
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
97
|
+
.filter((entry) => entry.isDirectory())
|
|
98
|
+
.map((entry) => resolve(dir, entry.name))
|
|
99
|
+
.filter((child) => existsSync(resolve(child, "SKILL.md")))
|
|
100
|
+
.sort((a, b) => a.localeCompare(b));
|
|
101
|
+
} catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function installAll(dirs: string[], force: boolean): Skill[] {
|
|
107
|
+
const installed: Skill[] = [];
|
|
108
|
+
for (const dir of dirs) {
|
|
109
|
+
const outcome = installSkillFromDir(dir, { force });
|
|
110
|
+
if (outcome.ok) {
|
|
111
|
+
installed.push(outcome.skill);
|
|
112
|
+
ok(
|
|
113
|
+
`Installed ${pc.bold(outcome.skill.name)} — ${outcome.skill.description}`,
|
|
114
|
+
);
|
|
115
|
+
} else {
|
|
116
|
+
fail(outcome.error);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return installed;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function cmdInstall(args: string[]): Promise<void> {
|
|
123
|
+
const force = args.includes("--force");
|
|
124
|
+
const source = args.find((arg) => !arg.startsWith("-"));
|
|
125
|
+
if (!source) {
|
|
126
|
+
console.log(USAGE);
|
|
127
|
+
process.exitCode = 1;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const resolved = resolveSource(source);
|
|
132
|
+
if (resolved.kind === "other") {
|
|
133
|
+
fail(
|
|
134
|
+
`"${source}" is not a folder, git URL, or owner/repo — skills install from SKILL.md folders.`,
|
|
135
|
+
);
|
|
136
|
+
process.exitCode = 1;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let installed: Skill[];
|
|
141
|
+
if (resolved.kind === "local") {
|
|
142
|
+
const dirs = collectSkillDirs(resolved.dir);
|
|
143
|
+
if (dirs.length === 0) {
|
|
144
|
+
fail(`No SKILL.md found in ${resolved.dir} (or its immediate children).`);
|
|
145
|
+
process.exitCode = 1;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
installed = installAll(dirs, force);
|
|
149
|
+
} else {
|
|
150
|
+
const clone = cloneShallow(resolved.url);
|
|
151
|
+
if (!clone.ok) {
|
|
152
|
+
fail(clone.error);
|
|
153
|
+
process.exitCode = 1;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const root = resolved.subpath
|
|
158
|
+
? resolve(clone.dir, resolved.subpath)
|
|
159
|
+
: clone.dir;
|
|
160
|
+
if (!existsSync(root)) {
|
|
161
|
+
fail(`Path "${resolved.subpath}" not found in ${resolved.url}`);
|
|
162
|
+
process.exitCode = 1;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const dirs = collectSkillDirs(root);
|
|
166
|
+
if (dirs.length === 0) {
|
|
167
|
+
fail(
|
|
168
|
+
`No SKILL.md found under ${resolved.subpath ?? "the repository root"} (or its immediate children).`,
|
|
169
|
+
);
|
|
170
|
+
process.exitCode = 1;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
installed = installAll(dirs, force);
|
|
174
|
+
} finally {
|
|
175
|
+
clone.cleanup();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (installed.length === 0) {
|
|
180
|
+
process.exitCode = 1;
|
|
181
|
+
} else {
|
|
182
|
+
console.log(
|
|
183
|
+
` ${pc.dim("Skills appear in the prompt index from the next session.")}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
console.log();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── enable / disable / remove ───────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
function cmdSetEnabled(name: string | undefined, enabled: boolean): void {
|
|
192
|
+
const verb = enabled ? "enable" : "disable";
|
|
193
|
+
if (!name) {
|
|
194
|
+
fail(`Usage: ${pc.cyan(`talon skill ${verb} <name>`)}`);
|
|
195
|
+
process.exitCode = 1;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (!setSkillEnabled(name, enabled)) {
|
|
199
|
+
fail(`No skill named "${name}" — see ${pc.cyan("talon skill list")}.`);
|
|
200
|
+
process.exitCode = 1;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
ok(
|
|
204
|
+
enabled
|
|
205
|
+
? `Skill ${pc.bold(name)} enabled — back in the prompt index next session.`
|
|
206
|
+
: `Skill ${pc.bold(name)} disabled — hidden from the prompt index (still readable via read_skill).`,
|
|
207
|
+
);
|
|
208
|
+
console.log();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function cmdRemove(name: string | undefined): void {
|
|
212
|
+
if (!name) {
|
|
213
|
+
fail(`Usage: ${pc.cyan("talon skill remove <name>")}`);
|
|
214
|
+
process.exitCode = 1;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (!deleteSkill(name)) {
|
|
218
|
+
fail(`No skill named "${name}" — see ${pc.cyan("talon skill list")}.`);
|
|
219
|
+
process.exitCode = 1;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
ok(`Removed skill ${pc.bold(name)} (folder deleted).`);
|
|
223
|
+
console.log();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── dispatch ────────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
export async function runSkillCommand(args: string[]): Promise<void> {
|
|
229
|
+
console.log();
|
|
230
|
+
const [subcommand, ...rest] = args;
|
|
231
|
+
switch (subcommand) {
|
|
232
|
+
case "list":
|
|
233
|
+
case undefined:
|
|
234
|
+
cmdList();
|
|
235
|
+
break;
|
|
236
|
+
case "install":
|
|
237
|
+
await cmdInstall(rest);
|
|
238
|
+
break;
|
|
239
|
+
case "enable":
|
|
240
|
+
cmdSetEnabled(rest[0], true);
|
|
241
|
+
break;
|
|
242
|
+
case "disable":
|
|
243
|
+
cmdSetEnabled(rest[0], false);
|
|
244
|
+
break;
|
|
245
|
+
case "remove":
|
|
246
|
+
cmdRemove(rest[0]);
|
|
247
|
+
break;
|
|
248
|
+
default:
|
|
249
|
+
console.log(USAGE);
|
|
250
|
+
process.exitCode = 1;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -27,6 +27,7 @@ import type {
|
|
|
27
27
|
ModelPickerOptions,
|
|
28
28
|
ModelPickerResult,
|
|
29
29
|
OneShotAgentParams,
|
|
30
|
+
OneShotUsage,
|
|
30
31
|
UnifiedModelInfo,
|
|
31
32
|
UnifiedModelResolution,
|
|
32
33
|
UnifiedProviderInfo,
|
|
@@ -131,12 +132,14 @@ export interface ChatBackend {
|
|
|
131
132
|
* - `runOneShotAgent(params)` — accepts `OneShotAgentParams`
|
|
132
133
|
* (with its `appendLog` callback) so the heartbeat / dream /
|
|
133
134
|
* trigger log-file producers keep their direct write path.
|
|
135
|
+
* Resolves with the run's token usage when the SDK reports it
|
|
136
|
+
* (the task table records it at settlement); void otherwise.
|
|
134
137
|
* - `evictOrphanSubprocesses(label)` — backends that spawn
|
|
135
138
|
* per-run subprocesses (Claude SDK) implement this so a hung
|
|
136
139
|
* run can be force-cleaned after the abort grace window.
|
|
137
140
|
*/
|
|
138
141
|
export interface BackgroundRunner {
|
|
139
|
-
runOneShotAgent(params: OneShotAgentParams): Promise<void>;
|
|
142
|
+
runOneShotAgent(params: OneShotAgentParams): Promise<OneShotUsage | void>;
|
|
140
143
|
evictOrphanSubprocesses?(contextLabel: string): Promise<{
|
|
141
144
|
found: number;
|
|
142
145
|
termed: number;
|
|
@@ -296,15 +296,17 @@ If commands fail, log the error and continue — this stage is optional.`
|
|
|
296
296
|
});
|
|
297
297
|
|
|
298
298
|
const agentPromise = (async () => {
|
|
299
|
-
await background.runOneShotAgent(oneShotParams);
|
|
299
|
+
const usage = await background.runOneShotAgent(oneShotParams);
|
|
300
300
|
appendDreamLog(
|
|
301
301
|
dreamLogFile,
|
|
302
302
|
`\n---\n**Dream completed at ${new Date().toISOString()}**\n`,
|
|
303
303
|
);
|
|
304
|
+
return usage;
|
|
304
305
|
})();
|
|
305
306
|
|
|
307
|
+
let usage: Awaited<typeof agentPromise>;
|
|
306
308
|
try {
|
|
307
|
-
await Promise.race([agentPromise, timeoutPromise]);
|
|
309
|
+
usage = await Promise.race([agentPromise, timeoutPromise]);
|
|
308
310
|
} catch (err) {
|
|
309
311
|
task.fail(err);
|
|
310
312
|
appendDreamLog(
|
|
@@ -332,7 +334,7 @@ If commands fail, log the error and continue — this stage is optional.`
|
|
|
332
334
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
333
335
|
}
|
|
334
336
|
|
|
335
|
-
task.succeed();
|
|
337
|
+
task.succeed(usage ?? undefined);
|
|
336
338
|
return dreamLogFile;
|
|
337
339
|
}
|
|
338
340
|
|
|
@@ -220,15 +220,17 @@ export async function runHeartbeatAgent(
|
|
|
220
220
|
});
|
|
221
221
|
|
|
222
222
|
const agentPromise = (async () => {
|
|
223
|
-
await background.runOneShotAgent(oneShotParams);
|
|
223
|
+
const usage = await background.runOneShotAgent(oneShotParams);
|
|
224
224
|
await appendHeartbeatLog(
|
|
225
225
|
heartbeatLogFile,
|
|
226
226
|
`\n---\n**Heartbeat #${runCount} completed at ${new Date().toISOString()}**\n`,
|
|
227
227
|
);
|
|
228
|
+
return usage;
|
|
228
229
|
})();
|
|
229
230
|
|
|
231
|
+
let usage: Awaited<typeof agentPromise>;
|
|
230
232
|
try {
|
|
231
|
-
await Promise.race([agentPromise, timeoutPromise]);
|
|
233
|
+
usage = await Promise.race([agentPromise, timeoutPromise]);
|
|
232
234
|
} catch (err) {
|
|
233
235
|
// Snapshot timeout state and clear the timer immediately, BEFORE any awaits
|
|
234
236
|
// in the error-handling path. Otherwise the timer can fire during the async
|
|
@@ -278,7 +280,7 @@ export async function runHeartbeatAgent(
|
|
|
278
280
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
279
281
|
}
|
|
280
282
|
|
|
281
|
-
task.succeed();
|
|
283
|
+
task.succeed(usage ?? undefined);
|
|
282
284
|
return heartbeatLogFile;
|
|
283
285
|
}
|
|
284
286
|
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { BackgroundRunner } from "../agent-runtime/capabilities.js";
|
|
18
|
-
import type { OneShotAgentParams } from "../types.js";
|
|
18
|
+
import type { OneShotAgentParams, OneShotUsage } from "../types.js";
|
|
19
19
|
import { logWarn, logError } from "../../util/log.js";
|
|
20
20
|
|
|
21
21
|
/** Default bounded grace after an abort before giving up on the backend. */
|
|
@@ -55,11 +55,12 @@ async function raceWithTimeout<T>(
|
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
57
|
* Run the one-shot under a hard timeout. Throws on timeout (after the grace
|
|
58
|
-
* window) and re-throws any agent error.
|
|
58
|
+
* window) and re-throws any agent error. Resolves with the run's token
|
|
59
|
+
* usage when the backend reports it.
|
|
59
60
|
*/
|
|
60
61
|
export async function runIsolatedAgent(
|
|
61
62
|
opts: IsolatedRunOptions,
|
|
62
|
-
): Promise<void> {
|
|
63
|
+
): Promise<OneShotUsage | void> {
|
|
63
64
|
const { background, params, timeoutMs } = opts;
|
|
64
65
|
const graceMs = opts.abortGraceMs ?? DEFAULT_ABORT_GRACE_MS;
|
|
65
66
|
|
|
@@ -81,7 +82,7 @@ export async function runIsolatedAgent(
|
|
|
81
82
|
});
|
|
82
83
|
|
|
83
84
|
try {
|
|
84
|
-
await Promise.race([agentPromise, timeoutPromise]);
|
|
85
|
+
return await Promise.race([agentPromise, timeoutPromise]);
|
|
85
86
|
} catch (err) {
|
|
86
87
|
// Snapshot + clear before any await so a late timer can't reclassify a
|
|
87
88
|
// non-timeout failure as a timeout (heartbeat learned this the hard way).
|
|
@@ -157,7 +157,7 @@ export async function runJobOneShot(
|
|
|
157
157
|
};
|
|
158
158
|
|
|
159
159
|
try {
|
|
160
|
-
await runIsolatedAgent({
|
|
160
|
+
const usage = await runIsolatedAgent({
|
|
161
161
|
background,
|
|
162
162
|
params: oneShot,
|
|
163
163
|
timeoutMs: params.timeoutMs ?? DEFAULT_JOB_TIMEOUT_MS,
|
|
@@ -165,7 +165,7 @@ export async function runJobOneShot(
|
|
|
165
165
|
// sweep here could kill a concurrent heartbeat's subprocess. Bounded
|
|
166
166
|
// abort-grace is enough.
|
|
167
167
|
});
|
|
168
|
-
task.succeed();
|
|
168
|
+
task.succeed(usage ?? undefined);
|
|
169
169
|
} catch (err) {
|
|
170
170
|
task.fail(err);
|
|
171
171
|
throw err;
|
|
@@ -5,30 +5,47 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { log, logWarn } from "../../../util/log.js";
|
|
8
|
+
import type { Backend } from "../../agent-runtime/capabilities.js";
|
|
8
9
|
import type { SharedActionHandlers } from "./types.js";
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
/**
|
|
12
|
+
* The chat-independent half of a plugin reload: re-read config, reload
|
|
13
|
+
* plugins, rebuild the system prompt, and drop per-session prompt
|
|
14
|
+
* snapshots. Shared by the `reload_plugins` action (which additionally
|
|
15
|
+
* hot-swaps MCP servers on the caller's chat) and the gateway's
|
|
16
|
+
* `POST /plugins/reload` endpoint, which has no chat context. Throws on
|
|
17
|
+
* failure — each caller renders errors its own way.
|
|
18
|
+
*/
|
|
19
|
+
export async function performPluginReload(
|
|
20
|
+
backend?: Backend | null,
|
|
21
|
+
): Promise<{ names: string[] }> {
|
|
22
|
+
const { reloadPlugins, getPluginPromptAdditions } =
|
|
23
|
+
await import("../../plugin/index.js");
|
|
24
|
+
const { rebuildSystemPrompt } = await import("../../../util/config.js");
|
|
25
|
+
const { clearSystemPromptSnapshots } =
|
|
26
|
+
await import("../../../backend/shared/system-prompt.js");
|
|
18
27
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
28
|
+
// reloadPlugins reads + validates config internally — no double read.
|
|
29
|
+
// Frontends are derived from config if not explicitly provided.
|
|
30
|
+
const { names, config: freshConfig } = await reloadPlugins();
|
|
22
31
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
32
|
+
// Rebuild system prompt on the freshConfig, then update the backend's
|
|
33
|
+
// live config reference so subsequent messages use the new prompt
|
|
34
|
+
rebuildSystemPrompt(freshConfig, getPluginPromptAdditions());
|
|
35
|
+
backend?.control?.updateSystemPrompt?.(freshConfig.systemPrompt);
|
|
27
36
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
// Plugin prompt additions changed — drop per-session prompt
|
|
38
|
+
// snapshots so every chat's next turn picks up the new prompt
|
|
39
|
+
// (deliberate one-time cache re-write per live session).
|
|
40
|
+
clearSystemPromptSnapshots();
|
|
41
|
+
|
|
42
|
+
return { names };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const pluginHandlers: SharedActionHandlers = {
|
|
46
|
+
reload_plugins: async (body, chatId, backend) => {
|
|
47
|
+
try {
|
|
48
|
+
const { names } = await performPluginReload(backend);
|
|
32
49
|
|
|
33
50
|
// Hot-swap MCP servers on the active query so new plugin tools
|
|
34
51
|
// are available immediately (not just on the next message)
|
|
@@ -475,6 +475,30 @@ export class Gateway {
|
|
|
475
475
|
return;
|
|
476
476
|
}
|
|
477
477
|
|
|
478
|
+
if (req.method === "POST" && req.url === "/plugins/reload") {
|
|
479
|
+
// Hot-reload plugins from config — the transport for
|
|
480
|
+
// `talon plugin install/enable/disable`, which has no chat
|
|
481
|
+
// context and so cannot use the reload_plugins action. Same
|
|
482
|
+
// 127.0.0.1 trust boundary as /action.
|
|
483
|
+
try {
|
|
484
|
+
const { performPluginReload } =
|
|
485
|
+
await import("./gateway-actions/plugins.js");
|
|
486
|
+
const { names } = await performPluginReload(this.backend);
|
|
487
|
+
log("gateway", `/plugins/reload: ${names.length} plugins loaded`);
|
|
488
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
489
|
+
res.end(JSON.stringify({ ok: true, loaded: names }));
|
|
490
|
+
} catch (err) {
|
|
491
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
492
|
+
res.end(
|
|
493
|
+
JSON.stringify({
|
|
494
|
+
ok: false,
|
|
495
|
+
error: `Plugin reload failed: ${err instanceof Error ? err.message : err}`,
|
|
496
|
+
}),
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
478
502
|
if (req.url?.startsWith(HUB_PATH_PREFIX)) {
|
|
479
503
|
// MCP hub — daemon-hosted MCP-over-HTTP endpoints for every
|
|
480
504
|
// backend (see core/mcp-hub). Same 127.0.0.1 trust boundary
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers over the config `plugins` array, shared by every surface
|
|
3
|
+
* that lists or toggles plugins (the `talon plugin` command group, the
|
|
4
|
+
* client bridge's plugin endpoints). Entries here are the on-disk shape
|
|
5
|
+
* (`core/plugin/types.ts` PluginEntry), not loaded plugins. No filesystem
|
|
6
|
+
* or process access — everything is testable data-in/data-out.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** On-disk plugin entry — path-based module or standalone MCP server. */
|
|
10
|
+
export type PluginEntryJson = {
|
|
11
|
+
path?: string;
|
|
12
|
+
config?: Record<string, unknown>;
|
|
13
|
+
name?: string;
|
|
14
|
+
command?: string;
|
|
15
|
+
args?: string[];
|
|
16
|
+
env?: Record<string, string>;
|
|
17
|
+
enabled?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Built-in plugins toggled by their own config sections (`github.enabled`,
|
|
22
|
+
* …) rather than `plugins[]` entries — see core/plugin/builtins.ts.
|
|
23
|
+
*/
|
|
24
|
+
export const BUILTIN_PLUGINS = [
|
|
25
|
+
"github",
|
|
26
|
+
"mempalace",
|
|
27
|
+
"mem0",
|
|
28
|
+
"playwright",
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
export function isBuiltinPlugin(name: string): boolean {
|
|
32
|
+
return (BUILTIN_PLUGINS as readonly string[]).includes(name);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Split a config path on either separator — Windows configs carry `\`. */
|
|
36
|
+
function pathSegments(path: string): string[] {
|
|
37
|
+
return path.split(/[\\/]+/).filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Display name for an entry: the MCP name, or for path entries the package
|
|
42
|
+
* name — segments after the last `node_modules` (preserving `@scope/pkg`),
|
|
43
|
+
* else the folder basename.
|
|
44
|
+
*/
|
|
45
|
+
export function entryDisplayName(entry: PluginEntryJson): string {
|
|
46
|
+
if (entry.name) return entry.name;
|
|
47
|
+
if (!entry.path) return "(invalid entry)";
|
|
48
|
+
const segments = pathSegments(entry.path);
|
|
49
|
+
const nm = segments.lastIndexOf("node_modules");
|
|
50
|
+
if (nm >= 0 && nm < segments.length - 1) {
|
|
51
|
+
return segments.slice(nm + 1).join("/");
|
|
52
|
+
}
|
|
53
|
+
return segments[segments.length - 1] ?? entry.path;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Does `token` identify this entry? Display name, basename, or full path. */
|
|
57
|
+
export function entryMatches(entry: PluginEntryJson, token: string): boolean {
|
|
58
|
+
if (entryDisplayName(entry) === token) return true;
|
|
59
|
+
if (!entry.path) return false;
|
|
60
|
+
const segments = pathSegments(entry.path);
|
|
61
|
+
return entry.path === token || segments[segments.length - 1] === token;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function findEntryIndex(
|
|
65
|
+
entries: readonly PluginEntryJson[],
|
|
66
|
+
token: string,
|
|
67
|
+
): number {
|
|
68
|
+
return entries.findIndex((entry) => entryMatches(entry, token));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Index of an entry with the same identity as `candidate` (same module
|
|
73
|
+
* path or same MCP name) — the duplicate an install must replace.
|
|
74
|
+
*/
|
|
75
|
+
export function findConflictIndex(
|
|
76
|
+
entries: PluginEntryJson[],
|
|
77
|
+
candidate: PluginEntryJson,
|
|
78
|
+
): number {
|
|
79
|
+
return entries.findIndex((entry) =>
|
|
80
|
+
candidate.path !== undefined
|
|
81
|
+
? entry.path === candidate.path
|
|
82
|
+
: entry.name !== undefined && entry.name === candidate.name,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Copy of `entry` with the enabled state applied. Enabled is the default,
|
|
88
|
+
* so enabling removes the key rather than writing `enabled: true`.
|
|
89
|
+
*/
|
|
90
|
+
export function withEnabled(
|
|
91
|
+
entry: PluginEntryJson,
|
|
92
|
+
enabled: boolean,
|
|
93
|
+
): PluginEntryJson {
|
|
94
|
+
const { enabled: _drop, ...rest } = entry;
|
|
95
|
+
return enabled ? rest : { ...rest, enabled: false };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Package name of an npm spec, version stripped: `pkg@1.2` → `pkg`,
|
|
100
|
+
* `@scope/pkg@^1` → `@scope/pkg`. A leading `@` is the scope, not a version.
|
|
101
|
+
*/
|
|
102
|
+
export function npmSpecName(spec: string): string {
|
|
103
|
+
const at = spec.lastIndexOf("@");
|
|
104
|
+
return at > 0 ? spec.slice(0, at) : spec;
|
|
105
|
+
}
|
|
@@ -16,8 +16,11 @@ import type {
|
|
|
16
16
|
import { isMcpPlugin } from "./types.js";
|
|
17
17
|
import { registry, _deps } from "./registry.js";
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Candidate entry point paths, checked in order. Exported for
|
|
21
|
+
* `talon plugin install`, which verifies a module before adding it.
|
|
22
|
+
*/
|
|
23
|
+
export const ENTRY_CANDIDATES = [
|
|
21
24
|
"src/index.ts",
|
|
22
25
|
"dist/index.js",
|
|
23
26
|
"index.ts",
|
|
@@ -35,6 +38,15 @@ export async function loadPlugins(
|
|
|
35
38
|
activeFrontends?: string[],
|
|
36
39
|
): Promise<void> {
|
|
37
40
|
for (const entry of pluginConfigs) {
|
|
41
|
+
// Disabled entries stay in config (so `talon plugin enable` can restore
|
|
42
|
+
// them) but are never loaded or registered.
|
|
43
|
+
if (entry.enabled === false) {
|
|
44
|
+
log(
|
|
45
|
+
"plugin",
|
|
46
|
+
`Skipped disabled plugin: ${isMcpPlugin(entry) ? entry.name : entry.path}`,
|
|
47
|
+
);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
38
50
|
// Standalone MCP servers are registered for getPluginMcpServers, not loaded as modules
|
|
39
51
|
if (isMcpPlugin(entry)) {
|
|
40
52
|
if (registry.registerMcpEntry(entry)) {
|