@falkenslab/agent-kit 0.24.0 → 0.26.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.
Files changed (46) hide show
  1. package/README.md +2 -0
  2. package/dist/chat/chatController.d.ts +10 -0
  3. package/dist/chat/chatController.js +39 -1
  4. package/dist/chat/extensions.d.ts +9 -0
  5. package/dist/chat/extensions.js +18 -1
  6. package/dist/chat/runs.d.ts +3 -1
  7. package/dist/chat/runs.js +1 -1
  8. package/dist/core/agentSpec.d.ts +16 -0
  9. package/dist/core/claudeAiConnectors.d.ts +35 -0
  10. package/dist/core/claudeAiConnectors.js +76 -0
  11. package/dist/core/extensionCatalog.d.ts +6 -1
  12. package/dist/core/extensionCatalog.js +10 -8
  13. package/dist/core/extensionOptions.d.ts +5 -0
  14. package/dist/core/extensionOptions.js +10 -0
  15. package/dist/core/extensions.d.ts +6 -0
  16. package/dist/core/externalExtensions.d.ts +24 -1
  17. package/dist/core/externalExtensions.js +78 -12
  18. package/dist/core/hooks/remoteGate.d.ts +22 -0
  19. package/dist/core/hooks/remoteGate.js +35 -0
  20. package/dist/core/liveExtensions.d.ts +38 -0
  21. package/dist/core/liveExtensions.js +69 -0
  22. package/dist/core/marketplaces.d.ts +1 -0
  23. package/dist/core/messages/de.js +13 -1
  24. package/dist/core/messages/en.d.ts +20 -0
  25. package/dist/core/messages/en.js +13 -1
  26. package/dist/core/messages/es.js +13 -1
  27. package/dist/core/messages/fr.js +13 -1
  28. package/dist/core/npxPackages.d.ts +2 -2
  29. package/dist/core/runner.js +41 -0
  30. package/dist/core/session.d.ts +11 -2
  31. package/dist/core/session.js +47 -3
  32. package/dist/core/sessionFacts.d.ts +20 -0
  33. package/dist/core/sessionFacts.js +1 -0
  34. package/dist/extensions/awareness/catalog.d.ts +8 -0
  35. package/dist/extensions/awareness/catalog.js +92 -0
  36. package/dist/extensions/awareness/index.d.ts +3 -1
  37. package/dist/extensions/awareness/index.js +15 -6
  38. package/dist/extensions/awareness/labels.js +8 -5
  39. package/dist/extensions/awareness/tools.d.ts +2 -2
  40. package/dist/extensions/awareness/tools.js +10 -3
  41. package/dist/index.d.ts +3 -3
  42. package/dist/index.js +1 -1
  43. package/dist/tui/extensionCommand.js +11 -6
  44. package/extensions/awareness/skills/extensions/SKILL.md +56 -0
  45. package/package.json +1 -1
  46. package/schemas/marketplace-catalog.schema.json +6 -0
@@ -10,10 +10,37 @@ import { getLanguage } from "./messages/index.js";
10
10
  import { truncate } from "./toolLabels.js";
11
11
  import { agentKitVersion } from "./version.js";
12
12
  import { outsideArchive } from "./packaged.js";
13
- import { extensionSecretsPath, missingOptions, optionsNamed, parseOptionValue, parseUserConfig, readOptionValues, removeSecrets, storeOptionValues } from "./extensionOptions.js";
13
+ import { extensionSecretsPath, fillOptions, missingOptions, optionsNamed, parseOptionValue, parseUserConfig, readOptionValues, removeSecrets, storeOptionValues } from "./extensionOptions.js";
14
14
  import { isTrusted } from "./workspaceTrust.js";
15
15
  import { checkRequirements, describeMissing, findOnPath, parseRequirements, requirementsProblem } from "./extensionRequirements.js";
16
16
  import { cachedPackageDir, cachedPackageProblem, cachePackage, npxServer, packageCacheDir, packageSpec } from "./npxPackages.js";
17
+ /** Whether a server is remote (#61). */
18
+ export function isRemoteServer(server) {
19
+ return "url" in server;
20
+ }
21
+ /**
22
+ * The name a session gives an extension's remote server: its own, prefixed with the extension's.
23
+ * The SDK's CLI keeps OAuth tokens by server name (confirmed empirically, CLI 2.1.300): a server
24
+ * named like one in the runner's own Claude Code would sign in with the runner's token.
25
+ */
26
+ export function remoteServerName(extension, server) {
27
+ return `${extension}-${server}`;
28
+ }
29
+ /** The hosts an extension's remote servers talk to, for the person to read before installing it. */
30
+ export function remoteHosts(manifest) {
31
+ return [
32
+ ...new Set(Object.values(manifest.servers).flatMap((server) => {
33
+ if (!isRemoteServer(server))
34
+ return [];
35
+ try {
36
+ return [new URL(server.url.replace(/\$\{[^}]*\}/g, "x")).host];
37
+ }
38
+ catch {
39
+ return [server.url];
40
+ }
41
+ })),
42
+ ];
43
+ }
17
44
  export const LOCK_FILE = "extensions.lock.json";
