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.
@@ -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
- export const pluginHandlers: SharedActionHandlers = {
11
- reload_plugins: async (body, chatId, backend) => {
12
- try {
13
- const { reloadPlugins, getPluginPromptAdditions } =
14
- await import("../../plugin/index.js");
15
- const { rebuildSystemPrompt } = await import("../../../util/config.js");
16
- const { clearSystemPromptSnapshots } =
17
- await import("../../../backend/shared/system-prompt.js");
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
- // reloadPlugins reads + validates config internally — no double read.
20
- // Frontends are derived from config if not explicitly provided.
21
- const { names, config: freshConfig } = await reloadPlugins();
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
- // Rebuild system prompt on the freshConfig, then update the backend's
24
- // live config reference so subsequent messages use the new prompt
25
- rebuildSystemPrompt(freshConfig, getPluginPromptAdditions());
26
- backend?.control?.updateSystemPrompt?.(freshConfig.systemPrompt);
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
- // Plugin prompt additions changed — drop per-session prompt
29
- // snapshots so every chat's next turn picks up the new prompt
30
- // (deliberate one-time cache re-write per live session).
31
- clearSystemPromptSnapshots();
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
- /** Candidate entry point paths, checked in order. */
20
- const ENTRY_CANDIDATES = [
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)) {