talon-agent 1.51.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/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/plugin.ts +1 -1
- 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/plugin/entries.ts +105 -0
- package/src/core/plugin/manage.ts +99 -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/package.json
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
186
|
-
|
|
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 {
|