18
45
  /**
19
46
  * An installed extension's data folder: `${CLAUDE_PLUGIN_DATA}` in its servers' arguments and
@@ -155,9 +182,20 @@ async function pluginServers(dir, raw) {
155
182
  throw new Error("its MCP servers aren't a JSON object.");
156
183
  const servers = {};
157
184
  for (const [name, server] of Object.entries(declared)) {
185
+ if (server && (server.type === "http" || server.type === "sse")) {
186
+ if (typeof server.url !== "string")
187
+ throw new Error(`its MCP server "${name}" has no url.`);
188
+ const headers = server.headers && typeof server.headers === "object" ? Object.fromEntries(Object.entries(server.headers).map(([key, value]) => [key, String(value)])) : undefined;
189
+ servers[name] = { type: server.type, url: server.url, ...(headers ? { headers } : {}) };
190
+ continue;
191
+ }
158
192
  if (!server || typeof server.command !== "string")
159
193
  throw new Error(`its MCP server "${name}" has no command.`);
160
- servers[name] = { command: server.command, ...(Array.isArray(server.args) ? { args: server.args.map(String) } : {}), ...(server.env ? { env: server.env } : {}) };
194
+ servers[name] = {
195
+ command: server.command,
196
+ ...(Array.isArray(server.args) ? { args: server.args.map(String) } : {}),
197
+ ...(server.env ? { env: server.env } : {}),
198
+ };
161
199
  }
162
200
  return servers;
163
201
  }
@@ -201,6 +239,12 @@ function serverLaunch(name, server, folders, packages = {}) {
201
239
  */
