talon-agent 1.51.0 → 1.53.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.51.0",
3
+ "version": "1.53.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -14,7 +14,7 @@
14
14
  import { readdir, readFile } from "node:fs/promises";
15
15
  import { query } from "@anthropic-ai/claude-agent-sdk";
16
16
  import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
17
- import type { OneShotAgentParams } from "../../core/types.js";
17
+ import type { OneShotAgentParams, OneShotUsage } from "../../core/types.js";
18
18
  import { log } from "../../util/log.js";
19
19
  import { ALLOWED_TOOLS_BACKGROUND } from "../../core/constants.js";
20
20
  import { buildMcpServers, buildPluginMcpServers } from "./options.js";
@@ -53,7 +53,7 @@ export function initClaudeOneShot(cfg: OneShotConfig): void {
53
53
 
54
54
  export async function runOneShotAgent(
55
55
  params: OneShotAgentParams,
56
- ): Promise<void> {
56
+ ): Promise<OneShotUsage | void> {
57
57
  const {
58
58
  prompt,
59
59
  systemPrompt,
@@ -86,9 +86,22 @@ export async function runOneShotAgent(
86
86
  options: options as Parameters<typeof query>[0]["options"],
87
87
  });
88
88
 
89
+ // The final `result` message carries the run's total token usage — the
90
+ // settlement figure the task table records.
91
+ let usage: OneShotUsage | undefined;
89
92
  for await (const msg of qi) {
90
93
  await formatAndAppendMessage(appendLog, msg);
94
+ if (msg.type === "result") {
95
+ const u = msg.usage;
96
+ usage = {
97
+ inputTokens: u.input_tokens ?? 0,
98
+ outputTokens: u.output_tokens ?? 0,
99
+ cacheRead: u.cache_read_input_tokens ?? 0,
100
+ cacheWrite: u.cache_creation_input_tokens ?? 0,
101
+ };
102
+ }
91
103
  }
104
+ return usage;
92
105
  }
93
106
 
94
107
  /**
@@ -17,7 +17,7 @@
17
17
  * fires — no orphan process handling needed.
18
18
  */
19
19
 
20
- import type { OneShotAgentParams } from "../../core/types.js";
20
+ import type { OneShotAgentParams, OneShotUsage } from "../../core/types.js";
21
21
  import { log, logWarn } from "../../util/log.js";
22
22
  import { appendBackendSuffix } from "../shared/index.js";
23
23
  import { ensureCodex, getCodexAuthInfo } from "./init.js";
@@ -69,7 +69,7 @@ function resolveOneShotModel(requested: string): {
69
69
 
70
70
  export async function runOneShotAgent(
71
71
  params: OneShotAgentParams,
72
- ): Promise<void> {
72
+ ): Promise<OneShotUsage | void> {
73
73
  const {
74
74
  prompt,
75
75
  systemPrompt,
@@ -118,10 +118,25 @@ export async function runOneShotAgent(
118
118
  signal: abortController.signal,
119
119
  });
120
120
 
121
+ // `turn.completed.usage` is cumulative across the run — the last one
122
+ // seen is the settlement figure the task table records.
123
+ let usage: OneShotUsage | undefined;
121
124
  for await (const event of events) {
122
125
  if (abortController.signal.aborted) break;
123
126
  await appendCodexEvent(appendLog, event);
127
+ if (event.type === "turn.completed") {
128
+ const u = (event as { usage?: Record<string, number> }).usage;
129
+ if (u) {
130
+ usage = {
131
+ inputTokens: u.input_tokens ?? 0,
132
+ outputTokens: u.output_tokens ?? 0,
133
+ cacheRead: u.cached_input_tokens ?? 0,
134
+ cacheWrite: 0, // Codex doesn't report cache writes
135
+ };
136
+ }
137
+ }
124
138
  }
139
+ return usage;
125
140
  } catch (err) {
126
141
  if (
127
142
  abortController.signal.aborted ||
@@ -7,7 +7,7 @@
7
7
  * suffix; everything else is the shared lifecycle.
8
8
  */
9
9
 
10
- import type { OneShotAgentParams } from "../../core/types.js";
10
+ import type { OneShotAgentParams, OneShotUsage } from "../../core/types.js";
11
11
  import { runRemoteOneShotAgent } from "../remote-server/one-shot.js";
12
12
  import {
13
13
  ensureServer,
@@ -21,7 +21,9 @@ import {
21
21
  errMsg,
22
22
  } from "./server.js";
23
23
 
24
- export function runOneShotAgent(params: OneShotAgentParams): Promise<void> {
24
+ export function runOneShotAgent(
25
+ params: OneShotAgentParams,
26
+ ): Promise<OneShotUsage | void> {
25
27
  return runRemoteOneShotAgent(
26
28
  {
27
29
  label: "Kilo",
@@ -7,7 +7,7 @@
7
7
  * suffix; everything else is the shared lifecycle.
8
8
  */
9
9
 
10
- import type { OneShotAgentParams } from "../../core/types.js";
10
+ import type { OneShotAgentParams, OneShotUsage } from "../../core/types.js";
11
11
  import { runRemoteOneShotAgent } from "../remote-server/one-shot.js";
12
12
  import {
13
13
  ensureServer,
@@ -21,7 +21,9 @@ import {
21
21
  errMsg,
22
22
  } from "./server.js";
23
23
 
24
- export function runOneShotAgent(params: OneShotAgentParams): Promise<void> {
24
+ export function runOneShotAgent(
25
+ params: OneShotAgentParams,
26
+ ): Promise<OneShotUsage | void> {
25
27
  return runRemoteOneShotAgent(
26
28
  {
27
29
  label: "OpenCode",
@@ -26,8 +26,12 @@
26
26
  * model stops spending tokens once the timeout fires.
27
27
  */
28
28
 
29
- import type { OneShotAgentParams } from "../../core/types.js";
29
+ import type { OneShotAgentParams, OneShotUsage } from "../../core/types.js";
30
30
  import { logWarn } from "../../util/log.js";
31
+ import {
32
+ extractAssistantUsage,
33
+ type RemoteAssistantInfo,
34
+ } from "./session-helpers.js";
31
35
  import { appendBackendSuffix } from "../shared/index.js";
32
36
 
33
37
  // ── Client surface ──────────────────────────────────────────────────────────
@@ -86,7 +90,7 @@ export async function runRemoteOneShotAgent<
86
90
  >(
87
91
  bindings: RemoteOneShotBindings<TClient>,
88
92
  params: OneShotAgentParams,
89
- ): Promise<void> {
93
+ ): Promise<OneShotUsage | void> {
90
94
  const {
91
95
  prompt,
92
96
  systemPrompt,
@@ -162,6 +166,21 @@ export async function runRemoteOneShotAgent<
162
166
  for (const part of parts) {
163
167
  await appendResponsePart(appendLog, part);
164
168
  }
169
+
170
+ // The prompt response's assistant info carries the run's token usage —
171
+ // the settlement figure the task table records.
172
+ const info = data?.info as RemoteAssistantInfo | undefined;
173
+ if (info) {
174
+ const u = extractAssistantUsage(info);
175
+ if (u.inputTokens > 0 || u.outputTokens > 0) {
176
+ return {
177
+ inputTokens: u.inputTokens,
178
+ outputTokens: u.outputTokens,
179
+ cacheRead: u.cacheRead,
180
+ cacheWrite: u.cacheWrite,
181
+ };
182
+ }
183
+ }
165
184
  } finally {
166
185
  abortController.signal.removeEventListener("abort", onAbort);
167
186
  await bindings.disconnectChatMcpServer(oc, chatMcpServerName);
package/src/cli/plugin.ts CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  npmSpecName,
39
39
  withEnabled,
40
40
  type PluginEntryJson,
41
- } from "./plugin-entries.js";
41
+ } from "../core/plugin/entries.js";
42
42
 
43
43
  /** Plugin init can legitimately take ~30s (MemPalace) — outlive it. */
44
44
  const RELOAD_TIMEOUT_MS = 60_000;
@@ -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;
@@ -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
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Plugin management over the live config — list and toggle, outcomes as
3
+ * data. The client bridge's plugin endpoints run through here in-process;
4
+ * the CLI edits config.json out-of-process with the same `entries.ts`
5
+ * helpers and converges through `POST /plugins/reload`. Rendering (and
6
+ * persisting/hot-applying, which are surface concerns) stay with the
7
+ * caller: a toggle returns the exact partial document to merge into
8
+ * talon.json so the surface owns its own write path.
9
+ */
10
+
11
+ import type { TalonConfig } from "../../util/config.js";
12
+ import { isPathPlugin, type PluginEntry } from "./types.js";
13
+ import {
14
+ BUILTIN_PLUGINS,
15
+ entryDisplayName,
16
+ findEntryIndex,
17
+ isBuiltinPlugin,
18
+ withEnabled,
19
+ } from "./entries.js";
20
+
21
+ /** One plugin as every management surface lists it. */
22
+ export type PluginItem = {
23
+ readonly name: string;
24
+ readonly kind: "builtin" | "module" | "mcp";
25
+ readonly enabled: boolean;
26
+ /** Where it comes from: a config section, module path, or MCP command. */
27
+ readonly source: string;
28
+ };
29
+
30
+ export type PluginToggleOutcome =
31
+ | {
32
+ readonly ok: true;
33
+ readonly name: string;
34
+ /** Partial talon.json document the surface must persist. */
35
+ readonly persist: Record<string, unknown>;
36
+ }
37
+ | { readonly ok: false; readonly error: string };
38
+
39
+ function builtinSection(
40
+ config: TalonConfig,
41
+ name: string,
42
+ ): { enabled?: boolean } | undefined {
43
+ return (config as unknown as Record<string, { enabled?: boolean }>)[name];
44
+ }
45
+
46
+ /** Every plugin: built-ins first, then configured entries, config order. */
47
+ export function listPluginItems(config: TalonConfig): PluginItem[] {
48
+ const builtins: PluginItem[] = BUILTIN_PLUGINS.map((name) => ({
49
+ name,
50
+ kind: "builtin" as const,
51
+ enabled: builtinSection(config, name)?.enabled === true,
52
+ source: `config.${name}`,
53
+ }));
54
+ const configured: PluginItem[] = (config.plugins ?? []).map((entry) => ({
55
+ name: entryDisplayName(entry),
56
+ kind: isPathPlugin(entry) ? ("module" as const) : ("mcp" as const),
57
+ enabled: entry.enabled !== false,
58
+ source: isPathPlugin(entry)
59
+ ? entry.path
60
+ : `${entry.command}${entry.args ? ` ${entry.args.join(" ")}` : ""}`,
61
+ }));
62
+ return [...builtins, ...configured];
63
+ }
64
+
65
+ /**
66
+ * Toggle a plugin on the LIVE config object (so in-process readers see the
67
+ * change immediately) and report what to persist. Hot-applying (plugin
68
+ * reload, prompt rebuild) is the caller's follow-up — this function only
69
+ * decides and mutates state.
70
+ */
71
+ export function setPluginEnabled(
72
+ config: TalonConfig,
73
+ rawName: string,
74
+ enabled: boolean,
75
+ ): PluginToggleOutcome {
76
+ const name = rawName.trim();
77
+ if (!name) return { ok: false, error: "A plugin name is required." };
78
+
79
+ if (isBuiltinPlugin(name)) {
80
+ const section = { ...(builtinSection(config, name) ?? {}), enabled };
81
+ (config as unknown as Record<string, unknown>)[name] = section;
82
+ return { ok: true, name, persist: { [name]: section } };
83
+ }
84
+
85
+ const list: PluginEntry[] = config.plugins ?? [];
86
+ const index = findEntryIndex(list, name);
87
+ if (index < 0) {
88
+ return { ok: false, error: `No plugin named "${name}".` };
89
+ }
90
+ // withEnabled only adds/drops the `enabled` key, so the entry keeps its
91
+ // (validated) format — safe to narrow back from the JSON helper shape.
92
+ list[index] = withEnabled(list[index]!, enabled) as PluginEntry;
93
+ config.plugins = list;
94
+ return {
95
+ ok: true,
96
+ name: entryDisplayName(list[index]!),
97
+ persist: { plugins: list },
98
+ };
99
+ }
package/src/core/types.ts CHANGED
@@ -120,6 +120,18 @@ export interface ModelPickerResult {
120
120
  * fire-and-forget agent invocation: spawn an agent with a prompt, let it run
121
121
  * tools, log everything to a file, return when it finishes (or when aborted).
122
122
  */
123
+ /**
124
+ * Token usage a one-shot run reports at settlement, when the backend's SDK
125
+ * surfaces it. Shape-compatible with the task table's `TaskUsage`, so the
126
+ * background callers can hand it straight to `TaskHandle.succeed`.
127
+ */
128
+ export type OneShotUsage = {
129
+ inputTokens: number;
130
+ outputTokens: number;
131
+ cacheRead: number;
132
+ cacheWrite: number;
133
+ };
134
+
123
135
  export type OneShotAgentParams = {
124
136
  /** The user prompt the agent should execute. */
125
137
  prompt: string;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Extensions over the bridge — plugins and skills, list + toggle.
3
+ *
4
+ * This is what backs the companion app's Plugins and Skills settings
5
+ * sub-menus. Listing is a pure read (core/plugin/manage.ts for plugins,
6
+ * the skill store for skills); a toggle mutates the live state, persists
7
+ * it, and hot-applies so the change is real on the very next turn:
8
+ *
9
+ * - plugin toggle → merge into talon.json + full plugin reload (same
10
+ * path as `POST /plugins/reload`), so tools appear/disappear live;
11
+ * - skill toggle → `.disabled` marker via the skill store + system
12
+ * prompt rebuild (skills render into the prompt; no plugin reload
13
+ * needed).
14
+ *
15
+ * Toggles hide capability from the model's default view — they never
16
+ * restrict it. That contract lives in the stores; nothing here adds
17
+ * gating on top.
18
+ */
19
+
20
+ import type { Backend } from "../../core/agent-runtime/capabilities.js";
21
+ import { performPluginReload } from "../../core/engine/gateway-actions/plugins.js";
22
+ import { getPluginPromptAdditions } from "../../core/plugin/index.js";
23
+ import { listPluginItems, setPluginEnabled } from "../../core/plugin/manage.js";
24
+ import { clearSystemPromptSnapshots } from "../../backend/shared/system-prompt.js";
25
+ import { listSkills, setSkillEnabled } from "../../storage/skill-store.js";
26
+ import { rebuildSystemPrompt, type TalonConfig } from "../../util/config.js";
27
+ import { log } from "../../util/log.js";
28
+ import type { PluginItem, SkillItem, ToggleResult } from "./protocol.js";
29
+ import { persistConfigPatch } from "./settings.js";
30
+
31
+ export function pluginItems(config: TalonConfig): PluginItem[] {
32
+ return listPluginItems(config);
33
+ }
34
+
35
+ export function skillItems(): SkillItem[] {
36
+ return listSkills().map(({ name, description, enabled }) => ({
37
+ name,
38
+ description,
39
+ enabled,
40
+ }));
41
+ }
42
+
43
+ /**
44
+ * Toggle a plugin: mutate the live config, persist the change, then
45
+ * hot-reload plugins (rebuilds the system prompt and drops per-session
46
+ * prompt snapshots on the way). A reload failure after a successful save
47
+ * is reported as an error — the state is on disk, but the caller must
48
+ * know the daemon hasn't applied it.
49
+ */
50
+ export async function togglePlugin(
51
+ config: TalonConfig,
52
+ backend: Backend | null,
53
+ name: string,
54
+ enabled: boolean,
55
+ ): Promise<ToggleResult> {
56
+ const outcome = setPluginEnabled(config, name, enabled);
57
+ if (!outcome.ok) return { ok: false, error: outcome.error };
58
+ persistConfigPatch(outcome.persist);
59
+ log(
60
+ "native",
61
+ `Plugin ${outcome.name} ${enabled ? "enabled" : "disabled"} via bridge`,
62
+ );
63
+ try {
64
+ await performPluginReload(backend);
65
+ } catch (err) {
66
+ return {
67
+ ok: false,
68
+ error: `Saved, but the hot reload failed: ${err instanceof Error ? err.message : String(err)} — restart Talon to apply.`,
69
+ };
70
+ }
71
+ return { ok: true };
72
+ }
73
+
74
+ /**
75
+ * Toggle a skill: the store drops/creates the `.disabled` marker, then the
76
+ * system prompt is rebuilt so the skill index reflects the change on every
77
+ * chat's next turn. No plugin reload — skills don't carry tools.
78
+ */
79
+ export function toggleSkill(
80
+ config: TalonConfig,
81
+ backend: Backend | null,
82
+ name: string,
83
+ enabled: boolean,
84
+ ): ToggleResult {
85
+ if (!setSkillEnabled(name, enabled)) {
86
+ return { ok: false, error: `No skill named "${name}".` };
87
+ }
88
+ log("native", `Skill ${name} ${enabled ? "enabled" : "disabled"} via bridge`);
89
+ rebuildSystemPrompt(config, getPluginPromptAdditions());
90
+ backend?.control?.updateSystemPrompt?.(config.systemPrompt);
91
+ clearSystemPromptSnapshots();
92
+ return { ok: true };
93
+ }
@@ -52,6 +52,12 @@ import {
52
52
  import { resetSession } from "../../storage/sessions.js";
53
53
  import { resetPulseCheckpoint } from "../../core/background/pulse.js";
54
54
  import { configSnapshot, applyConfigUpdate } from "./settings.js";
55
+ import {
56
+ pluginItems,
57
+ skillItems,
58
+ togglePlugin,
59
+ toggleSkill,
60
+ } from "./extensions.js";
55
61
  import { resolveModel } from "../../core/models/catalog.js";
56
62
  import {
57
63
  getBackendForChat,
@@ -743,7 +749,7 @@ export function createNativeFrontend(
743
749
  return {
744
750
  app: "talon-bridge",
745
751
  protocol: BRIDGE_PROTOCOL_VERSION,
746
- capabilities: ["mesh", "mesh-commands"],
752
+ capabilities: ["mesh", "mesh-commands", "plugins-skills"],
747
753
  botName,
748
754
  backend: config.backend,
749
755
  model: resolveModel(config.model)?.displayName ?? config.model,
@@ -1168,6 +1174,12 @@ export function createNativeFrontend(
1168
1174
  broadcast({ kind: "status", status: status() });
1169
1175
  return snap;
1170
1176
  },
1177
+ listPlugins: () => pluginItems(config),
1178
+ setPluginEnabled: (name, enabled) =>
1179
+ togglePlugin(config, getPooledBackend(config.backend), name, enabled),
1180
+ listSkills: () => skillItems(),
1181
+ setSkillEnabled: (name, enabled) =>
1182
+ toggleSkill(config, getPooledBackend(config.backend), name, enabled),
1171
1183
  control,
1172
1184
  logs: ({ lines, minLevel, component }) =>
1173
1185
  readLogEntries(files.log, { limit: lines, minLevel, component }),
@@ -197,6 +197,39 @@ export type BackendOption = {
197
197
  label: string;
198
198
  };
199
199
 
200
+ /**
201
+ * One installed plugin, as `GET /plugins` lists it. Toggled via
202
+ * `POST /plugins/toggle` (`{ name, enabled }` → `ToggleResult`). Additive
203
+ * in v1 behind the `plugins-skills` capability — older clients never call
204
+ * these endpoints.
205
+ */
206
+ export type PluginItem = {
207
+ name: string;
208
+ /** Built-in (config-section) plugin, module plugin, or MCP server. */
209
+ kind: "builtin" | "module" | "mcp";
210
+ enabled: boolean;
211
+ /** Where it comes from: a config section, module path, or MCP command. */
212
+ source: string;
213
+ };
214
+
215
+ /**
216
+ * One installed skill, as `GET /skills` lists it. Toggled via
217
+ * `POST /skills/toggle` — a disabled skill drops out of the model's
218
+ * prompt index but stays installed.
219
+ */
220
+ export type SkillItem = {
221
+ name: string;
222
+ description: string;
223
+ enabled: boolean;
224
+ };
225
+
226
+ /**
227
+ * Application-level outcome of a toggle, always carried in an HTTP 200
228
+ * body (mirrors `/backend`: the client renders ok/error, HTTP-level
229
+ * failures stay transport errors).
230
+ */
231
+ export type ToggleResult = { ok: boolean; error?: string };
232
+
200
233
  // Mesh device shapes are canonical in core (the mesh is daemon-wide state,
201
234
  // readable from every frontend); re-exported here so bridge clients keep
202
235
  // depending on the protocol module alone.
@@ -40,7 +40,10 @@ import {
40
40
  type LogEntry,
41
41
  type LogLevel,
42
42
  type ModelOption,
43
+ type PluginItem,
43
44
  type SearchResult,
45
+ type SkillItem,
46
+ type ToggleResult,
44
47
  } from "./protocol.js";
45
48
  import type { ConfigSnapshot } from "./settings.js";
46
49
 
@@ -100,6 +103,14 @@ export type BridgeServerHandlers = {
100
103
  getConfig(): ConfigSnapshot;
101
104
  /** Change daemon settings; returns the fresh snapshot. */
102
105
  setConfig(update: Record<string, unknown>): ConfigSnapshot;
106
+ /** Installed plugins (built-ins + configured entries) with state. */
107
+ listPlugins(): PluginItem[];
108
+ /** Enable/disable a plugin; persists + hot-reloads. */
109
+ setPluginEnabled(name: string, enabled: boolean): Promise<ToggleResult>;
110
+ /** Installed skills with state. */
111
+ listSkills(): SkillItem[];
112
+ /** Enable/disable a skill; rebuilds the prompt index. */
113
+ setSkillEnabled(name: string, enabled: boolean): ToggleResult;
103
114
  /** Fire a daemon-level control action (e.g. "restart", "dream"). */
104
115
  control(action: string): Promise<{ ok: boolean; message: string }>;
105
116
  /** Newest daemon log entries (for the client's log viewer). */
@@ -552,6 +563,32 @@ export class BridgeServer {
552
563
  });
553
564
  }
554
565
 
566
+ if (method === "GET" && path === "/plugins")
567
+ return this.json(res, 200, { plugins: this.handlers.listPlugins() });
568
+
569
+ if (method === "POST" && path === "/plugins/toggle") {
570
+ const body = await this.readJson(req);
571
+ // Always 200: ok/error is an application result the client renders,
572
+ // not an HTTP-level failure (mirrors /backend).
573
+ const result = await this.handlers.setPluginEnabled(
574
+ asString(body.name) ?? "",
575
+ body.enabled === true,
576
+ );
577
+ return this.json(res, 200, result);
578
+ }
579
+
580
+ if (method === "GET" && path === "/skills")
581
+ return this.json(res, 200, { skills: this.handlers.listSkills() });
582
+
583
+ if (method === "POST" && path === "/skills/toggle") {
584
+ const body = await this.readJson(req);
585
+ const result = this.handlers.setSkillEnabled(
586
+ asString(body.name) ?? "",
587
+ body.enabled === true,
588
+ );
589
+ return this.json(res, 200, result);
590
+ }
591
+
555
592
  if (method === "GET" && path === "/config")
556
593
  return this.json(res, 200, this.handlers.getConfig());
557
594
 
@@ -175,15 +175,19 @@ export function applyConfigUpdate(
175
175
  }
176
176
 
177
177
  if (Object.keys(persist).length > 0) {
178
- persistToFile(persist);
178
+ persistConfigPatch(persist);
179
179
  log("native", `Config updated: ${Object.keys(persist).join(", ")}`);
180
180
  }
181
181
 
182
182
  return configSnapshot(config);
183
183
  }
184
184
 
185
- /** Merge a partial update into talon.json on disk, preserving everything else. */
186
- function persistToFile(update: Record<string, unknown>): void {
185
+ /**
186
+ * Merge a partial update into talon.json on disk, preserving everything
187
+ * else. Shared by every bridge surface that persists config changes
188
+ * (settings sync here, plugin toggles in extensions.ts).
189
+ */
190
+ export function persistConfigPatch(update: Record<string, unknown>): void {
187
191
  const file = pathFiles.config;
188
192
  let current: Record<string, unknown> = {};
189
193
  try {