@falkenslab/agent-kit 0.26.0 → 0.27.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/dist/chat/extensions.d.ts +14 -0
- package/dist/chat/extensions.js +34 -3
- package/dist/core/agentSpec.d.ts +17 -0
- package/dist/core/extensions.d.ts +7 -0
- package/dist/core/externalExtensions.d.ts +30 -0
- package/dist/core/externalExtensions.js +37 -0
- package/dist/core/hooks/guidedGate.d.ts +57 -0
- package/dist/core/hooks/guidedGate.js +97 -0
- package/dist/core/hooks/transcriptLogger.d.ts +2 -0
- package/dist/core/hooks/transcriptLogger.js +1 -1
- package/dist/core/messages/de.js +7 -1
- package/dist/core/messages/en.d.ts +16 -0
- package/dist/core/messages/en.js +7 -1
- package/dist/core/messages/es.js +7 -1
- package/dist/core/messages/fr.js +7 -1
- package/dist/core/readOnlyHints.d.ts +12 -6
- package/dist/core/readOnlyHints.js +14 -8
- package/dist/core/session.js +46 -5
- package/dist/core/tools/humanApproval.d.ts +4 -2
- package/dist/core/tools/humanApproval.js +3 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/tui/extensionCommand.d.ts +1 -1
- package/dist/tui/extensionCommand.js +14 -6
- package/package.json +1 -1
- package/schemas/agent-kit-metadata.schema.json +5 -0
|
@@ -39,6 +39,20 @@ export type ExtensionAction = {
|
|
|
39
39
|
export declare function describeEntry(entry: CatalogEntry): string;
|
|
40
40
|
/** An installed extension's options, one line each: its value, its default, or whether a sensitive one is set (never its value). */
|
|
41
41
|
export declare function describeOptions(extension: InstalledExtension): Promise<string[]>;
|
|
42
|
+
/** What guided mode keeps about an extension's tools (#74, #76): whether they ask, and the answers kept. None for one without tools. */
|
|
43
|
+
export declare function describeConfirm(extension: InstalledExtension): string[];
|
|
44
|
+
/**
|
|
45
|
+
* `confirm=on|off|reset` among `config`'s pairs (#74, #76): `off` lets its tools run without asking
|
|
46
|
+
* in guided mode, `on` asks again, `reset` forgets the answers kept. Returns the other pairs and
|
|
47
|
+
* the extension as it is now, or why it can't.
|
|
48
|
+
*/
|
|
49
|
+
export declare function applyConfirm(extension: InstalledExtension, pairs: readonly string[]): Promise<{
|
|
50
|
+
rest: string[];
|
|
51
|
+
extension: InstalledExtension;
|
|
52
|
+
changed: boolean;
|
|
53
|
+
} | {
|
|
54
|
+
error: string;
|
|
55
|
+
}>;
|
|
42
56
|
/**
|
|
43
57
|
* `/extensions` in a chat (#37): with nothing after it, what the session runs with, what's off
|
|
44
58
|
* and why, and what's installed; with `enable <name>` or `disable <name>`, changes its lock and
|
package/dist/chat/extensions.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { extensionOptionStates, setExtensionEnabled, setExtensionOptions } from "../core/externalExtensions.js";
|
|
2
|
+
import { extensionOptionStates, readLock, setExtensionConfirm, setExtensionEnabled, setExtensionOptions } from "../core/externalExtensions.js";
|
|
3
3
|
import { t } from "../core/messages/index.js";
|
|
4
4
|
import { extensionCatalog } from "../core/extensionCatalog.js";
|
|
5
5
|
/** One line for an installed extension: name and version, scope, on or off, its author, where it came from. */
|
|
@@ -54,6 +54,31 @@ export async function describeOptions(extension) {
|
|
|
54
54
|
return ` ${key} = ${shown} (${option.title}${option.required ? `, ${t().optionRequired}` : ""})`;
|
|
55
55
|
});
|
|
56
56
|
}
|
|
57
|
+
/** What guided mode keeps about an extension's tools (#74, #76): whether they ask, and the answers kept. None for one without tools. */
|
|
58
|
+
export function describeConfirm(extension) {
|
|
59
|
+
if (!Object.keys(extension.manifest?.servers ?? {}).length)
|
|
60
|
+
return [];
|
|
61
|
+
const confirm = extension.lock.confirm;
|
|
62
|
+
if (confirm?.off)
|
|
63
|
+
return [t().extensionConfirmOff(extension.name)];
|
|
64
|
+
const kept = Object.entries(confirm?.tools ?? {});
|
|
65
|
+
return [t().extensionConfirmOn(extension.name), ...(kept.length ? kept.map(([tool, answer]) => t().confirmKept(tool, answer)) : [t().confirmNoneKept])];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* `confirm=on|off|reset` among `config`'s pairs (#74, #76): `off` lets its tools run without asking
|
|
69
|
+
* in guided mode, `on` asks again, `reset` forgets the answers kept. Returns the other pairs and
|
|
70
|
+
* the extension as it is now, or why it can't.
|
|
71
|
+
*/
|
|
72
|
+
export async function applyConfirm(extension, pairs) {
|
|
73
|
+
const rest = pairs.filter((pair) => !pair.startsWith("confirm="));
|
|
74
|
+
const values = pairs.filter((pair) => pair.startsWith("confirm=")).map((pair) => pair.slice("confirm=".length).toLowerCase());
|
|
75
|
+
if (values.some((value) => !["on", "off", "reset"].includes(value)))
|
|
76
|
+
return { error: "confirm takes on, off or reset" };
|
|
77
|
+
for (const value of values)
|
|
78
|
+
await setExtensionConfirm(extension.scopeDir, extension.name, value === "reset" ? { reset: true } : { off: value === "off" });
|
|
79
|
+
const lock = values.length ? (await readLock(extension.scopeDir))[extension.name] : undefined;
|
|
80
|
+
return { rest, extension: lock ? { ...extension, lock } : extension, changed: values.length > 0 };
|
|
81
|
+
}
|
|
57
82
|
/**
|
|
58
83
|
* `/extensions` in a chat (#37): with nothing after it, what the session runs with, what's off
|
|
59
84
|
* and why, and what's installed; with `enable <name>` or `disable <name>`, changes its lock and
|
|
@@ -96,10 +121,16 @@ export async function chatExtensionsCommand(line, status, canReopen) {
|
|
|
96
121
|
return { lines: [t().extensionNotInstalled(name)] };
|
|
97
122
|
if (!extra.length) {
|
|
98
123
|
const options = await describeOptions(installed);
|
|
99
|
-
return { lines: options.length ? [t().extensionOptions(name), ...options] : [t().extensionNoOptions(name)] };
|
|
124
|
+
return { lines: [...(options.length ? [t().extensionOptions(name), ...options] : [t().extensionNoOptions(name)]), ...describeConfirm(installed)] };
|
|
100
125
|
}
|
|
126
|
+
// Guided mode's answers: the gate reads them on each call, so nothing restarts (#74, #76).
|
|
127
|
+
const confirm = await applyConfirm(installed, extra);
|
|
128
|
+
if ("error" in confirm)
|
|
129
|
+
return { lines: [`(${confirm.error})`] };
|
|
130
|
+
if (!confirm.rest.length)
|
|
131
|
+
return { lines: describeConfirm(confirm.extension) };
|
|
101
132
|
const typed = {};
|
|
102
|
-
for (const pair of
|
|
133
|
+
for (const pair of confirm.rest) {
|
|
103
134
|
const at = pair.indexOf("=");
|
|
104
135
|
const key = at > 0 ? pair.slice(0, at) : pair;
|
|
105
136
|
if (installed.manifest?.userConfig[key]?.sensitive)
|
package/dist/core/agentSpec.d.ts
CHANGED
|
@@ -186,6 +186,11 @@ export interface AgentSpec<TConfig extends BaseSessionConfig> {
|
|
|
186
186
|
approved: string;
|
|
187
187
|
rejected: string;
|
|
188
188
|
};
|
|
189
|
+
/**
|
|
190
|
+
* Guided mode's gate for installed extensions' tools that change things (#74, #76, see
|
|
191
|
+
* hooks/guidedGate.ts): one that isn't known to only read waits for the person's yes. On by default.
|
|
192
|
+
*/
|
|
193
|
+
extensionGate?: ExtensionGateSpec;
|
|
189
194
|
/**
|
|
190
195
|
* Text for the generic manual-intervention checkpoint (see tools/manualLogin.ts), AND
|
|
191
196
|
* this domain's opt-in signal for offering that checkpoint at all: `buildSessionOptions()`
|
|
@@ -209,6 +214,18 @@ export interface AgentSpec<TConfig extends BaseSessionConfig> {
|
|
|
209
214
|
*/
|
|
210
215
|
planMode?: PlanModeSpec<TConfig>;
|
|
211
216
|
}
|
|
217
|
+
/** The agent's word on guided mode's gate for extensions' tools (`AgentSpec.extensionGate`). */
|
|
218
|
+
export interface ExtensionGateSpec {
|
|
219
|
+
/** `false` turns the gate off: extensions' tools run without asking, as the model decides. */
|
|
220
|
+
enabled?: boolean;
|
|
221
|
+
/**
|
|
222
|
+
* Tools that always ask, even inside a batch the person approved, and never offer "always", by
|
|
223
|
+
* extension and short tool name (`{ canva: ["create-design"] }`): added to its manifest's `confirmTools`.
|
|
224
|
+
*/
|
|
225
|
+
confirmTools?: Record<string, string[]>;
|
|
226
|
+
/** In autonomous mode, where no one can be asked: `"deny"` (the default) blocks those tools, `"allow"` runs them. */
|
|
227
|
+
autonomous?: "deny" | "allow";
|
|
228
|
+
}
|
|
212
229
|
/** Who an agent is, as told to its model (`AgentSpec.identity`). */
|
|
213
230
|
export interface AgentIdentity {
|
|
214
231
|
/** The agent's name, e.g. "padawan". */
|
|
@@ -64,6 +64,13 @@ export interface ExtensionContribution {
|
|
|
64
64
|
* no `readOnlyTools`. A list in the manifest wins: its servers aren't here.
|
|
65
65
|
*/
|
|
66
66
|
readOnlyHintServers?: string[];
|
|
67
|
+
/**
|
|
68
|
+
* Its MCP tools that always ask the person in guided mode, even inside an approved batch, by full
|
|
69
|
+
* name (#76): an installed extension's manifest's `confirmTools`.
|
|
70
|
+
*/
|
|
71
|
+
confirmTools?: string[];
|
|
72
|
+
/** Its MCP servers whose tools that always ask guided mode learns from their `destructiveHint`: an installed extension whose manifest lists no `confirmTools`. */
|
|
73
|
+
confirmHintServers?: string[];
|
|
67
74
|
/**
|
|
68
75
|
* Its MCP servers that are remote (#61), by session name: the SDK fixes their URL and headers
|
|
69
76
|
* when the session opens, so a changed option takes opening it again, and they may need the
|
|
@@ -101,6 +101,12 @@ export interface ExternalManifest {
|
|
|
101
101
|
* list is the whole answer, even empty.
|
|
102
102
|
*/
|
|
103
103
|
readOnlyTools?: string[];
|
|
104
|
+
/**
|
|
105
|
+
* Its tools that always ask the person in guided mode (short names), even inside a batch they
|
|
106
|
+
* approved, and never offer "always" (#76): what creates, edits or publishes in their account.
|
|
107
|
+
* Without the key, its servers' `destructiveHint` says which.
|
|
108
|
+
*/
|
|
109
|
+
confirmTools?: string[];
|
|
104
110
|
/** Its tools' chat labels, by short name (of any of its servers) and language (English when the kit's isn't there). */
|
|
105
111
|
labels: Record<string, Partial<Record<Language, ExternalToolLabel>>>;
|
|
106
112
|
/** What it says about itself in a session, for the agent to tell the person (`SessionFacts`). */
|
|
@@ -140,6 +146,8 @@ export interface LockEntry {
|
|
|
140
146
|
options?: Record<string, OptionValue>;
|
|
141
147
|
/** The npm packages its servers run with npx (#54), by `<package>@<version>`: in the agent's package cache, checked when it loads. */
|
|
142
148
|
packages?: Record<string, CachedPackage>;
|
|
149
|
+
/** What the person said about its tools in guided mode (#74, #76): kept answers, or the gate off for it. */
|
|
150
|
+
confirm?: ExtensionConfirm;
|
|
143
151
|
enabled: boolean;
|
|
144
152
|
/** When it was installed, ISO 8601 in UTC. */
|
|
145
153
|
installed: string;
|
|
@@ -264,6 +272,28 @@ export declare function removeExtension(scopeDir: string, name: string, options?
|
|
|
264
272
|
}): Promise<boolean>;
|
|
265
273
|
/** The size in bytes of the files under `dir` (0 when it doesn't exist). */
|
|
266
274
|
export declare function folderSize(dir: string): Promise<number>;
|
|
275
|
+
/** A kept answer to guided mode's question about one of an extension's tools (#76). */
|
|
276
|
+
export type ToolConfirmation = "always" | "never";
|
|
277
|
+
/** What the person said about an extension's tools in guided mode (#74, #76), in its lock. */
|
|
278
|
+
export interface ExtensionConfirm {
|
|
279
|
+
/** Its tools run without asking: the person trusts it. */
|
|
280
|
+
off?: boolean;
|
|
281
|
+
/** Their kept answers, by the tool's short name. */
|
|
282
|
+
tools?: Record<string, ToolConfirmation>;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Changes what's kept about an installed extension's tools in guided mode: `off` turns the gate
|
|
286
|
+
* off (or back on) for it; `tool` keeps an answer for one tool (`undefined` asks again);
|
|
287
|
+
* `reset` forgets every kept answer. False when it isn't installed there.
|
|
288
|
+
*/
|
|
289
|
+
export declare function setExtensionConfirm(scopeDir: string, name: string, change: {
|
|
290
|
+
off?: boolean;
|
|
291
|
+
tool?: {
|
|
292
|
+
name: string;
|
|
293
|
+
answer: ToolConfirmation | undefined;
|
|
294
|
+
};
|
|
295
|
+
reset?: boolean;
|
|
296
|
+
}): Promise<boolean>;
|
|
267
297
|
/** Enables or disables an installed extension in a scope; false when it isn't there. */
|
|
268
298
|
export declare function setExtensionEnabled(scopeDir: string, name: string, enabled: boolean): Promise<boolean>;
|
|
269
299
|
/**
|
|
@@ -140,6 +140,7 @@ export async function readExternalManifest(dir) {
|
|
|
140
140
|
requirements: parseRequirements(kit.requirements),
|
|
141
141
|
servers: await pluginServers(dir, raw),
|
|
142
142
|
...(Array.isArray(kit.readOnlyTools) ? { readOnlyTools: kit.readOnlyTools.filter((tool) => typeof tool === "string") } : {}),
|
|
143
|
+
...(Array.isArray(kit.confirmTools) ? { confirmTools: kit.confirmTools.filter((tool) => typeof tool === "string") } : {}),
|
|
143
144
|
labels: kit.labels ?? {},
|
|
144
145
|
...(kit.help ? { help: kit.help } : {}),
|
|
145
146
|
userConfig: parseUserConfig(raw.userConfig),
|
|
@@ -424,6 +425,10 @@ function externalExtension(installed, manifest, cache, unavailable = []) {
|
|
|
424
425
|
...(manifest.readOnlyTools
|
|
425
426
|
? { readOnlyTools: Object.keys(servers).flatMap((server) => manifest.readOnlyTools.map((tool) => `mcp__${server}__${tool}`)) }
|
|
426
427
|
: { readOnlyTools: [], readOnlyHintServers: Object.keys(servers) }),
|
|
428
|
+
// The same for what always asks in guided mode (#76): the list, or the servers' destructiveHint.
|
|
429
|
+
...(manifest.confirmTools
|
|
430
|
+
? { confirmTools: Object.keys(servers).flatMap((server) => manifest.confirmTools.map((tool) => `mcp__${server}__${tool}`)) }
|
|
431
|
+
: { confirmHintServers: Object.keys(servers) }),
|
|
427
432
|
toolLabels: manifestToolLabels(manifest, Object.keys(servers)),
|
|
428
433
|
...(remote.length ? { remoteServers: remote } : {}),
|
|
429
434
|
...(manifest.help ? { helpLines: [manifest.help] } : {}),
|
|
@@ -568,6 +573,9 @@ export async function addExtension(source, scopeDir, options = {}) {
|
|
|
568
573
|
const kept = Object.fromEntries(Object.entries(lock[manifest.name]?.options ?? {}).filter(([key]) => manifest.userConfig[key] && !manifest.userConfig[key].sensitive));
|
|
569
574
|
if (Object.keys(kept).length)
|
|
570
575
|
locked.options = kept;
|
|
576
|
+
// And what the person said about its tools in guided mode (#76): theirs, not the version's.
|
|
577
|
+
if (lock[manifest.name]?.confirm)
|
|
578
|
+
locked.confirm = lock[manifest.name].confirm;
|
|
571
579
|
await rm(target, { recursive: true, force: true });
|
|
572
580
|
await mkdir(scopeDir, { recursive: true });
|
|
573
581
|
const origin = options.origin ?? (isGitSource(source) ? source : from);
|
|
@@ -636,6 +644,35 @@ export async function folderSize(dir) {
|
|
|
636
644
|
}
|
|
637
645
|
return size;
|
|
638
646
|
}
|
|
647
|
+
/**
|
|
648
|
+
* Changes what's kept about an installed extension's tools in guided mode: `off` turns the gate
|
|
649
|
+
* off (or back on) for it; `tool` keeps an answer for one tool (`undefined` asks again);
|
|
650
|
+
* `reset` forgets every kept answer. False when it isn't installed there.
|
|
651
|
+
*/
|
|
652
|
+
export async function setExtensionConfirm(scopeDir, name, change) {
|
|
653
|
+
const lock = await readLock(scopeDir);
|
|
654
|
+
const entry = lock[name];
|
|
655
|
+
if (!entry)
|
|
656
|
+
return false;
|
|
657
|
+
const confirm = { ...entry.confirm, tools: change.reset ? {} : { ...entry.confirm?.tools } };
|
|
658
|
+
if (change.off !== undefined)
|
|
659
|
+
confirm.off = change.off;
|
|
660
|
+
if (change.tool) {
|
|
661
|
+
if (change.tool.answer)
|
|
662
|
+
confirm.tools[change.tool.name] = change.tool.answer;
|
|
663
|
+
else
|
|
664
|
+
delete confirm.tools[change.tool.name];
|
|
665
|
+
}
|
|
666
|
+
if (!confirm.off)
|
|
667
|
+
delete confirm.off;
|
|
668
|
+
if (!Object.keys(confirm.tools).length)
|
|
669
|
+
delete confirm.tools;
|
|
670
|
+
lock[name] = { ...entry, ...(Object.keys(confirm).length ? { confirm } : {}) };
|
|
671
|
+
if (!Object.keys(confirm).length)
|
|
672
|
+
delete lock[name].confirm;
|
|
673
|
+
await writeLock(scopeDir, lock);
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
639
676
|
/** Enables or disables an installed extension in a scope; false when it isn't there. */
|
|
640
677
|
export async function setExtensionEnabled(scopeDir, name, enabled) {
|
|
641
678
|
const lock = await readLock(scopeDir);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import type { Mode } from "../agentSpec.js";
|
|
3
|
+
import type { ToolLabels } from "../toolLabels.js";
|
|
4
|
+
/** An installed extension whose tools the guided gate watches. */
|
|
5
|
+
export interface GuardedExtension {
|
|
6
|
+
name: string;
|
|
7
|
+
/** Its scope's folder: its lock keeps what the person said about its tools. */
|
|
8
|
+
scopeDir: string;
|
|
9
|
+
/** Its MCP servers, by session name. */
|
|
10
|
+
servers: readonly string[];
|
|
11
|
+
/** Its tools that always ask (full names): its manifest's `confirmTools` and the agent's own list. */
|
|
12
|
+
confirmTools: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
/** One answer the gate gave, for the transcript: what was asked, and what the person (or the mode) said. */
|
|
15
|
+
export interface GuidedDecision {
|
|
16
|
+
extension: string;
|
|
17
|
+
tool: string;
|
|
18
|
+
answer: "yes" | "session" | "always" | "no" | "never" | "kept-never" | "autonomous-deny";
|
|
19
|
+
}
|
|
20
|
+
/** The guided gate: its `PreToolUse` hook, and what ends or starts an approved batch. */
|
|
21
|
+
export interface GuidedGate {
|
|
22
|
+
preToolUse: HookCallback;
|
|
23
|
+
/** A `UserPromptSubmit` hook: the person's next message ends an approved batch. */
|
|
24
|
+
userPromptSubmit: HookCallback;
|
|
25
|
+
/** `request_human_approval`'s answer: approved, the calls that follow don't ask until the person's next message. */
|
|
26
|
+
approvalAnswered(approved: boolean): void;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Guided mode's gate for installed extensions' tools that change things (#74, #76): ADR-025 treats
|
|
30
|
+
* every extension tool as publishing unless it's known to only read, and in guided mode the person
|
|
31
|
+
* says yes before one runs, whatever the model did first. The step gate covers interactive mode
|
|
32
|
+
* (it asks before everything) and the plan gate plan mode (it denies), so this one decides only in
|
|
33
|
+
* guided and autonomous mode, where no one can be asked and `autonomous` says what happens.
|
|
34
|
+
*
|
|
35
|
+
* Read-only tools (the manifest's `readOnlyTools`, else the server's `readOnlyHint`) never ask.
|
|
36
|
+
* The rest ask through the interaction port, with answers the person can make last: this call,
|
|
37
|
+
* this session, always (kept in the extension's lock), no, never (kept too). Right after
|
|
38
|
+
* `request_human_approval` is approved, calls go on until the person's next message (a batch),
|
|
39
|
+
* except those that always ask: the agent's or the manifest's `confirmTools`, else the server's
|
|
40
|
+
* `destructiveHint`, which aren't offered "always" either. An extension whose lock says `off`
|
|
41
|
+
* runs without asking. Approving gives no decision of its own, so the other gates still judge.
|
|
42
|
+
*/
|
|
43
|
+
export declare function createGuidedGate(options: {
|
|
44
|
+
runDir: string;
|
|
45
|
+
mode: () => Mode;
|
|
46
|
+
extensions: readonly GuardedExtension[];
|
|
47
|
+
/** Whether a tool only reads: the extensions' `readOnlyTools`, else their servers' hints. */
|
|
48
|
+
isReadOnly(tool: string): Promise<boolean>;
|
|
49
|
+
/** Whether a tool's server marks it `destructiveHint`, for those whose manifest lists no `confirmTools`. */
|
|
50
|
+
isDestructive(tool: string): Promise<boolean>;
|
|
51
|
+
/** In autonomous mode, where no one can be asked: denied (the default) or run. */
|
|
52
|
+
autonomous?: "deny" | "allow";
|
|
53
|
+
/** The tools' labels in the session's language, to say what's about to happen. */
|
|
54
|
+
labels?: () => ToolLabels;
|
|
55
|
+
/** Each answer, for the transcript. */
|
|
56
|
+
record?(decision: GuidedDecision): Promise<void>;
|
|
57
|
+
}): GuidedGate;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { askForChoice } from "./humanInput.js";
|
|
2
|
+
import { readLock, setExtensionConfirm } from "../externalExtensions.js";
|
|
3
|
+
import { t } from "../messages/index.js";
|
|
4
|
+
/** The kit's approval tool: asking it ends a batch, and its yes starts one. */
|
|
5
|
+
const APPROVAL_TOOL = "mcp__approvals__request_human_approval";
|
|
6
|
+
/**
|
|
7
|
+
* Guided mode's gate for installed extensions' tools that change things (#74, #76): ADR-025 treats
|
|
8
|
+
* every extension tool as publishing unless it's known to only read, and in guided mode the person
|
|
9
|
+
* says yes before one runs, whatever the model did first. The step gate covers interactive mode
|
|
10
|
+
* (it asks before everything) and the plan gate plan mode (it denies), so this one decides only in
|
|
11
|
+
* guided and autonomous mode, where no one can be asked and `autonomous` says what happens.
|
|
12
|
+
*
|
|
13
|
+
* Read-only tools (the manifest's `readOnlyTools`, else the server's `readOnlyHint`) never ask.
|
|
14
|
+
* The rest ask through the interaction port, with answers the person can make last: this call,
|
|
15
|
+
* this session, always (kept in the extension's lock), no, never (kept too). Right after
|
|
16
|
+
* `request_human_approval` is approved, calls go on until the person's next message (a batch),
|
|
17
|
+
* except those that always ask: the agent's or the manifest's `confirmTools`, else the server's
|
|
18
|
+
* `destructiveHint`, which aren't offered "always" either. An extension whose lock says `off`
|
|
19
|
+
* runs without asking. Approving gives no decision of its own, so the other gates still judge.
|
|
20
|
+
*/
|
|
21
|
+
export function createGuidedGate(options) {
|
|
22
|
+
const thisSession = new Set();
|
|
23
|
+
let batch = false;
|
|
24
|
+
const deny = (input, reason) => ({ hookSpecificOutput: { hookEventName: input.hook_event_name, permissionDecision: "deny", permissionDecisionReason: reason } });
|
|
25
|
+
const preToolUse = async (input) => {
|
|
26
|
+
const pre = input;
|
|
27
|
+
const full = pre.tool_name;
|
|
28
|
+
// A new approval request ends the batch the last one started, whatever its answer.
|
|
29
|
+
if (full === APPROVAL_TOOL)
|
|
30
|
+
batch = false;
|
|
31
|
+
const mode = options.mode();
|
|
32
|
+
if (mode !== "guided" && mode !== "autonomous")
|
|
33
|
+
return {};
|
|
34
|
+
const server = options.extensions.flatMap((extension) => extension.servers.map((name) => ({ extension, prefix: `mcp__${name}__` }))).find(({ prefix }) => full.startsWith(prefix));
|
|
35
|
+
if (!server)
|
|
36
|
+
return {};
|
|
37
|
+
const { extension } = server;
|
|
38
|
+
const tool = full.slice(server.prefix.length);
|
|
39
|
+
if (await options.isReadOnly(full))
|
|
40
|
+
return {};
|
|
41
|
+
const confirm = (await readLock(extension.scopeDir).catch(() => ({})))[extension.name]?.confirm;
|
|
42
|
+
if (confirm?.off)
|
|
43
|
+
return {};
|
|
44
|
+
const record = async (answer) => await options.record?.({ extension: extension.name, tool: full, answer }).catch(() => { });
|
|
45
|
+
const kept = confirm?.tools?.[tool];
|
|
46
|
+
if (kept === "never") {
|
|
47
|
+
await record("kept-never");
|
|
48
|
+
return deny(pre, `The person never lets ${extension.name} run ${tool} (they said so before). Don't call it again: do the task another way, or tell them it needs it.`);
|
|
49
|
+
}
|
|
50
|
+
const alwaysAsks = extension.confirmTools.includes(full) || (await options.isDestructive(full));
|
|
51
|
+
if (kept === "always" && !alwaysAsks)
|
|
52
|
+
return {};
|
|
53
|
+
if (thisSession.has(full))
|
|
54
|
+
return {};
|
|
55
|
+
if (mode === "autonomous") {
|
|
56
|
+
if (options.autonomous === "allow")
|
|
57
|
+
return {};
|
|
58
|
+
await record("autonomous-deny");
|
|
59
|
+
return deny(pre, `Autonomous mode: no one can approve ${extension.name}'s ${tool}, which changes things, so it isn't run. Do what you can without it, and say it needs a person to approve it.`);
|
|
60
|
+
}
|
|
61
|
+
if (batch && !alwaysAsks)
|
|
62
|
+
return {};
|
|
63
|
+
const answers = t().confirmAnswers;
|
|
64
|
+
const choices = [answers.yes, answers.session, ...(alwaysAsks ? [] : [answers.always]), answers.no, answers.never];
|
|
65
|
+
const label = options.labels?.()[full]?.label((pre.tool_input ?? {}));
|
|
66
|
+
const answer = await askForChoice(options.runDir, {
|
|
67
|
+
title: t().guidedTitle(extension.name),
|
|
68
|
+
lines: [...(label ? [label] : []), t().toolLine(full), t().parametersLine(JSON.stringify(pre.tool_input, null, 2))],
|
|
69
|
+
}, choices);
|
|
70
|
+
const picked = answer.chosen[0];
|
|
71
|
+
if (picked === answers.yes || picked === answers.session || picked === answers.always) {
|
|
72
|
+
if (picked === answers.session)
|
|
73
|
+
thisSession.add(full);
|
|
74
|
+
if (picked === answers.always)
|
|
75
|
+
await setExtensionConfirm(extension.scopeDir, extension.name, { tool: { name: tool, answer: "always" } });
|
|
76
|
+
await record(picked === answers.yes ? "yes" : picked === answers.session ? "session" : "always");
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
if (picked === answers.never) {
|
|
80
|
+
await setExtensionConfirm(extension.scopeDir, extension.name, { tool: { name: tool, answer: "never" } });
|
|
81
|
+
await record("never");
|
|
82
|
+
return deny(pre, `The person never lets ${extension.name} run ${tool}. Don't call it again: do the task another way, or tell them it needs it.`);
|
|
83
|
+
}
|
|
84
|
+
await record("no");
|
|
85
|
+
return deny(pre, `The person didn't approve this call${answer.other ? `. They said: ${answer.other}` : ""}. Don't retry it without asking them first.`);
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
preToolUse,
|
|
89
|
+
userPromptSubmit: async () => {
|
|
90
|
+
batch = false;
|
|
91
|
+
return {};
|
|
92
|
+
},
|
|
93
|
+
approvalAnswered(approved) {
|
|
94
|
+
batch = approved;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -3,6 +3,8 @@ import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
|
|
3
3
|
export interface TranscriptLogger {
|
|
4
4
|
preToolUse: HookCallback;
|
|
5
5
|
postToolUse: HookCallback;
|
|
6
|
+
/** Another event the kit keeps in it (a gate's answer, #74): one line, with its time. */
|
|
7
|
+
note(event: string, entry: Record<string, unknown>): Promise<void>;
|
|
6
8
|
}
|
|
7
9
|
/**
|
|
8
10
|
* Logs every tool call (request + result) as one JSON line, redacting `secrets` (e.g. a
|
|
@@ -39,7 +39,7 @@ export function createTranscriptLogger(transcriptPath, secrets = []) {
|
|
|
39
39
|
});
|
|
40
40
|
return {};
|
|
41
41
|
};
|
|
42
|
-
return { preToolUse, postToolUse };
|
|
42
|
+
return { preToolUse, postToolUse, note: async (event, entry) => await write({ ts: new Date().toISOString(), event, ...entry }) };
|
|
43
43
|
}
|
|
44
44
|
/** Avoids bloating the transcript with large payloads (browser-tool screenshots/base64). */
|
|
45
45
|
export function summarizeToolResponse(response) {
|
package/dist/core/messages/de.js
CHANGED
|
@@ -35,7 +35,7 @@ export const de = {
|
|
|
35
35
|
extensionSwitched: (name, enabled) => `(${name} ${enabled ? "aktiviert" : "deaktiviert"}, in derselben Sitzung)`,
|
|
36
36
|
extensionToggled: (name, enabled) => `(${name} ${enabled ? "aktiviert" : "deaktiviert"}: die Sitzung wird mit derselben Unterhaltung neu geöffnet)`,
|
|
37
37
|
extensionNotInstalled: (name) => `(${name} ist nicht installiert: /extensions listet sie auf)`,
|
|
38
|
-
extensionsUsage: "(Verwendung: /extensions, enable|disable <Name>, config <Name> [Schlüssel=Wert…], search [Wörter], install <Name>, update [Name], remove <Name> [--delete-data], reload, trust, sign-in <Name> [Rückruf-URL], sign-out <Name>)",
|
|
38
|
+
extensionsUsage: "(Verwendung: /extensions, enable|disable <Name>, config <Name> [Schlüssel=Wert… | confirm=on|off|reset], search [Wörter], install <Name>, update [Name], remove <Name> [--delete-data], reload, trust, sign-in <Name> [Rückruf-URL], sign-out <Name>)",
|
|
39
39
|
extensionsCantReopen: "(dieser Chat kann seine Sitzung nicht neu öffnen: im Terminal aktivieren oder deaktivieren und neu starten)",
|
|
40
40
|
extensionOptions: (name) => `Optionen von ${name}:`,
|
|
41
41
|
extensionNoOptions: (name) => `(${name} hat keine Optionen)`,
|
|
@@ -61,6 +61,12 @@ export const de = {
|
|
|
61
61
|
extensionSignInUnavailable: (name) => `(bei ${name} kann man sich hier nicht anmelden: sie läuft nicht in dieser Sitzung, hat keinen entfernten Server, oder das SDK dieses Agenten kann es nicht)`,
|
|
62
62
|
extensionSignedOut: (name) => `(bei ${name} abgemeldet: sie bittet dich, dich erneut anzumelden)`,
|
|
63
63
|
remoteCallTitle: (destination) => `Das an ${destination} senden? Es verlässt diesen Computer`,
|
|
64
|
+
guidedTitle: (extension) => `${extension} das tun lassen?`,
|
|
65
|
+
confirmAnswers: { yes: "Ja", session: "Ja, für diese Sitzung", always: "Ja, immer", no: "Nein", never: "Nein, nie" },
|
|
66
|
+
extensionConfirmOn: (name) => `Im geführten Modus fragen die Werkzeuge von ${name}, die etwas ändern, zuerst (confirm=off führt sie ohne Nachfrage aus):`,
|
|
67
|
+
extensionConfirmOff: (name) => `Im geführten Modus laufen die Werkzeuge von ${name} ohne Nachfrage (confirm=on fragt wieder).`,
|
|
68
|
+
confirmKept: (tool, answer) => ` ${tool}: ${answer === "always" ? "immer ja" : "nie"}`,
|
|
69
|
+
confirmNoneKept: " (keine gespeicherten Antworten; confirm=reset vergisst sie)",
|
|
64
70
|
connectorsAllowed: (names) => `claude.ai-Connectoren: ${names}`,
|
|
65
71
|
connectorsMissing: (names) => `Nicht in deinem claude.ai-Konto: ${names} (verbinde ihn auf claude.ai und öffne die Sitzung neu)`,
|
|
66
72
|
extensionInstalled: (name) => `(${name} installiert: in einem neuen Gespräch da, oder sofort mit /extensions reload, ohne dieses zu verlieren)`,
|
|
@@ -84,6 +84,22 @@ export interface Messages {
|
|
|
84
84
|
extensionSignedOut(name: string): string;
|
|
85
85
|
/** Before a call to a remote MCP server, when the agent asks first (#70): where it goes. */
|
|
86
86
|
remoteCallTitle(destination: string): string;
|
|
87
|
+
/** Guided mode's question before an installed extension's tool that changes things (#74): its title. */
|
|
88
|
+
guidedTitle(extension: string): string;
|
|
89
|
+
/** Its answers (#76): this call, this session, always, no, never. */
|
|
90
|
+
confirmAnswers: {
|
|
91
|
+
yes: string;
|
|
92
|
+
session: string;
|
|
93
|
+
always: string;
|
|
94
|
+
no: string;
|
|
95
|
+
never: string;
|
|
96
|
+
};
|
|
97
|
+
/** In /extensions config: whether its tools ask in guided mode, before the answers kept. */
|
|
98
|
+
extensionConfirmOn(name: string): string;
|
|
99
|
+
extensionConfirmOff(name: string): string;
|
|
100
|
+
/** A kept answer about one of its tools. */
|
|
101
|
+
confirmKept(tool: string, answer: "always" | "never"): string;
|
|
102
|
+
confirmNoneKept: string;
|
|
87
103
|
/** In /extensions: the claude.ai connectors let in (#70). */
|
|
88
104
|
connectorsAllowed(names: string): string;
|
|
89
105
|
/** In /extensions: connectors the agent asked for that the account hasn't. */
|
package/dist/core/messages/en.js
CHANGED
|
@@ -34,7 +34,7 @@ export const en = {
|
|
|
34
34
|
extensionSwitched: (name, enabled) => `(${name} ${enabled ? "enabled" : "disabled"}, in this same session)`,
|
|
35
35
|
extensionToggled: (name, enabled) => `(${name} ${enabled ? "enabled" : "disabled"}: the session reopens, with the same conversation)`,
|
|
36
36
|
extensionNotInstalled: (name) => `(${name} isn't installed: /extensions lists them)`,
|
|
37
|
-
extensionsUsage: "(usage: /extensions, enable|disable <name>, config <name> [key=value…], search [words], install <name>, update [name], remove <name> [--delete-data], reload, trust, sign-in <name> [callback URL], sign-out <name>)",
|
|
37
|
+
extensionsUsage: "(usage: /extensions, enable|disable <name>, config <name> [key=value… | confirm=on|off|reset], search [words], install <name>, update [name], remove <name> [--delete-data], reload, trust, sign-in <name> [callback URL], sign-out <name>)",
|
|
38
38
|
extensionsCantReopen: "(this chat can't reopen its session: enable or disable it from the terminal, and start again)",
|
|
39
39
|
extensionOptions: (name) => `${name}'s options:`,
|
|
40
40
|
extensionNoOptions: (name) => `(${name} has no options)`,
|
|
@@ -61,6 +61,12 @@ export const en = {
|
|
|
61
61
|
extensionSignInUnavailable: (name) => `(${name} can't be signed in to here: it isn't running in this session, has no remote server, or this agent's SDK can't sign in)`,
|
|
62
62
|
extensionSignedOut: (name) => `(signed out of ${name}: it will ask you to sign in again)`,
|
|
63
63
|
remoteCallTitle: (destination) => `Send this to ${destination}? It leaves this computer`,
|
|
64
|
+
guidedTitle: (extension) => `Let ${extension} do this?`,
|
|
65
|
+
confirmAnswers: { yes: "Yes", session: "Yes, for this session", always: "Yes, always", no: "No", never: "No, never" },
|
|
66
|
+
extensionConfirmOn: (name) => `In guided mode, ${name}'s tools that change things ask first (confirm=off runs them without asking):`,
|
|
67
|
+
extensionConfirmOff: (name) => `In guided mode, ${name}'s tools run without asking (confirm=on asks again).`,
|
|
68
|
+
confirmKept: (tool, answer) => ` ${tool}: ${answer === "always" ? "always yes" : "never"}`,
|
|
69
|
+
confirmNoneKept: " (no kept answers; confirm=reset forgets them)",
|
|
64
70
|
connectorsAllowed: (names) => `claude.ai connectors: ${names}`,
|
|
65
71
|
connectorsMissing: (names) => `Not in your claude.ai account: ${names} (connect it on claude.ai, then reopen the session)`,
|
|
66
72
|
extensionUpdated: (name, from, to) => `(${name} updated, ${from} → ${to}: it's there in a new conversation, or now with /extensions reload)`,
|
package/dist/core/messages/es.js
CHANGED
|
@@ -35,7 +35,7 @@ export const es = {
|
|
|
35
35
|
extensionSwitched: (name, enabled) => `(${name} ${enabled ? "activada" : "desactivada"}, en esta misma sesión)`,
|
|
36
36
|
extensionToggled: (name, enabled) => `(${name} ${enabled ? "activada" : "desactivada"}: la sesión se reabre, con la misma conversación)`,
|
|
37
37
|
extensionNotInstalled: (name) => `(${name} no está instalada: /extensions las lista)`,
|
|
38
|
-
extensionsUsage: "(uso: /extensions, enable|disable <nombre>, config <nombre> [clave=valor…], search [palabras], install <nombre>, update [nombre], remove <nombre> [--delete-data], reload, trust, sign-in <nombre> [URL de vuelta], sign-out <nombre>)",
|
|
38
|
+
extensionsUsage: "(uso: /extensions, enable|disable <nombre>, config <nombre> [clave=valor… | confirm=on|off|reset], search [palabras], install <nombre>, update [nombre], remove <nombre> [--delete-data], reload, trust, sign-in <nombre> [URL de vuelta], sign-out <nombre>)",
|
|
39
39
|
extensionsCantReopen: "(este chat no puede reabrir su sesión: actívala o desactívala desde la terminal y vuelve a empezar)",
|
|
40
40
|
extensionOptions: (name) => `Opciones de ${name}:`,
|
|
41
41
|
extensionNoOptions: (name) => `(${name} no tiene opciones)`,
|
|
@@ -61,6 +61,12 @@ export const es = {
|
|
|
61
61
|
extensionSignInUnavailable: (name) => `(no se puede iniciar sesión en ${name} aquí: no se ejecuta en esta sesión, no tiene servidor remoto, o el SDK de este agente no sabe hacerlo)`,
|
|
62
62
|
extensionSignedOut: (name) => `(sesión cerrada en ${name}: te pedirá que vuelvas a iniciarla)`,
|
|
63
63
|
remoteCallTitle: (destination) => `¿Enviar esto a ${destination}? Sale de este ordenador`,
|
|
64
|
+
guidedTitle: (extension) => `¿Dejas que ${extension} haga esto?`,
|
|
65
|
+
confirmAnswers: { yes: "Sí", session: "Sí, en esta sesión", always: "Sí, siempre", no: "No", never: "No, nunca" },
|
|
66
|
+
extensionConfirmOn: (name) => `En modo guiado, las herramientas de ${name} que cambian cosas preguntan antes (confirm=off las ejecuta sin preguntar):`,
|
|
67
|
+
extensionConfirmOff: (name) => `En modo guiado, las herramientas de ${name} se ejecutan sin preguntar (confirm=on vuelve a preguntar).`,
|
|
68
|
+
confirmKept: (tool, answer) => ` ${tool}: ${answer === "always" ? "siempre sí" : "nunca"}`,
|
|
69
|
+
confirmNoneKept: " (ninguna respuesta guardada; confirm=reset las olvida)",
|
|
64
70
|
connectorsAllowed: (names) => `Conectores de claude.ai: ${names}`,
|
|
65
71
|
connectorsMissing: (names) => `No están en tu cuenta de claude.ai: ${names} (conéctalo en claude.ai y vuelve a abrir la sesión)`,
|
|
66
72
|
extensionInstalled: (name) => `(${name} instalada: está en una conversación nueva, o ya con /extensions reload, sin perder esta)`,
|
package/dist/core/messages/fr.js
CHANGED
|
@@ -35,7 +35,7 @@ export const fr = {
|
|
|
35
35
|
extensionSwitched: (name, enabled) => `(${name} ${enabled ? "activée" : "désactivée"}, dans cette même session)`,
|
|
36
36
|
extensionToggled: (name, enabled) => `(${name} ${enabled ? "activée" : "désactivée"} : la session se rouvre, avec la même conversation)`,
|
|
37
37
|
extensionNotInstalled: (name) => `(${name} n'est pas installée : /extensions les liste)`,
|
|
38
|
-
extensionsUsage: "(usage : /extensions, enable|disable <nom>, config <nom> [clé=valeur…], search [mots], install <nom>, update [nom], remove <nom> [--delete-data], reload, trust, sign-in <nom> [URL de retour], sign-out <nom>)",
|
|
38
|
+
extensionsUsage: "(usage : /extensions, enable|disable <nom>, config <nom> [clé=valeur… | confirm=on|off|reset], search [mots], install <nom>, update [nom], remove <nom> [--delete-data], reload, trust, sign-in <nom> [URL de retour], sign-out <nom>)",
|
|
39
39
|
extensionsCantReopen: "(ce chat ne peut pas rouvrir sa session : activez-la ou désactivez-la depuis le terminal, puis recommencez)",
|
|
40
40
|
extensionOptions: (name) => `Options de ${name} :`,
|
|
41
41
|
extensionNoOptions: (name) => `(${name} n'a pas d'options)`,
|
|
@@ -61,6 +61,12 @@ export const fr = {
|
|
|
61
61
|
extensionSignInUnavailable: (name) => `(impossible de se connecter à ${name} ici : elle ne tourne pas dans cette session, n'a pas de serveur distant, ou le SDK de cet agent ne sait pas le faire)`,
|
|
62
62
|
extensionSignedOut: (name) => `(déconnecté de ${name} : elle vous demandera de vous reconnecter)`,
|
|
63
63
|
remoteCallTitle: (destination) => `Envoyer ceci à ${destination} ? Cela quitte cet ordinateur`,
|
|
64
|
+
guidedTitle: (extension) => `Laisser ${extension} faire ceci ?`,
|
|
65
|
+
confirmAnswers: { yes: "Oui", session: "Oui, pour cette session", always: "Oui, toujours", no: "Non", never: "Non, jamais" },
|
|
66
|
+
extensionConfirmOn: (name) => `En mode guidé, les outils de ${name} qui modifient des choses demandent d'abord (confirm=off les exécute sans demander) :`,
|
|
67
|
+
extensionConfirmOff: (name) => `En mode guidé, les outils de ${name} s'exécutent sans demander (confirm=on demande à nouveau).`,
|
|
68
|
+
confirmKept: (tool, answer) => ` ${tool} : ${answer === "always" ? "toujours oui" : "jamais"}`,
|
|
69
|
+
confirmNoneKept: " (aucune réponse gardée ; confirm=reset les oublie)",
|
|
64
70
|
connectorsAllowed: (names) => `Connecteurs claude.ai : ${names}`,
|
|
65
71
|
connectorsMissing: (names) => `Absents de votre compte claude.ai : ${names} (connectez-le sur claude.ai, puis rouvrez la session)`,
|
|
66
72
|
extensionInstalled: (name) => `(${name} installée : elle est là dans une nouvelle conversation, ou dès maintenant avec /extensions reload, en gardant celle-ci)`,
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import type { McpServerStatus } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
/** What an MCP tool's own server says about it: `readOnlyHint`, `destructiveHint`. */
|
|
3
|
+
export type ToolHint = "readOnly" | "destructive";
|
|
2
4
|
/**
|
|
3
|
-
*
|
|
4
|
-
* whose manifest lists no `readOnlyTools
|
|
5
|
+
* What the MCP servers themselves say about their tools (#53, #76): for an installed extension
|
|
6
|
+
* whose manifest lists no `readOnlyTools` (plan mode, guided mode) or no `confirmTools` (guided
|
|
7
|
+
* mode), a tool its server marks `readOnlyHint` only reads, and one it marks `destructiveHint`
|
|
8
|
+
* always asks.
|
|
5
9
|
*
|
|
6
|
-
* Asked when
|
|
10
|
+
* Asked when a gate meets the call, not once at start: a server is `pending` for seconds after
|
|
7
11
|
* the session starts, and one turned off and on again (liveExtensions.ts) may come back with
|
|
8
12
|
* other tools. The SDK's `mcpServerStatus()` gives each connected server's tools with
|
|
9
|
-
* `annotations.readOnly
|
|
10
|
-
* empirically
|
|
11
|
-
* Anything else — not connected, not asked in time, no hint — is no
|
|
13
|
+
* `annotations.readOnly` (and `destructive`), set exactly when the server says the hint is `true`
|
|
14
|
+
* (confirmed empirically for `readOnlyHint`: `false` and no hint both leave it out; the names are
|
|
15
|
+
* the short ones). Anything else — not connected, not asked in time, no hint — is no.
|
|
12
16
|
*/
|
|
17
|
+
export declare function createToolHints(servers: readonly string[], status: () => (() => Promise<McpServerStatus[]>) | undefined, hint: ToolHint): (toolName: string) => Promise<boolean>;
|
|
18
|
+
/** Plan mode's read-only tools from the servers themselves: `createToolHints()` for `readOnlyHint`. */
|
|
13
19
|
export declare function createReadOnlyHints(servers: readonly string[], status: () => (() => Promise<McpServerStatus[]>) | undefined): (toolName: string) => Promise<boolean>;
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* whose manifest lists no `readOnlyTools
|
|
2
|
+
* What the MCP servers themselves say about their tools (#53, #76): for an installed extension
|
|
3
|
+
* whose manifest lists no `readOnlyTools` (plan mode, guided mode) or no `confirmTools` (guided
|
|
4
|
+
* mode), a tool its server marks `readOnlyHint` only reads, and one it marks `destructiveHint`
|
|
5
|
+
* always asks.
|
|
4
6
|
*
|
|
5
|
-
* Asked when
|
|
7
|
+
* Asked when a gate meets the call, not once at start: a server is `pending` for seconds after
|
|
6
8
|
* the session starts, and one turned off and on again (liveExtensions.ts) may come back with
|
|
7
9
|
* other tools. The SDK's `mcpServerStatus()` gives each connected server's tools with
|
|
8
|
-
* `annotations.readOnly
|
|
9
|
-
* empirically
|
|
10
|
-
* Anything else — not connected, not asked in time, no hint — is no
|
|
10
|
+
* `annotations.readOnly` (and `destructive`), set exactly when the server says the hint is `true`
|
|
11
|
+
* (confirmed empirically for `readOnlyHint`: `false` and no hint both leave it out; the names are
|
|
12
|
+
* the short ones). Anything else — not connected, not asked in time, no hint — is no.
|
|
11
13
|
*/
|
|
12
|
-
export function
|
|
14
|
+
export function createToolHints(servers, status, hint) {
|
|
13
15
|
return async (toolName) => {
|
|
14
16
|
const server = servers.find((name) => toolName.startsWith(`mcp__${name}__`));
|
|
15
17
|
const read = status();
|
|
@@ -18,9 +20,13 @@ export function createReadOnlyHints(servers, status) {
|
|
|
18
20
|
const tool = toolName.slice(`mcp__${server}__`.length);
|
|
19
21
|
const statuses = await within(read(), []);
|
|
20
22
|
const found = statuses.find((entry) => entry.name === server && entry.status === "connected");
|
|
21
|
-
return found?.tools?.some((entry) => entry.name === tool && entry.annotations?.
|
|
23
|
+
return found?.tools?.some((entry) => entry.name === tool && entry.annotations?.[hint] === true) ?? false;
|
|
22
24
|
};
|
|
23
25
|
}
|
|
26
|
+
/** Plan mode's read-only tools from the servers themselves: `createToolHints()` for `readOnlyHint`. */
|
|
27
|
+
export function createReadOnlyHints(servers, status) {
|
|
28
|
+
return createToolHints(servers, status, "readOnly");
|
|
29
|
+
}
|
|
24
30
|
// A call to the session made from a hook, before a tool runs: never let it hold the tool up.
|
|
25
31
|
const TIMEOUT_MS = 3000;
|
|
26
32
|
const within = (promise, fallback) => Promise.race([promise.catch(() => fallback), new Promise((resolve) => setTimeout(() => resolve(fallback), TIMEOUT_MS).unref())]);
|
package/dist/core/session.js
CHANGED
|
@@ -9,6 +9,7 @@ import { createSubagentForegroundGate } from "./hooks/subagentForegroundGate.js"
|
|
|
9
9
|
import { createFileScopeGate } from "./hooks/fileScopeGate.js";
|
|
10
10
|
import { createPlanGate } from "./hooks/planGate.js";
|
|
11
11
|
import { createRemoteGate } from "./hooks/remoteGate.js";
|
|
12
|
+
import { createGuidedGate } from "./hooks/guidedGate.js";
|
|
12
13
|
import { chooseConnectors, discoverConnectors, serverToolPrefix } from "./claudeAiConnectors.js";
|
|
13
14
|
import { createHumanApprovalServer } from "./tools/humanApproval.js";
|
|
14
15
|
import { createManualLoginServer } from "./tools/manualLogin.js";
|
|
@@ -19,7 +20,7 @@ import { aboutExtension, describeCapability, extensionsPromptSection, resolveExt
|
|
|
19
20
|
import { allowAnyMcpTool } from "./mcpPermissions.js";
|
|
20
21
|
import { pluginAgents } from "./pluginAgents.js";
|
|
21
22
|
import { outsideArchive, unpackedClaudeExecutable } from "./packaged.js";
|
|
22
|
-
import { listInstalled, loadExternalExtensions, scopePrivateDir } from "./externalExtensions.js";
|
|
23
|
+
import { listInstalled, loadExternalExtensions, readLock, scopePrivateDir } from "./externalExtensions.js";
|
|
23
24
|
import { replyLanguageInstruction } from "./language.js";
|
|
24
25
|
import { chooseLanguage } from "./messages/index.js";
|
|
25
26
|
import { createRunStore } from "./runs.js";
|
|
@@ -27,7 +28,7 @@ import { createSessionView, registerSessionView } from "./sessionFacts.js";
|
|
|
27
28
|
import { createExtensionAuth, createExtensionRestart, createExtensionSwitch, mountPlugin } from "./liveExtensions.js";
|
|
28
29
|
import { secretsDir } from "./extensionOptions.js";
|
|
29
30
|
import { createElicitationHandler } from "./elicitation.js";
|
|
30
|
-
import { createReadOnlyHints } from "./readOnlyHints.js";
|
|
31
|
+
import { createReadOnlyHints, createToolHints } from "./readOnlyHints.js";
|
|
31
32
|
import { agentKitVersion } from "./version.js";
|
|
32
33
|
/**
|
|
33
34
|
* Builds the `options` object passed to the Agent SDK's `query()` — everything about
|
|
@@ -181,6 +182,28 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
|
|
|
181
182
|
const readOnlyTools = fromExtensions((contribution) => contribution.readOnlyTools);
|
|
182
183
|
// Those whose manifest lists none: their servers say, asked while the session runs (#53).
|
|
183
184
|
const readOnlyHintServers = fromExtensions((contribution) => contribution.readOnlyHintServers);
|
|
185
|
+
const readOnlyHinted = readOnlyHintServers.length ? createReadOnlyHints(readOnlyHintServers, () => late.view?.controls().mcpServerStatus) : undefined;
|
|
186
|
+
// Guided mode's gate for the installed extensions' tools that change things (#74, #76): whose
|
|
187
|
+
// they are, which always ask (the manifest's list and the agent's, else the servers' hints),
|
|
188
|
+
// and those the person said never to, kept in the lock: out of the session altogether.
|
|
189
|
+
const gateSpec = spec.extensionGate ?? {};
|
|
190
|
+
const guardedExtensions = gateSpec.enabled === false
|
|
191
|
+
? []
|
|
192
|
+
: resolved.active.flatMap(({ extension }, index) => {
|
|
193
|
+
const installed = installedDirs.get(extension.name);
|
|
194
|
+
if (!installed)
|
|
195
|
+
return [];
|
|
196
|
+
const servers = Object.keys(added[index]?.mcpServers ?? {});
|
|
197
|
+
const own = (gateSpec.confirmTools?.[extension.name] ?? []).flatMap((tool) => servers.map((server) => `mcp__${server}__${tool}`));
|
|
198
|
+
return [{ name: extension.name, scopeDir: installed.scopeDir, servers, confirmTools: [...(added[index]?.confirmTools ?? []), ...own] }];
|
|
199
|
+
});
|
|
200
|
+
const neverTools = (await Promise.all(guardedExtensions.map(async (extension) => {
|
|
201
|
+
const confirm = (await readLock(extension.scopeDir).catch(() => ({})))[extension.name]?.confirm;
|
|
202
|
+
if (confirm?.off)
|
|
203
|
+
return [];
|
|
204
|
+
const never = Object.entries(confirm?.tools ?? {}).filter(([, answer]) => answer === "never");
|
|
205
|
+
return never.flatMap(([tool]) => extension.servers.map((server) => `mcp__${server}__${tool}`));
|
|
206
|
+
}))).flat();
|
|
184
207
|
// What no file tool may touch: the agent's denied paths and the SDK's own credentials.
|
|
185
208
|
// And the installed extensions' sensitive option values (#55), in each scope's .secrets/.
|
|
186
209
|
const deniedPaths = [
|
|
@@ -193,6 +216,19 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
|
|
|
193
216
|
];
|
|
194
217
|
const transcriptPath = path.join(runDir, "transcript.jsonl");
|
|
195
218
|
const transcriptLogger = createTranscriptLogger(transcriptPath, config.secrets ?? []);
|
|
219
|
+
const guidedGate = guardedExtensions.length
|
|
220
|
+
? createGuidedGate({
|
|
221
|
+
runDir,
|
|
222
|
+
mode: () => modeControl.mode,
|
|
223
|
+
extensions: guardedExtensions,
|
|
224
|
+
isReadOnly: async (tool) => readOnlyTools.includes(tool) || ((await readOnlyHinted?.(tool)) ?? false),
|
|
225
|
+
isDestructive: createToolHints(fromExtensions((contribution) => contribution.confirmHintServers), () => late.view?.controls().mcpServerStatus, "destructive"),
|
|
226
|
+
...(gateSpec.autonomous ? { autonomous: gateSpec.autonomous } : {}),
|
|
227
|
+
// The session's labels, built below: read when a call is asked about.
|
|
228
|
+
labels: () => toolLabels,
|
|
229
|
+
record: (decision) => transcriptLogger.note("guided_decision", { ...decision }),
|
|
230
|
+
})
|
|
231
|
+
: undefined;
|
|
196
232
|
const promptSections = [
|
|
197
233
|
spec.buildSystemPrompt(config),
|
|
198
234
|
// What's on and what isn't, then the extensions' own sections.
|
|
@@ -255,7 +291,8 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
|
|
|
255
291
|
// mcpPermissions.ts for why a fixed allowedTools wildcard can't cover a server whose
|
|
256
292
|
// name isn't known ahead of time.
|
|
257
293
|
canUseTool: allowAnyMcpTool,
|
|
258
|
-
|
|
294
|
+
// And the extensions' tools the person said never to (#76).
|
|
295
|
+
disallowedTools: [...(spec.disallowedTools ?? []), ...neverTools],
|
|
259
296
|
// TodoWrite (todos.ts) only exists with CLAUDE_CODE_ENABLE_TASKS=0: by default the CLI
|
|
260
297
|
// offers its TaskCreate/TaskUpdate/TaskList/TaskGet tools instead, and drops TodoWrite from
|
|
261
298
|
// the session even when `tools` names it (confirmed empirically). `env` replaces the
|
|
@@ -288,7 +325,7 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
|
|
|
288
325
|
...(subagentDefinitions ? { agents: subagentDefinitions } : {}),
|
|
289
326
|
mcpServers: {
|
|
290
327
|
...spec.buildMcpServers(config, runDir),
|
|
291
|
-
...(includeApprovalTool ? { approvals: createHumanApprovalServer(runDir, spec.humanApprovalTexts, { modeControl }) } : {}),
|
|
328
|
+
...(includeApprovalTool ? { approvals: createHumanApprovalServer(runDir, spec.humanApprovalTexts, { modeControl, ...(guidedGate ? { onApproval: guidedGate.approvalAnswered } : {}) }) } : {}),
|
|
292
329
|
...(manualInterventionTexts ? { manualLogin: createManualLoginServer(runDir, manualInterventionTexts) } : {}),
|
|
293
330
|
// The extensions' tools (the knowledge base's, the sources').
|
|
294
331
|
...Object.assign({}, ...added.map((contribution) => contribution.mcpServers ?? {})),
|
|
@@ -313,6 +350,8 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
|
|
|
313
350
|
}),
|
|
314
351
|
],
|
|
315
352
|
},
|
|
353
|
+
// Guided mode asks before an installed extension's tool that changes things (#74, #76).
|
|
354
|
+
...(guidedGate ? [{ hooks: [guidedGate.preToolUse] }] : []),
|
|
316
355
|
...(fileTools.length || toolOnlyDirs.length
|
|
317
356
|
? [{ hooks: [createFileScopeGate({
|
|
318
357
|
projectDir: config.projectDir,
|
|
@@ -344,7 +383,7 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
|
|
|
344
383
|
createPlanGate({
|
|
345
384
|
projectDir: config.projectDir,
|
|
346
385
|
readOnlyTools,
|
|
347
|
-
...(
|
|
386
|
+
...(readOnlyHinted ? { isReadOnlyHinted: readOnlyHinted } : {}),
|
|
348
387
|
...(spec.planMode?.isPlanFile ? { isPlanFile: (filePath) => spec.planMode.isPlanFile(filePath, config) } : {}),
|
|
349
388
|
...(spec.planMode?.isReadOnlyTool ? { isReadOnlyTool: spec.planMode.isReadOnlyTool } : {}),
|
|
350
389
|
}, () => modeControl.mode === "plan"),
|
|
@@ -361,6 +400,8 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
|
|
|
361
400
|
: []),
|
|
362
401
|
],
|
|
363
402
|
PostToolUse: [{ hooks: [transcriptLogger.postToolUse] }],
|
|
403
|
+
// The person's message ends a batch they approved (guided mode, #74).
|
|
404
|
+
...(guidedGate ? { UserPromptSubmit: [{ hooks: [guidedGate.userPromptSubmit] }] } : {}),
|
|
364
405
|
},
|
|
365
406
|
};
|
|
366
407
|
// Where each remote server's calls go (#70), for the remote gate: an http/sse server's host
|
|
@@ -10,10 +10,12 @@ export declare const DEFAULT_HUMAN_APPROVAL_TEXTS: HumanApprovalTexts;
|
|
|
10
10
|
/**
|
|
11
11
|
* The tools that ask the person, in every mode but autonomous: `request_human_approval`
|
|
12
12
|
* (right before an action that publishes something visible to others and that's hard to
|
|
13
|
-
* undo
|
|
13
|
+
* undo; approved, guided mode lets the calls that follow through until the person's next
|
|
14
|
+
* message, see hooks/guidedGate.ts), `ask_human` (a choice between options, in a panel, within the turn) and, when the
|
|
14
15
|
* session can be in plan mode (`modeControl`), `present_plan`, the kit's own way out of plan
|
|
15
16
|
* mode (ADR-023): the person runs the plan, keeps planning or cancels.
|
|
16
17
|
*/
|
|
17
18
|
export declare function createHumanApprovalServer(runDir: string, texts?: HumanApprovalTexts, options?: {
|
|
18
|
-
modeControl?: ModeControl;
|
|
19
|
+
modeControl?: ModeControl; /** Each approval's answer: guided mode's batch (#74). */
|
|
20
|
+
onApproval?: (approved: boolean) => void;
|
|
19
21
|
}): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
|
|
@@ -16,7 +16,8 @@ const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
|
16
16
|
/**
|
|
17
17
|
* The tools that ask the person, in every mode but autonomous: `request_human_approval`
|
|
18
18
|
* (right before an action that publishes something visible to others and that's hard to
|
|
19
|
-
* undo
|
|
19
|
+
* undo; approved, guided mode lets the calls that follow through until the person's next
|
|
20
|
+
* message, see hooks/guidedGate.ts), `ask_human` (a choice between options, in a panel, within the turn) and, when the
|
|
20
21
|
* session can be in plan mode (`modeControl`), `present_plan`, the kit's own way out of plan
|
|
21
22
|
* mode (ADR-023): the person runs the plan, keeps planning or cancels.
|
|
22
23
|
*/
|
|
@@ -29,6 +30,7 @@ export function createHumanApprovalServer(runDir, texts = DEFAULT_HUMAN_APPROVAL
|
|
|
29
30
|
lines: [t().summaryLine(args.summary)],
|
|
30
31
|
});
|
|
31
32
|
const approved = APPROVED_ANSWERS.has(answer);
|
|
33
|
+
options.onApproval?.(approved);
|
|
32
34
|
return {
|
|
33
35
|
content: [{ type: "text", text: approved ? texts.approved : texts.rejected }],
|
|
34
36
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { createPromptLoader } from "./core/promptTemplate.js";
|
|
|
6
6
|
export { resolveClaudeAuth, type ClaudeAuthConfig } from "./core/claudeAuth.js";
|
|
7
7
|
export { ensureClaudeAuth } from "./tui/claudeAuth.js";
|
|
8
8
|
export { createFriendlyToolLabel, truncate, truncatePath, withToolLabels, withToolPhrases, type ToolDescriber, type ToolLabel, type ToolLabels, } from "./core/toolLabels.js";
|
|
9
|
-
export type { Mode, BaseSessionConfig, AgentSpec, AgentIdentity, PlanModeSpec } from "./core/agentSpec.js";
|
|
9
|
+
export type { Mode, BaseSessionConfig, AgentSpec, AgentIdentity, PlanModeSpec, ExtensionGateSpec } from "./core/agentSpec.js";
|
|
10
10
|
export { buildSessionOptions, createInputQueue, createDeferred, createModeControl, togglePlanMode, type ModeControl, type ExtensionsStatus } from "./core/session.js";
|
|
11
11
|
export { runQuery, type AgentEvent, type AgentRun, type SessionUsage, type ContextUsage } from "./core/runner.js";
|
|
12
12
|
export type { McpAuthentication, SessionFacts } from "./core/sessionFacts.js";
|
|
@@ -17,7 +17,7 @@ export { knowledge, type KnowledgeOptions } from "./extensions/knowledge/index.j
|
|
|
17
17
|
export { memory, type MemoryOptions } from "./extensions/memory/index.js";
|
|
18
18
|
export { describeCapability, readExtensionManifest, type AboutExtension, type CapabilityText, type DescribedCapability, type Extension, type ExtensionCapability, type ExtensionContext, type ExtensionContribution, type ExtensionManifest, type FolderOption, type UnavailableCapability, } from "./core/extensions.js";
|
|
19
19
|
export { checkRequirements, type ExtensionRequirement, type RequirementCheck } from "./core/extensionRequirements.js";
|
|
20
|
-
export { addExtension, codeStoreDir, installTarget, scopePrivateDir, extensionDataDir, extensionOptionStates, setExtensionOptions, removeExtension, setExtensionEnabled, listInstalled, readExternalManifest, type ExtensionAuthor, type ExtensionDirs, type ExtensionScope, type ExternalManifest, type ExternalToolLabel, type PluginServer, type LocalServer, type RemoteServer, isRemoteServer, remoteHosts, remoteServerName, type InstalledExtension, type LockEntry, type ExtensionOptionState, } from "./core/externalExtensions.js";
|
|
20
|
+
export { addExtension, codeStoreDir, installTarget, scopePrivateDir, extensionDataDir, extensionOptionStates, setExtensionOptions, removeExtension, setExtensionEnabled, setExtensionConfirm, listInstalled, readExternalManifest, type ExtensionAuthor, type ExtensionDirs, type ExtensionScope, type ExternalManifest, type ExternalToolLabel, type PluginServer, type LocalServer, type RemoteServer, isRemoteServer, remoteHosts, remoteServerName, type InstalledExtension, type LockEntry, type ExtensionOptionState, type ExtensionConfirm, type ToolConfirmation, } from "./core/externalExtensions.js";
|
|
21
21
|
export type { ExtensionOption, OptionValue } from "./core/extensionOptions.js";
|
|
22
22
|
export type { ExtensionAuth, ExtensionRestart, ExtensionSwitch, SignIn } from "./core/liveExtensions.js";
|
|
23
23
|
export { packageCacheDir, type CachedPackage } from "./core/npxPackages.js";
|
|
@@ -41,6 +41,7 @@ export { createSubagentTypeGate } from "./core/hooks/subagentTypeGate.js";
|
|
|
41
41
|
export { createSubagentForegroundGate } from "./core/hooks/subagentForegroundGate.js";
|
|
42
42
|
export { createFileScopeGate, checkFileScope, type FileScope } from "./core/hooks/fileScopeGate.js";
|
|
43
43
|
export { createPlanGate, checkPlanScope, type PlanScope } from "./core/hooks/planGate.js";
|
|
44
|
+
export { createGuidedGate, type GuardedExtension, type GuidedDecision, type GuidedGate } from "./core/hooks/guidedGate.js";
|
|
44
45
|
export { askForDecision, askForManualIntervention, askForChoice, askForText, type ChoiceAnswer } from "./core/hooks/humanInput.js";
|
|
45
46
|
export { setInteractionPort, getInteractionPort, type InteractionPort, type ApprovalPrompt, type ChoiceSettings } from "./core/interaction.js";
|
|
46
47
|
export { formFields, type FormAnswer, type FormField, type FormRequest } from "./core/elicitation.js";
|
package/dist/index.js
CHANGED
|
@@ -17,7 +17,7 @@ export { knowledge } from "./extensions/knowledge/index.js";
|
|
|
17
17
|
export { memory } from "./extensions/memory/index.js";
|
|
18
18
|
export { describeCapability, readExtensionManifest, } from "./core/extensions.js";
|
|
19
19
|
export { checkRequirements } from "./core/extensionRequirements.js";
|
|
20
|
-
export { addExtension, codeStoreDir, installTarget, scopePrivateDir, extensionDataDir, extensionOptionStates, setExtensionOptions, removeExtension, setExtensionEnabled, listInstalled, readExternalManifest, isRemoteServer, remoteHosts, remoteServerName, } from "./core/externalExtensions.js";
|
|
20
|
+
export { addExtension, codeStoreDir, installTarget, scopePrivateDir, extensionDataDir, extensionOptionStates, setExtensionOptions, removeExtension, setExtensionEnabled, setExtensionConfirm, listInstalled, readExternalManifest, isRemoteServer, remoteHosts, remoteServerName, } from "./core/externalExtensions.js";
|
|
21
21
|
export { packageCacheDir } from "./core/npxPackages.js";
|
|
22
22
|
export { addMarketplace, findPlugin, inspectMarketplace, installFromMarketplace, listMarketplaces, readMarketplaceManifest, removeMarketplace, resolvePluginSource, updateMarketplace, } from "./core/marketplaces.js";
|
|
23
23
|
export { extensionCatalog, installExtension, restoreExtensions, updateExtension } from "./core/extensionCatalog.js";
|
|
@@ -40,6 +40,7 @@ export { createSubagentTypeGate } from "./core/hooks/subagentTypeGate.js";
|
|
|
40
40
|
export { createSubagentForegroundGate } from "./core/hooks/subagentForegroundGate.js";
|
|
41
41
|
export { createFileScopeGate, checkFileScope } from "./core/hooks/fileScopeGate.js";
|
|
42
42
|
export { createPlanGate, checkPlanScope } from "./core/hooks/planGate.js";
|
|
43
|
+
export { createGuidedGate } from "./core/hooks/guidedGate.js";
|
|
43
44
|
export { askForDecision, askForManualIntervention, askForChoice, askForText } from "./core/hooks/humanInput.js";
|
|
44
45
|
export { setInteractionPort, getInteractionPort } from "./core/interaction.js";
|
|
45
46
|
export { formFields } from "./core/elicitation.js";
|
|
@@ -12,7 +12,7 @@ export { chatExtensionsCommand, describe } from "../chat/extensions.js";
|
|
|
12
12
|
* - `extension add <folder> --link [--project]`: registered where it is, to develop it (not copied, its files not checked)
|
|
13
13
|
* - `extension remove <name> [--project | --agent] [--yes | --keep-data]`: asks before deleting its data folder
|
|
14
14
|
* - `extension enable|disable <name> [--project | --agent]`
|
|
15
|
-
* - `extension config <name> [key=value… | key… | --unset key…] [--project | --agent]`: its options; `key` alone asks for it (without echo when it's a secret)
|
|
15
|
+
* - `extension config <name> [key=value… | key… | --unset key… | confirm=on|off|reset] [--project | --agent]`: its options; `key` alone asks for it (without echo when it's a secret); `confirm=` what guided mode asks about its tools (#74, #76)
|
|
16
16
|
* - `extension update [name] [--yes]`: one installed from a marketplace (or all that have a newer version there) to the version it offers now, its data and options kept
|
|
17
17
|
* - `extension restore [--trust]`: gets the code a workspace's lock lists and this computer lacks (#57); `--trust` trusts it too
|
|
18
18
|
* - `extension trust [name…]`: lets a workspace's extensions run, as their code is now
|
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { addMarketplace, findPlugin, inspectMarketplace, installFromMarketplace, removeMarketplace, updateMarketplace, } from "../core/marketplaces.js";
|
|
5
5
|
import { addExtension, extensionOptionStates, installTarget, folderSize, isGitSource, isRemoteServer, remoteHosts, listInstalled, removeExtension, setExtensionEnabled, setExtensionOptions, } from "../core/externalExtensions.js";
|
|
6
|
-
import { describe, describeOptions } from "../chat/extensions.js";
|
|
6
|
+
import { applyConfirm, describe, describeConfirm, describeOptions } from "../chat/extensions.js";
|
|
7
7
|
import { extensionCatalog, knownMarketplaces as catalogMarketplaces, restoreExtensions, updateExtension } from "../core/extensionCatalog.js";
|
|
8
8
|
import { trustWorkspace } from "../core/workspaceTrust.js";
|
|
9
9
|
import { describeCapability, describeInLanguage } from "../core/extensions.js";
|
|
@@ -21,7 +21,7 @@ export { chatExtensionsCommand, describe } from "../chat/extensions.js";
|
|
|
21
21
|
* - `extension add <folder> --link [--project]`: registered where it is, to develop it (not copied, its files not checked)
|
|
22
22
|
* - `extension remove <name> [--project | --agent] [--yes | --keep-data]`: asks before deleting its data folder
|
|
23
23
|
* - `extension enable|disable <name> [--project | --agent]`
|
|
24
|
-
* - `extension config <name> [key=value… | key… | --unset key…] [--project | --agent]`: its options; `key` alone asks for it (without echo when it's a secret)
|
|
24
|
+
* - `extension config <name> [key=value… | key… | --unset key… | confirm=on|off|reset] [--project | --agent]`: its options; `key` alone asks for it (without echo when it's a secret); `confirm=` what guided mode asks about its tools (#74, #76)
|
|
25
25
|
* - `extension update [name] [--yes]`: one installed from a marketplace (or all that have a newer version there) to the version it offers now, its data and options kept
|
|
26
26
|
* - `extension restore [--trust]`: gets the code a workspace's lock lists and this computer lacks (#57); `--trust` trusts it too
|
|
27
27
|
* - `extension trust [name…]`: lets a workspace's extensions run, as their code is now
|
|
@@ -200,16 +200,22 @@ export async function runExtensionCommand(argv, options) {
|
|
|
200
200
|
case "config": {
|
|
201
201
|
const name = positional[0];
|
|
202
202
|
if (!name)
|
|
203
|
-
return fail(`Usage: ${command} extension config <name> [key=value… | key… | --unset key…] [--project | --agent]`);
|
|
203
|
+
return fail(`Usage: ${command} extension config <name> [key=value… | key… | --unset key… | confirm=on|off|reset] [--project | --agent]`);
|
|
204
204
|
const found = (await listInstalled(options.dirs)).filter((extension) => extension.name === name && !extension.shadowed && (!askedScope || extension.scope === askedScope));
|
|
205
205
|
if (!found.length)
|
|
206
206
|
return fail(`${name} isn't installed${askedScope ? ` in the ${askedScope} scope` : ""}. \`${command} extension list\` lists them.`);
|
|
207
207
|
const installed = found[0];
|
|
208
208
|
const declared = installed.manifest?.userConfig ?? {};
|
|
209
|
+
// Guided mode's answers about its tools (#74, #76): confirm=on|off|reset.
|
|
210
|
+
const confirm = await applyConfirm(installed, positional.slice(1));
|
|
211
|
+
if ("error" in confirm)
|
|
212
|
+
return fail(`${confirm.error}.`);
|
|
213
|
+
if (confirm.changed)
|
|
214
|
+
write(`${name}'s guided-mode answers saved in the ${installed.scope} scope.`);
|
|
209
215
|
const typed = {};
|
|
210
216
|
for (const key of values("unset"))
|
|
211
217
|
typed[key] = undefined;
|
|
212
|
-
for (const pair of
|
|
218
|
+
for (const pair of confirm.rest) {
|
|
213
219
|
const at = pair.indexOf("=");
|
|
214
220
|
if (at > 0)
|
|
215
221
|
typed[pair.slice(0, at)] = pair.slice(at + 1);
|
|
@@ -224,10 +230,11 @@ export async function runExtensionCommand(argv, options) {
|
|
|
224
230
|
await setExtensionOptions(installed, name, typed);
|
|
225
231
|
write(`${name}'s options saved in the ${installed.scope} scope. They apply when its server starts again: the next session, or in a chat \`/extensions disable ${name}\` and \`enable\`.`);
|
|
226
232
|
}
|
|
227
|
-
const
|
|
233
|
+
const now = (await listInstalled(options.dirs)).find((extension) => extension.name === name && extension.scopeDir === installed.scopeDir);
|
|
234
|
+
const lines = await describeOptions(now);
|
|
228
235
|
if (!lines.length)
|
|
229
236
|
write(`${name} has no options.`);
|
|
230
|
-
for (const line of lines)
|
|
237
|
+
for (const line of [...lines, ...describeConfirm(now)])
|
|
231
238
|
write(line);
|
|
232
239
|
return true;
|
|
233
240
|
}
|
|
@@ -297,6 +304,7 @@ export async function runExtensionCommand(argv, options) {
|
|
|
297
304
|
" remove <name> [--project | --agent] [--yes | --keep-data]",
|
|
298
305
|
" enable|disable <name> [--project | --agent]",
|
|
299
306
|
" config <name> [key=value… | key… | --unset key…] its options (key alone asks for it)",
|
|
307
|
+
" config <name> confirm=on|off|reset whether guided mode asks before its tools, or forget the answers kept",
|
|
300
308
|
" update [name] to the version its marketplace offers now (all that have one, without a name)",
|
|
301
309
|
" restore [--trust] gets what this workspace's lock lists and this computer lacks",
|
|
302
310
|
" trust [name…] lets this workspace's extensions run, as they are now",
|
package/package.json
CHANGED
|
@@ -34,6 +34,11 @@
|
|
|
34
34
|
"type": "array",
|
|
35
35
|
"items": { "type": "string", "minLength": 1 }
|
|
36
36
|
},
|
|
37
|
+
"confirmTools": {
|
|
38
|
+
"description": "Its tools that always ask the person in guided mode (short names), even inside a batch they approved, and are never offered \"always\": what creates, edits or publishes in their account. Without it, its servers' destructiveHint says which.",
|
|
39
|
+
"type": "array",
|
|
40
|
+
"items": { "type": "string", "minLength": 1 }
|
|
41
|
+
},
|
|
37
42
|
"labels": {
|
|
38
43
|
"description": "How the chat shows each tool (short name), per language (en, es, fr, de): {field} takes the call's input.",
|
|
39
44
|
"type": "object",
|