202
240
  async function serversProblem(manifest, root, packages) {
203
241
  for (const [name, server] of Object.entries(manifest.servers)) {
242
+ if (isRemoteServer(server)) {
243
+ // Never in clear over the network: plain HTTP only to this computer (a server under development).
244
+ if (!/^https:\/\//i.test(server.url) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/i.test(server.url))
245
+ return `talks to its server "${name}" at ${server.url}: a remote server must be https (http only on localhost)`;
246
+ continue;
247
+ }
204
248
  const npx = npxServer(server);
205
249
  if (typeof npx === "string")
206
250
  return `${npx} (its server "${name}")`;
@@ -222,6 +266,8 @@ async function serversProblem(manifest, root, packages) {
222
266
  async function fetchServerPackages(manifest, cache) {
223
267
  const packages = {};
224
268
  for (const server of Object.values(manifest.servers)) {
269
+ if (isRemoteServer(server))
270
+ continue;
225
271
  const npx = npxServer(server);
226
272
  if (!npx || typeof npx === "string")
227
273
  continue;
@@ -312,8 +358,8 @@ export function extensionLauncherPath() {
312
358
  // Node runs it as another process: outside the archive when packaged (#42).
313
359
  return outsideArchive(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "assets", "extension-launcher.mjs"));
314
360
  }
315
- /** Its tools' chat labels in the kit's language, from its manifest: `{field}` takes the input; for each of its servers. */
316
- function manifestToolLabels(manifest) {
361
+ /** Its tools' chat labels in the kit's language, from its manifest: `{field}` takes the input; for each of its servers, by their session names. */
362
+ function manifestToolLabels(manifest, sessionServers) {
317
363
  const language = getLanguage();
318
364
  const fill = (template, input) => template.replace(/\{(\w+)\}/g, (_, field) => (input[field] === undefined ? "" : truncate(String(input[field]), 60)));
319
365
  return Object.fromEntries(Object.entries(manifest.labels).flatMap(([tool, byLanguage]) => {
@@ -321,7 +367,7 @@ function manifestToolLabels(manifest) {
321
367
  if (!texts)
322
368
  return [];
323
369
  const label = { label: (input) => fill(texts.label, input), ...(texts.phrase ? { phrase: texts.phrase } : {}) };
324
- return Object.keys(manifest.servers).map((server) => [`mcp__${server}__${tool}`, label]);
370
+ return sessionServers.map((server) => [`mcp__${server}__${tool}`, label]);
325
371
  }));
326
372
  }
327
373
  /** The extension an installed one makes: its servers started through the launcher, its rules from its manifest. */
@@ -334,10 +380,27 @@ function externalExtension(installed, manifest, cache, unavailable = []) {
334
380
  ...(unavailable.length ? { unavailable } : {}),
335
381
  async contribute() {
336
382
  const servers = {};
383
+ const remote = [];
337
384
  if (Object.keys(manifest.servers).length)
338
385
  await mkdir(installed.dataDir, { recursive: true });
386
+ const folders = { root: installed.dir, data: installed.dataDir };
387
+ // A remote server's options go into its URL and headers here, when the session opens: the
388
+ // SDK fixes them then, so a changed value takes opening the session again (#61).
389
+ const values = Object.values(manifest.servers).some(isRemoteServer) ? await readOptionValues(installed.privateDir, installed.name, manifest.userConfig, installed.lock.options) : {};
339
390
  for (const [name, server] of Object.entries(manifest.servers)) {
340
- const launch = serverLaunch(name, server, { root: installed.dir, data: installed.dataDir }, { cache, locked: installed.lock.packages });
391
+ if (isRemoteServer(server)) {
392
+ const fill = (text) => fillOptions(expand(text, folders), values);
393
+ // A header naming an option without a value isn't sent: `Authorization: Bearer ` would
394
+ // turn the server's OAuth off ("OAuth fallback is disabled when headers.Authorization is
395
+ // set", confirmed empirically, CLI 2.1.300), so a token stays optional beside signing in.
396
+ const unset = (text) => optionsNamed([text]).some((key) => values[key] === undefined || values[key] === "");
397
+ const headers = Object.entries(server.headers ?? {}).filter(([, value]) => !unset(value));
398
+ const sessionName = remoteServerName(manifest.name, name);
399
+ servers[sessionName] = { type: server.type, url: fill(server.url), ...(headers.length ? { headers: Object.fromEntries(headers.map(([key, value]) => [key, fill(value)])) } : {}) };
400
+ remote.push(sessionName);
401
+ continue;
402
+ }
403
+ const launch = serverLaunch(name, server, folders, { cache, locked: installed.lock.packages });
341
404
  if (typeof launch === "string")
342
405
  continue; // checked when it loaded
343
406
  // Its `env` reaches it (the SDK merges it with the agent's); the launcher removes the rest.
@@ -359,9 +422,10 @@ function externalExtension(installed, manifest, cache, unavailable = []) {
359
422
  // Its manifest's list, or its servers' own word: the server is the code the kit trusts
360
423
  // least, so its hints only fill the list's absence (ADR-025).
361
424
  ...(manifest.readOnlyTools
362
- ? { readOnlyTools: Object.keys(manifest.servers).flatMap((server) => manifest.readOnlyTools.map((tool) => `mcp__${server}__${tool}`)) }
425
+ ? { readOnlyTools: Object.keys(servers).flatMap((server) => manifest.readOnlyTools.map((tool) => `mcp__${server}__${tool}`)) }
363
426
  : { readOnlyTools: [], readOnlyHintServers: Object.keys(servers) }),
364
- toolLabels: manifestToolLabels(manifest),
427
+ toolLabels: manifestToolLabels(manifest, Object.keys(servers)),
428
+ ...(remote.length ? { remoteServers: remote } : {}),
365
429
  ...(manifest.help ? { helpLines: [manifest.help] } : {}),
366
430
  };
367
431
  },
@@ -373,7 +437,7 @@ function optionDefaults(declared) {
373
437
  }
374
438
  /** Why its options keep it from running (a server naming one it doesn't declare, a required one without a value), or nothing. */
375
439
  function optionsProblem(manifest, values) {
376
- const named = optionsNamed(Object.values(manifest.servers).flatMap((server) => [server.command, ...(server.args ?? []), ...Object.values(server.env ?? {}).map(String)]));
440
+ const named = optionsNamed(Object.values(manifest.servers).flatMap((server) => isRemoteServer(server) ? [server.url, ...Object.values(server.headers ?? {})] : [server.command, ...(server.args ?? []), ...Object.values(server.env ?? {}).map(String)]));
377
441
  const undeclared = named.filter((key) => !manifest.userConfig[key]);
378
442
  if (undeclared.length)
379
443
  return `names ${undeclared.map((key) => "${user_config." + key + "}").join(", ")}, which its userConfig doesn't declare`;
@@ -490,6 +554,8 @@ export async function addExtension(source, scopeDir, options = {}) {
490
554
  throw new Error(`${manifest.name} can't be installed: it ${servers}.`);
491
555
  // What it needs and this computer lacks: said, never installed, and it installs anyway (#63).
492
556
  const missing = (await checkRequirements(manifest.requirements)).filter((check) => !check.ok).map((check) => missingRequirement(check));
557
+ // Where what the agent sends goes, for the person to know (#61).
558
+ const remote = remoteHosts(manifest);
493
559
  const target = path.join(scopeDir, manifest.name);
494
560
  // Replacing what's installed there would delete the source itself.
495
561
  if (path.resolve(from) === path.resolve(target))
@@ -516,12 +582,12 @@ export async function addExtension(source, scopeDir, options = {}) {
516
582
  }
517
583
  lock[manifest.name] = { source: origin, ...provenance, sha256, stored: true, ...locked, enabled: true, installed: new Date().toISOString() };
518
584
  await writeLock(scopeDir, lock);
519
- return { name: manifest.name, replaced, missing, ...(commit ? { commit } : {}) };
585
+ return { name: manifest.name, replaced, missing, remote, ...(commit ? { commit } : {}) };
520
586
  }
521
587
  if (options.link) {
522
588
  lock[manifest.name] = { source: from, linked: true, sha256: "", ...locked, enabled: true, installed: new Date().toISOString() };
523
589
  await writeLock(scopeDir, lock);
524
- return { name: manifest.name, replaced, missing };
590
+ return { name: manifest.name, replaced, missing, remote };
525
591
  }
526
592
  await cp(from, target, { recursive: true, filter: (file) => path.basename(file) !== ".git" });
527
593
  lock[manifest.name] = {
@@ -533,7 +599,7 @@ export async function addExtension(source, scopeDir, options = {}) {
533
599
  installed: new Date().toISOString(),
534
600
  };
535
601
  await writeLock(scopeDir, lock);
536
- return { name: manifest.name, replaced, missing, ...(commit ? { commit } : {}) };
602
+ return { name: manifest.name, replaced, missing, remote, ...(commit ? { commit } : {}) };
537
603
  }
538
604
  finally {
539
605
  if (clone)
@@ -0,0 +1,22 @@
1
+ import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
2
+ /** What a call to a remote MCP server does (#70): goes on, asks the person first, or is blocked. */
3
+ export type RemoteServersPolicy = "allow" | "ask" | "deny";
4
+ /**
5
+ * PreToolUse hook for remote MCP servers (#70): what the model sends one leaves the computer.
6
+ * - A claude.ai connector's tool the agent didn't choose is always blocked: a connector the
7
+ * account gained after the session opened, which the session's denylist doesn't name.
8
+ * - A remote server's tool (an installed extension's, the agent's own, a chosen connector) goes
9
+ * on with `allow`; with `ask`, the person approves each call, told where it goes (blocked when
10
+ * no one can be asked); with `deny`, it's blocked.
11
+ * Approving gives no decision of its own, so the other gates (plan mode, the file scope) still judge.
12
+ */
13
+ export declare function createRemoteGate(options: {
14
+ runDir: string;
15
+ policy: RemoteServersPolicy;
16
+ /** Where a tool's call goes (a host, a connector), or nothing when it isn't remote. */
17
+ destination: (tool: string) => string | undefined;
18
+ /** The chosen connectors' tool prefixes (`serverToolPrefix()`). */
19
+ allowedConnectors: readonly string[];
20
+ /** Whether a person can be asked now (not autonomous). */
21
+ canAsk: () => boolean;
22
+ }): HookCallback;
@@ -0,0 +1,35 @@
1
+ import { askForDecision } from "./humanInput.js";
2
+ import { isConnectorTool } from "../claudeAiConnectors.js";
3
+ import { t } from "../messages/index.js";
4
+ /**
5
+ * PreToolUse hook for remote MCP servers (#70): what the model sends one leaves the computer.
6
+ * - A claude.ai connector's tool the agent didn't choose is always blocked: a connector the
7
+ * account gained after the session opened, which the session's denylist doesn't name.
8
+ * - A remote server's tool (an installed extension's, the agent's own, a chosen connector) goes
9
+ * on with `allow`; with `ask`, the person approves each call, told where it goes (blocked when
10
+ * no one can be asked); with `deny`, it's blocked.
11
+ * Approving gives no decision of its own, so the other gates (plan mode, the file scope) still judge.
12
+ */
13
+ export function createRemoteGate(options) {
14
+ const deny = (input, reason) => ({ hookSpecificOutput: { hookEventName: input.hook_event_name, permissionDecision: "deny", permissionDecisionReason: reason } });
15
+ return async (input) => {
16
+ const pre = input;
17
+ const tool = pre.tool_name;
18
+ if (isConnectorTool(tool) && !options.allowedConnectors.some((prefix) => tool.startsWith(prefix))) {
19
+ return deny(pre, "This agent doesn't use that claude.ai connector: it isn't one of the connectors it lets in. Don't call it again.");
20
+ }
21
+ const destination = options.destination(tool);
22
+ if (!destination || options.policy === "allow")
23
+ return {};
24
+ if (options.policy === "deny" || !options.canAsk()) {
25
+ return deny(pre, `This agent sends nothing to remote servers here: calling ${destination} isn't allowed. Do the task without it, or tell the person it needs that service.`);
26
+ }
27
+ const answer = await askForDecision(options.runDir, {
28
+ title: t().remoteCallTitle(destination),
29
+ lines: [t().toolLine(tool), t().parametersLine(JSON.stringify(pre.tool_input, null, 2))],
30
+ });
31
+ if (answer === "" || answer === "y" || answer === "yes")
32
+ return {};
33
+ return deny(pre, `The person didn't let this call go to ${destination}.`);
34
+ };
35
+ }
@@ -47,6 +47,8 @@ export interface LiveExtension {
47
47
  capabilities: ExtensionCapability[];
48
48
  /** What it can't do on this computer (#63). */
49
49
  unavailable: UnavailableCapability[];
50
+ /** Its remote servers, by session name (#61): their options apply when the session opens, and they may need signing in. */
51
+ remoteServers: string[];
50
52
  help: string[];
51
53
  }
52
54
  /** The session's own lists the switch keeps in step: the same objects the gates, the chat and the view read. */
@@ -78,3 +80,39 @@ export declare function createExtensionSwitch(extensions: readonly LiveExtension
78
80
  export type ExtensionRestart = (name: string) => Promise<boolean>;
79
81
  /** The restart for a session's installed extensions. */
80
82
  export declare function createExtensionRestart(extensions: readonly LiveExtension[], targets: LiveTargets): ExtensionRestart;
83
+ /** Where signing in to an extension's remote servers stands (#61). */
84
+ export type SignIn =
85
+ /** Its servers are connected: nothing to sign in to. */
86
+ {
87
+ status: "signed-in";
88
+ }
89
+ /** The links the person opens to sign in, one per server that needs it. */
90
+ | {
91
+ status: "open";
92
+ links: {
93
+ server: string;
94
+ url: string;
95
+ }[];
96
+ }
97
+ /** It isn't running in this session, has no remote server, or the SDK can't sign in. */
98
+ | {
99
+ status: "unavailable";
100
+ reason: "not-running" | "not-remote" | "unsupported";
101
+ };
102
+ /** Signing in to installed extensions' remote servers, and out (#61). */
103
+ export interface ExtensionAuth {
104
+ /** The running extensions whose remote servers need the person to sign in. */
105
+ needed(): Promise<string[]>;
106
+ /**
107
+ * Starts signing in to `name`'s servers that need it, giving the links to open: the SDK's CLI
108
+ * waits for the browser at its localhost callback. With `callbackUrl` (the address the browser
109
+ * ended on, pasted from another computer), finishes it instead, and reconnects.
110
+ */
111
+ signIn(name: string, callbackUrl?: string): Promise<SignIn>;
112
+ /** Whether all of `name`'s remote servers are connected, waiting up to `timeoutMs` for it. */
113
+ connected(name: string, timeoutMs?: number): Promise<boolean>;
114
+ /** Signs out of `name`'s remote servers; false when it can't be done here. */
115
+ signOut(name: string): Promise<boolean>;
116
+ }
117
+ /** Signing in and out for a session's installed extensions. */
118
+ export declare function createExtensionAuth(extensions: readonly LiveExtension[], targets: Pick<LiveTargets, "controls">): ExtensionAuth;
@@ -97,6 +97,10 @@ export function createExtensionRestart(extensions, targets) {
97
97
  const reconnect = targets.controls().reconnectMcpServer;
98
98
  if (!extension || !reconnect || !extension.servers.length)
99
99
  return false;
100
+ // A remote server's URL and headers are fixed when the session opens: restarting it would keep
101
+ // the old values (#61), so its options take opening the session again.
102
+ if (extension.remoteServers.length)
103
+ return false;
100
104
  try {
101
105
  for (const server of extension.servers)
102
106
  await reconnect(server);
@@ -108,6 +112,71 @@ export function createExtensionRestart(extensions, targets) {
108
112
  }
109
113
  };
110
114
  }
115
+ /** Signing in and out for a session's installed extensions. */
116
+ export function createExtensionAuth(extensions, targets) {
117
+ const remote = (name) => extensions.find((candidate) => candidate.name === name)?.remoteServers ?? [];
118
+ const statuses = async () => {
119
+ const status = targets.controls().mcpServerStatus;
120
+ return new Map(status ? (await status().catch(() => [])).map((server) => [server.name, server.status]) : []);
121
+ };
122
+ return {
123
+ async needed() {
124
+ const now = await statuses();
125
+ return extensions.filter((extension) => extension.remoteServers.some((server) => now.get(server) === "needs-auth")).map((extension) => extension.name);
126
+ },
127
+ async signIn(name, callbackUrl) {
128
+ const extension = extensions.find((candidate) => candidate.name === name);
129
+ if (!extension)
130
+ return { status: "unavailable", reason: "not-running" };
131
+ if (!extension.remoteServers.length)
132
+ return { status: "unavailable", reason: "not-remote" };
133
+ const controls = targets.controls();
134
+ const now = await statuses();
135
+ const pending = extension.remoteServers.filter((server) => now.get(server) === "needs-auth");
136
+ if (!pending.length)
137
+ return { status: "signed-in" };
138
+ if (callbackUrl) {
139
+ if (!controls.mcpSubmitOAuthCallbackUrl || !controls.reconnectMcpServer)
140
+ return { status: "unavailable", reason: "unsupported" };
141
+ // A pasted callback stores the token, but the session stays needs-auth until the server
142
+ // reconnects (confirmed empirically, CLI 2.1.300).
143
+ for (const server of pending) {
144
+ await controls.mcpSubmitOAuthCallbackUrl(server, callbackUrl);
145
+ await controls.reconnectMcpServer(server);
146
+ }
147
+ return (await this.connected(name, 5000)) ? { status: "signed-in" } : { status: "open", links: [] };
148
+ }
149
+ if (!controls.mcpAuthenticate)
150
+ return { status: "unavailable", reason: "unsupported" };
151
+ const links = [];
152
+ for (const server of pending) {
153
+ const started = await controls.mcpAuthenticate(server);
154
+ if (started.authUrl)
155
+ links.push({ server, url: started.authUrl });
156
+ }
157
+ return { status: "open", links };
158
+ },
159
+ async connected(name, timeoutMs = 0) {
160
+ const servers = remote(name);
161
+ for (const start = Date.now();; await new Promise((resolve) => setTimeout(resolve, 500))) {
162
+ const now = await statuses();
163
+ if (servers.length && servers.every((server) => now.get(server) === "connected"))
164
+ return true;
165
+ if (Date.now() - start >= timeoutMs)
166
+ return false;
167
+ }
168
+ },
169
+ async signOut(name) {
170
+ const servers = remote(name);
171
+ const clear = targets.controls().mcpClearAuth;
172
+ if (!servers.length || !clear)
173
+ return false;
174
+ for (const server of servers)
175
+ await clear(server);
176
+ return true;
177
+ },
178
+ };
179
+ }
111
180
  /** The kit's lists, as the extension is now. */
112
181
  function follow(extension, enabled, targets) {
113
182
  const without = (list, items) => {
@@ -120,4 +120,5 @@ export declare function installFromMarketplace(dir: string, spec: string, scopeD
120
120
  commit?: string;
121
121
  marketplace: string;
122
122
  missing: string[];
123
+ remote: string[];
123
124
  }>;
@@ -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)",
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>)",
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)`,
@@ -51,6 +51,18 @@ export const de = {
51
51
  extensionIsInstalled: "(installiert)",
52
52
  extensionUpdateAvailable: (installed) => `(installiert: ${installed}, Update verfügbar)`,
53
53
  extensionLacks: (name, what) => `(${name}: ${what})`,
54
+ extensionTalksTo: (name, hosts) => `(${name} spricht mit ${hosts}: was dein Agent dorthin sendet, verlässt diesen Computer)`,
55
+ extensionNeedsSignIn: (name) => `${name} braucht deine Anmeldung: /extensions sign-in ${name}`,
56
+ extensionSignInOpen: (name, url) => `(zum Anmelden bei ${name} öffne ${url} : sobald du dort fertig bist, ist sie hier verbunden)`,
57
+ extensionSignInPaste: (name) => `(auf einem anderen Computer? Füge die Adresse ein, bei der der Browser landet: /extensions sign-in ${name} <diese Adresse>)`,
58
+ extensionSignedIn: (name) => `(bei ${name} angemeldet: ihre Werkzeuge sind bereit)`,
59
+ extensionSignInWaiting: (name) => `(warte noch auf die Anmeldung bei ${name}; /extensions sign-in ${name} gibt den Link erneut)`,
60
+ extensionAlreadySignedIn: (name) => `(${name} ist bereits angemeldet)`,
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
+ extensionSignedOut: (name) => `(bei ${name} abgemeldet: sie bittet dich, dich erneut anzumelden)`,
63
+ remoteCallTitle: (destination) => `Das an ${destination} senden? Es verlässt diesen Computer`,
64
+ connectorsAllowed: (names) => `claude.ai-Connectoren: ${names}`,
65
+ connectorsMissing: (names) => `Nicht in deinem claude.ai-Konto: ${names} (verbinde ihn auf claude.ai und öffne die Sitzung neu)`,
54
66
  extensionInstalled: (name) => `(${name} installiert: in einem neuen Gespräch da, oder sofort mit /extensions reload, ohne dieses zu verlieren)`,
55
67
  extensionUpdated: (name, from, to) => `(${name} aktualisiert, ${from} → ${to}: in einem neuen Gespräch da, oder sofort mit /extensions reload)`,
56
68
  extensionsUpToDate: "(alle Erweiterungen aus einem Marketplace sind aktuell)",
@@ -68,6 +68,26 @@ export interface Messages {
68
68
  extensionInstalled(name: string): string;
69
69
  /** After installing: a program it needs that this computer lacks (#63); `what` is the reason, in English. */
70
70
  extensionLacks(name: string, what: string): string;
71
+ /** After installing one with remote servers (#61): the hosts it talks to. */
72
+ extensionTalksTo(name: string, hosts: string): string;
73
+ /** In /extensions: a running extension whose remote server needs the person to sign in. */
74
+ extensionNeedsSignIn(name: string): string;
75
+ /** The link to sign in to an extension's remote server. */
76
+ extensionSignInOpen(name: string, url: string): string;
77
+ /** How to finish signing in from another computer. */
78
+ extensionSignInPaste(name: string): string;
79
+ extensionSignedIn(name: string): string;
80
+ /** Signing in hasn't finished after a while. */
81
+ extensionSignInWaiting(name: string): string;
82
+ extensionAlreadySignedIn(name: string): string;
83
+ extensionSignInUnavailable(name: string): string;
84
+ extensionSignedOut(name: string): string;
85
+ /** Before a call to a remote MCP server, when the agent asks first (#70): where it goes. */
86
+ remoteCallTitle(destination: string): string;
87
+ /** In /extensions: the claude.ai connectors let in (#70). */
88
+ connectorsAllowed(names: string): string;
89
+ /** In /extensions: connectors the agent asked for that the account hasn't. */
90
+ connectorsMissing(names: string): string;
71
91
  extensionUpdated(name: string, from: string, to: string): string;
72
92
  extensionsUpToDate: string;
73
93
  extensionRemoved(name: string, withData: boolean): string;
@@ -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)",
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>)",
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)`,
@@ -51,6 +51,18 @@ export const en = {
51
51
  extensionUpdateAvailable: (installed) => `(installed: ${installed}, update available)`,
52
52
  extensionInstalled: (name) => `(${name} installed: it's there in a new conversation, or now with /extensions reload, keeping this one)`,
53
53
  extensionLacks: (name, what) => `(${name} ${what})`,
54
+ extensionTalksTo: (name, hosts) => `(${name} talks to ${hosts}: what your agent sends there leaves this computer)`,
55
+ extensionNeedsSignIn: (name) => `${name} needs you to sign in: /extensions sign-in ${name}`,
56
+ extensionSignInOpen: (name, url) => `(to sign in to ${name}, open ${url} : when you're done there, it's connected here)`,
57
+ extensionSignInPaste: (name) => `(on another computer? Paste the address the browser ends on: /extensions sign-in ${name} <that address>)`,
58
+ extensionSignedIn: (name) => `(signed in to ${name}: its tools are ready)`,
59
+ extensionSignInWaiting: (name) => `(still waiting for the sign-in to ${name}; /extensions sign-in ${name} gives the link again)`,
60
+ extensionAlreadySignedIn: (name) => `(${name} is already signed in)`,
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
+ extensionSignedOut: (name) => `(signed out of ${name}: it will ask you to sign in again)`,
63
+ remoteCallTitle: (destination) => `Send this to ${destination}? It leaves this computer`,
64
+ connectorsAllowed: (names) => `claude.ai connectors: ${names}`,
65
+ connectorsMissing: (names) => `Not in your claude.ai account: ${names} (connect it on claude.ai, then reopen the session)`,
54
66
  extensionUpdated: (name, from, to) => `(${name} updated, ${from} → ${to}: it's there in a new conversation, or now with /extensions reload)`,
55
67
  extensionsUpToDate: "(every extension from a marketplace is up to date)",
56
68
  extensionRemoved: (name, withData) => `(${name} removed${withData ? ", with its data" : "; its data is kept for a reinstall"})`,
@@ -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)",
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>)",
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)`,
@@ -51,6 +51,18 @@ export const es = {
51
51
  extensionIsInstalled: "(instalada)",
52
52
  extensionUpdateAvailable: (installed) => `(instalada: ${installed}, hay actualización)`,
53
53
  extensionLacks: (name, what) => `(${name}: ${what})`,
54
+ extensionTalksTo: (name, hosts) => `(${name} habla con ${hosts}: lo que tu agente le envíe sale de este ordenador)`,
55
+ extensionNeedsSignIn: (name) => `${name} necesita que inicies sesión: /extensions sign-in ${name}`,
56
+ extensionSignInOpen: (name, url) => `(para iniciar sesión en ${name}, abre ${url} : cuando termines allí, queda conectada aquí)`,
57
+ extensionSignInPaste: (name) => `(¿en otro ordenador? Pega la dirección en la que acaba el navegador: /extensions sign-in ${name} <esa dirección>)`,
58
+ extensionSignedIn: (name) => `(sesión iniciada en ${name}: sus herramientas están listas)`,
59
+ extensionSignInWaiting: (name) => `(sigo esperando el inicio de sesión en ${name}; /extensions sign-in ${name} vuelve a dar el enlace)`,
60
+ extensionAlreadySignedIn: (name) => `(${name} ya tiene la sesión iniciada)`,
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
+ extensionSignedOut: (name) => `(sesión cerrada en ${name}: te pedirá que vuelvas a iniciarla)`,
63
+ remoteCallTitle: (destination) => `¿Enviar esto a ${destination}? Sale de este ordenador`,
64
+ connectorsAllowed: (names) => `Conectores de claude.ai: ${names}`,
65
+ connectorsMissing: (names) => `No están en tu cuenta de claude.ai: ${names} (conéctalo en claude.ai y vuelve a abrir la sesión)`,
54
66
  extensionInstalled: (name) => `(${name} instalada: está en una conversación nueva, o ya con /extensions reload, sin perder esta)`,
55
67
  extensionUpdated: (name, from, to) => `(${name} actualizada, ${from} → ${to}: está en una conversación nueva, o ya con /extensions reload)`,
56
68
  extensionsUpToDate: "(todas las extensiones de un marketplace están al día)",
@@ -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)",
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>)",
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)`,
@@ -51,6 +51,18 @@ export const fr = {
51
51
  extensionIsInstalled: "(installée)",
52
52
  extensionUpdateAvailable: (installed) => `(installée : ${installed}, mise à jour disponible)`,
53
53
  extensionLacks: (name, what) => `(${name} : ${what})`,
54
+ extensionTalksTo: (name, hosts) => `(${name} échange avec ${hosts} : ce que votre agent y envoie quitte cet ordinateur)`,
55
+ extensionNeedsSignIn: (name) => `${name} a besoin que vous vous connectiez : /extensions sign-in ${name}`,
56
+ extensionSignInOpen: (name, url) => `(pour vous connecter à ${name}, ouvrez ${url} : une fois terminé là-bas, elle est connectée ici)`,
57
+ extensionSignInPaste: (name) => `(sur un autre ordinateur ? Collez l'adresse où le navigateur arrive : /extensions sign-in ${name} <cette adresse>)`,
58
+ extensionSignedIn: (name) => `(connecté à ${name} : ses outils sont prêts)`,
59
+ extensionSignInWaiting: (name) => `(toujours en attente de la connexion à ${name} ; /extensions sign-in ${name} redonne le lien)`,
60
+ extensionAlreadySignedIn: (name) => `(${name} est déjà connectée)`,
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
+ extensionSignedOut: (name) => `(déconnecté de ${name} : elle vous demandera de vous reconnecter)`,
63
+ remoteCallTitle: (destination) => `Envoyer ceci à ${destination} ? Cela quitte cet ordinateur`,
64
+ connectorsAllowed: (names) => `Connecteurs claude.ai : ${names}`,
65
+ connectorsMissing: (names) => `Absents de votre compte claude.ai : ${names} (connectez-le sur claude.ai, puis rouvrez la session)`,
54
66
  extensionInstalled: (name) => `(${name} installée : elle est là dans une nouvelle conversation, ou dès maintenant avec /extensions reload, en gardant celle-ci)`,
55
67
  extensionUpdated: (name, from, to) => `(${name} mise à jour, ${from} → ${to} : elle est là dans une nouvelle conversation, ou dès maintenant avec /extensions reload)`,
56
68
  extensionsUpToDate: "(toutes les extensions d'un marketplace sont à jour)",
@@ -1,4 +1,4 @@
1
- import type { ExtensionDirs, PluginServer } from "./externalExtensions.js";
1
+ import type { ExtensionDirs, LocalServer } from "./externalExtensions.js";
2
2
  /**
3
3
  * Installed extensions' servers run with `npx` (#54): a `.mcp.json` that says
4
4
  * `npx -y <package>@<x.y.z>` runs as it is in Claude Code, and in the kit too, which doesn't run
@@ -32,7 +32,7 @@ export declare const packageSpec: (server: NpxServer) => string;
32
32
  * does but not as the kit can, or its package. Its command is `npx` (`npx.cmd`), its options
33
33
  * only `-y`/`--yes`, then `<package>@<x.y.z>` and the server's own arguments.
34
34
  */
35
- export declare function npxServer(server: PluginServer): NpxServer | string | undefined;
35
+ export declare function npxServer(server: LocalServer): NpxServer | string | undefined;
36
36
  /** Where an agent keeps the packages its extensions' npx servers run: in its scope (the project's when it has none), shared by both. */
37
37
  export declare function packageCacheDir(dirs: ExtensionDirs): string | undefined;
38
38
  /** A package's folder in the cache. */
@@ -19,6 +19,45 @@ export function toolResultText(content) {
19
19
  .map((part) => part.text)
20
20
  .join("\n");
21
21
  }
22
+ /**
23
+ * The SDK's sign-in methods for a remote MCP server behind OAuth, when its `Query` has them: they
24
+ * aren't in its typings (confirmed empirically, SDK 0.3.300; ADR-028), so each is checked first.
25
+ */
26
+ function signInControls(result) {
27
+ const methods = result;
28
+ const call = (name) => (typeof methods[name] === "function" ? (...args) => methods[name].apply(result, args) : undefined);
29
+ const authenticate = call("mcpAuthenticate");
30
+ const submit = call("mcpSubmitOAuthCallbackUrl");
31
+ const clear = call("mcpClearAuth");
32
+ return {
33
+ ...(authenticate ? { mcpAuthenticate: (server) => authenticate(server) } : {}),
34
+ ...(submit ? { mcpSubmitOAuthCallbackUrl: (server, callbackUrl) => submit(server, callbackUrl) } : {}),
35
+ ...(clear ? { mcpClearAuth: (server) => clear(server) } : {}),
36
+ };
37
+ }
38
+ /**
39
+ * Turns back on the servers the CLI saved as switched off in an earlier session (#75):
40
+ * `toggleMcpServer(name, false)` is saved in the Claude Code config of the session's folder
41
+ * (`~/.claude.json`, `disabledMcpServers`), not only in the session (confirmed empirically, CLI
42
+ * 2.1.300), so a server the kit passes again would start disabled. The kit passes only what it
43
+ * wants running, so one of them that starts disabled is turned on, which also removes the saved
44
+ * entry. Only while the session's servers start: switching one off later in the session is the
45
+ * live switch's (liveExtensions.ts).
46
+ */
47
+ async function restoreSavedSwitches(result, options) {
48
+ const passed = new Set(Object.keys(options.mcpServers ?? {}));
49
+ if (!passed.size)
50
+ return;
51
+ for (let attempt = 0; attempt < 30; attempt++) {
52
+ const status = (await result.mcpServerStatus().catch(() => [])).filter((server) => passed.has(server.name));
53
+ for (const server of status)
54
+ if (server.status === "disabled")
55
+ await result.toggleMcpServer(server.name, true).catch(() => undefined);
56
+ if (status.length && !status.some((server) => server.status === "pending" || server.status === "disabled"))
57
+ return;
58
+ await new Promise((resolve) => setTimeout(resolve, 500));
59
+ }
60
+ }
22
61
  /**
23
62
  * Thin wrapper around the SDK's own `query()`: same two-argument shape (a plain string
24
63
  * for a one-shot run, or an `AsyncIterable<SDKUserMessage>` — see `createInputQueue()` —
@@ -38,7 +77,9 @@ export function runQuery(prompt, options) {
38
77
  reloadPlugins: () => result.reloadPlugins(),
39
78
  reconnectMcpServer: (server) => result.reconnectMcpServer(server),
40
79
  mcpServerStatus: () => result.mcpServerStatus(),
80
+ ...signInControls(result),
41
81
  });
82
+ void restoreSavedSwitches(result, options);
42
83
  // The main agent's tool calls by id, to name the results that come back for them.
43
84
  const toolNames = new Map();
44
85
  async function* generateEvents() {