@falkenslab/agent-kit 0.24.0 → 0.25.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 (40) 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 +13 -1
  6. package/dist/chat/runs.d.ts +3 -1
  7. package/dist/chat/runs.js +1 -1
  8. package/dist/core/extensionCatalog.d.ts +6 -1
  9. package/dist/core/extensionCatalog.js +10 -8
  10. package/dist/core/extensionOptions.d.ts +5 -0
  11. package/dist/core/extensionOptions.js +10 -0
  12. package/dist/core/extensions.d.ts +6 -0
  13. package/dist/core/externalExtensions.d.ts +24 -1
  14. package/dist/core/externalExtensions.js +78 -12
  15. package/dist/core/liveExtensions.d.ts +38 -0
  16. package/dist/core/liveExtensions.js +69 -0
  17. package/dist/core/marketplaces.d.ts +1 -0
  18. package/dist/core/messages/de.js +10 -1
  19. package/dist/core/messages/en.d.ts +14 -0
  20. package/dist/core/messages/en.js +10 -1
  21. package/dist/core/messages/es.js +10 -1
  22. package/dist/core/messages/fr.js +10 -1
  23. package/dist/core/npxPackages.d.ts +2 -2
  24. package/dist/core/runner.js +17 -0
  25. package/dist/core/session.d.ts +6 -2
  26. package/dist/core/session.js +5 -2
  27. package/dist/core/sessionFacts.d.ts +14 -0
  28. package/dist/extensions/awareness/catalog.d.ts +8 -0
  29. package/dist/extensions/awareness/catalog.js +92 -0
  30. package/dist/extensions/awareness/index.d.ts +3 -1
  31. package/dist/extensions/awareness/index.js +15 -6
  32. package/dist/extensions/awareness/labels.js +8 -5
  33. package/dist/extensions/awareness/tools.d.ts +2 -2
  34. package/dist/extensions/awareness/tools.js +4 -3
  35. package/dist/index.d.ts +3 -3
  36. package/dist/index.js +1 -1
  37. package/dist/tui/extensionCommand.js +11 -6
  38. package/extensions/awareness/skills/extensions/SKILL.md +56 -0
  39. package/package.json +1 -1
  40. package/schemas/marketplace-catalog.schema.json +6 -0
package/README.md CHANGED
@@ -9,6 +9,8 @@
9
9
  [![license](https://img.shields.io/npm/l/@falkenslab/agent-kit)](LICENSE)
10
10
  [![docs](https://img.shields.io/badge/docs-falkenslab.github.io%2Fagent--kit-4453b8)](https://falkenslab.github.io/agent-kit/)
11
11
 
12
+ **[📖 Documentation](https://falkenslab.github.io/agent-kit/)** · [📖 Documentación en español](https://falkenslab.github.io/agent-kit/es/)
13
+
12
14
  A foundation for building AI agents on top of the [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk). It isn't an agent itself: it solves once what every agent needs (human oversight, safety, memory and a good terminal interface), so each concrete agent only has to write what belongs to its own domain: what it knows how to do, with which tools, and how it talks.
13
15
 
14
16
  ## What an agent built with it can do
@@ -9,6 +9,7 @@ import { type ExtensionsStatus, type ModeControl } from "../core/session.js";
9
9
  import { type Todo } from "../core/todos.js";
10
10
  import { type ToolLabels } from "../core/toolLabels.js";
11
11
  import type { ToolPhrase } from "../core/messages/index.js";
12
+ import type { SignIn } from "../core/liveExtensions.js";
12
13
  import { type ExtensionOptionState, type ExtensionScope } from "../core/externalExtensions.js";
13
14
  import { type CatalogEntry } from "../core/extensionCatalog.js";
14
15
  import { type SessionOpener } from "./runs.js";
@@ -268,12 +269,21 @@ export declare function createChatController(options: Options | SessionOpener, s
268
269
  * installed until `confirmed`: the result says which (`confirm`), for the view to ask the
269
270
  * person. The running session is untouched: it's there in a new conversation, or with `reopen()`.
270
271
  */
272
+ /**
273
+ * Signs in to an installed extension's remote servers (#61): gives the person the links (never
274
+ * opens a browser), then says when the sign-in finishes. With `callbackUrl`, the address the
275
+ * browser ended on (from another computer), finishes it instead.
276
+ */
277
+ signInExtension(name: string, callbackUrl?: string): Promise<SignIn>;
278
+ /** Signs out of an installed extension's remote servers (#61). */
279
+ signOutExtension(name: string): Promise<boolean>;
271
280
  installExtension(spec: string, options?: {
272
281
  scope?: ExtensionScope;
273
282
  confirmed?: boolean;
274
283
  }): Promise<{
275
284
  name?: string;
276
285
  missing?: string[];
286
+ remote?: string[];
277
287
  confirm?: {
278
288
  marketplace: string;
279
289
  description?: string;
@@ -315,6 +315,10 @@ export async function createChatController(options, settings = {}) {
315
315
  await controller.removeExtension(action.name, { deleteData: action.deleteData });
316
316
  else if (action.type === "trust")
317
317
  await controller.trustWorkspace();
318
+ else if (action.type === "sign-in")
319
+ await controller.signInExtension(action.name, action.callbackUrl);
320
+ else if (action.type === "sign-out")
321
+ await controller.signOutExtension(action.name);
318
322
  else
319
323
  await controller.reopen();
320
324
  }
@@ -595,6 +599,37 @@ export async function createChatController(options, settings = {}) {
595
599
  * installed until `confirmed`: the result says which (`confirm`), for the view to ask the
596
600
  * person. The running session is untouched: it's there in a new conversation, or with `reopen()`.
597
601
  */
602
+ /**
603
+ * Signs in to an installed extension's remote servers (#61): gives the person the links (never
604
+ * opens a browser), then says when the sign-in finishes. With `callbackUrl`, the address the
605
+ * browser ended on (from another computer), finishes it instead.
606
+ */
607
+ async signInExtension(name, callbackUrl) {
608
+ const auth = current.extensionAuth;
609
+ const result = auth ? await auth.signIn(name, callbackUrl) : { status: "unavailable", reason: "not-running" };
610
+ if (result.status === "unavailable")
611
+ notice(t().extensionSignInUnavailable(name));
612
+ else if (result.status === "signed-in")
613
+ notice(callbackUrl ? t().extensionSignedIn(name) : t().extensionAlreadySignedIn(name));
614
+ else {
615
+ for (const link of result.links)
616
+ notice(t().extensionSignInOpen(name, link.url));
617
+ notice(t().extensionSignInPaste(name));
618
+ // Waits in the background: the person signs in in their browser, whenever they do.
619
+ void auth.connected(name, 5 * 60_000).then((done) => {
620
+ notice(done ? t().extensionSignedIn(name) : t().extensionSignInWaiting(name));
621
+ if (done)
622
+ extensionNotices.push(`<system-reminder>The user signed in to the ${name} extension: its remote tools are available now.</system-reminder>`);
623
+ });
624
+ }
625
+ return result;
626
+ },
627
+ /** Signs out of an installed extension's remote servers (#61). */
628
+ async signOutExtension(name) {
629
+ const done = (await current.extensionAuth?.signOut(name).catch(() => false)) ?? false;
630
+ notice(done ? t().extensionSignedOut(name) : t().extensionSignInUnavailable(name));
631
+ return done;
632
+ },
598
633
  async installExtension(spec, options = {}) {
599
634
  if (!current.extensions)
600
635
  throw new Error("This chat's session has no extensions folder.");
@@ -608,7 +643,10 @@ export async function createChatController(options, settings = {}) {
608
643
  // What it needs and this computer lacks (#63): the person installs it, the kit never does.
609
644
  for (const line of result.missing)
610
645
  notice(t().extensionLacks(result.name, line));
611
- return { name: result.name, missing: result.missing };
646
+ // Where what the agent sends goes (#61).
647
+ if (result.remote.length)
648
+ notice(t().extensionTalksTo(result.name, result.remote.join(", ")));
649
+ return { name: result.name, missing: result.missing, remote: result.remote };
612
650
  },
613
651
  /** Updates one installed extension from its marketplace, or every one with a newer version there; the names updated. */
614
652
  async updateExtension(name) {
@@ -25,6 +25,15 @@ export type ExtensionAction = {
25
25
  type: "reload";
26
26
  } | {
27
27
  type: "trust";
28
+ }
29
+ /** Signing in to an extension's remote servers (#61), or finishing it with the callback URL pasted from another computer. */
30
+ | {
31
+ type: "sign-in";
32
+ name: string;
33
+ callbackUrl?: string;
34
+ } | {
35
+ type: "sign-out";
36
+ name: string;
28
37
  };
29
38
  /** One line for a catalog entry: what installs it, its version, whether it's installed or has an update, what it is. */
30
39
  export declare function describeEntry(entry: CatalogEntry): string;
@@ -35,6 +35,13 @@ export function describeCapabilities(status) {
35
35
  ];
36
36
  });
37
37
  }
38
+ /** The running extensions whose remote servers need the person to sign in (#61). */
39
+ async function needingSignIn(status) {
40
+ const waiting = new Set((await status.serverStatus?.().catch(() => []))?.filter((server) => server.status === "needs-auth").map((server) => server.name));
41
+ if (!waiting.size)
42
+ return [];
43
+ return status.active.filter((name) => status.about[name]?.servers.some((server) => waiting.has(server)));
44
+ }
38
45
  /** One line for a catalog entry: what installs it, its version, whether it's installed or has an update, what it is. */
39
46
  export function describeEntry(entry) {
40
47
  const state = entry.installed ? (entry.update ? ` ${t().extensionUpdateAvailable(entry.installed.manifest?.version ?? "?")}` : ` ${t().extensionIsInstalled}`) : "";
@@ -78,6 +85,11 @@ export async function chatExtensionsCommand(line, status, canReopen) {
78
85
  return canReopen ? { lines: [], action: { type: "reload" } } : { lines: [t().extensionsCantReopen] };
79
86
  if (verb === "trust" && !name)
80
87
  return canReopen ? { lines: [], action: { type: "trust" } } : { lines: [t().extensionsCantReopen] };
88
+ // Signing in happens in the running session: no reopening (#61).
89
+ if (verb === "sign-in" && name)
90
+ return { lines: [], action: { type: "sign-in", name, ...(extra[0] ? { callbackUrl: extra[0] } : {}) } };
91
+ if (verb === "sign-out" && name)
92
+ return { lines: [], action: { type: "sign-out", name } };
81
93
  if (action?.toLowerCase() === "config" && name) {
82
94
  const installed = (status?.installed ?? []).find((extension) => extension.name === name && !extension.shadowed);
83
95
  if (!installed)
@@ -107,7 +119,7 @@ export async function chatExtensionsCommand(line, status, canReopen) {
107
119
  if (!action) {
108
120
  const lines = [status?.active.length ? t().extensionsRunning(status.active.join(", ")) : t().extensionsNone];
109
121
  if (status)
110
- lines.push(...describeCapabilities(status));
122
+ lines.push(...describeCapabilities(status), ...(await needingSignIn(status)).map((extension) => t().extensionNeedsSignIn(extension)));
111
123
  for (const { name: off, reason } of status?.inactive ?? [])
112
124
  lines.push(t().extensionOff(off, reason));
113
125
  if (status?.installed.length)
@@ -1,7 +1,7 @@
1
1
  import type { Options } from "@anthropic-ai/claude-agent-sdk";
2
2
  import type { ExtensionsStatus, ModeControl } from "../core/session.js";
3
3
  import type { ToolLabels } from "../core/toolLabels.js";
4
- import type { ExtensionRestart, ExtensionSwitch } from "../core/liveExtensions.js";
4
+ import type { ExtensionAuth, ExtensionRestart, ExtensionSwitch } from "../core/liveExtensions.js";
5
5
  import { type RunFolder, type RunSummary } from "../core/runs.js";
6
6
  /**
7
7
  * Builds the session's options for a run folder, e.g.
@@ -17,6 +17,7 @@ export type SessionOpener = (run: RunFolder) => Promise<Options | {
17
17
  apis?: Record<string, Record<string, unknown>>;
18
18
  switchExtension?: ExtensionSwitch;
19
19
  restartExtension?: ExtensionRestart;
20
+ extensionAuth?: ExtensionAuth;
20
21
  }>;
21
22
  export interface OpenedSession {
22
23
  run: RunFolder;
@@ -27,6 +28,7 @@ export interface OpenedSession {
27
28
  apis?: Record<string, Record<string, unknown>>;
28
29
  switchExtension?: ExtensionSwitch;
29
30
  restartExtension?: ExtensionRestart;
31
+ extensionAuth?: ExtensionAuth;
30
32
  }
31
33
  /** The run a chat starts with: the latest one with `--continue` (if any), a new one otherwise. */
32
34
  export declare function firstRun(runsDir: string, argv?: readonly string[]): Promise<RunFolder>;
package/dist/chat/runs.js CHANGED
@@ -13,7 +13,7 @@ export async function firstRun(runsDir, argv = process.argv.slice(2)) {
13
13
  export async function openSession(opener, run) {
14
14
  const opened = await opener(run);
15
15
  // The SDK's Options has no "options" field: this is buildSessionOptions()'s result.
16
- return "options" in opened ? { run, options: opened.options, modeControl: opened.modeControl, toolLabels: opened.toolLabels, extensions: opened.extensions, apis: opened.apis, switchExtension: opened.switchExtension, restartExtension: opened.restartExtension } : { run, options: opened };
16
+ return "options" in opened ? { run, options: opened.options, modeControl: opened.modeControl, toolLabels: opened.toolLabels, extensions: opened.extensions, apis: opened.apis, switchExtension: opened.switchExtension, restartExtension: opened.restartExtension, extensionAuth: opened.extensionAuth } : { run, options: opened };
17
17
  }
18
18
  /** A run's date for a person, in the kit's language. */
19
19
  export function runDate(date) {
@@ -1,5 +1,6 @@
1
1
  import { type ExtensionDirs, type ExtensionScope, type InstalledExtension } from "./externalExtensions.js";
2
2
  import { type DescribedCapability } from "./extensions.js";
3
+ import type { Language } from "./language.js";
3
4
  import { type KnownMarketplace, type MarketplacePlugin } from "./marketplaces.js";
4
5
  /**
5
6
  * The agent's catalog of extensions (#56): what its known marketplaces offer, what's installed
@@ -42,14 +43,17 @@ export interface CatalogEntry {
42
43
  name: string;
43
44
  for: string[];
44
45
  }[];
46
+ /** The hosts its remote servers talk to (#61): what the agent sends there leaves the computer. */
47
+ remote: string[];
45
48
  }
46
49
  /** Whether `offered` is newer than `installed` (x.y.z); a version that can't be compared isn't. */
47
50
  export declare function isNewer(offered: string | undefined, installed: string | undefined): boolean;
48
- /** Everything the known marketplaces offer, matching `words` (in its name, description, category, tags or keywords) when given. */
51
+ /** Everything the known marketplaces offer, matching `words` (in its name, description, category, tags or keywords) when given; its texts in `language` (the kit's by default). */
49
52
  export declare function extensionCatalog(dirs: ExtensionDirs, options?: {
50
53
  official?: string;
51
54
  words?: readonly string[];
52
55
  topics?: readonly string[];
56
+ language?: Language;
53
57
  }): Promise<CatalogEntry[]>;
54
58
  /** Whether installing `spec` needs the person's confirmation first: a plugin of a marketplace that isn't the agent's own. */
55
59
  export declare function needsConfirmation(dirs: ExtensionDirs, spec: string, official?: string): Promise<{
@@ -72,6 +76,7 @@ export declare function installExtension(dirs: ExtensionDirs, spec: string, opti
72
76
  marketplace: string;
73
77
  commit?: string;
74
78
  missing: string[];
79
+ remote: string[];
75
80
  }>;
76
81
  /**
77
82
  * Updates an installed extension from the marketplace it came from: a fresh copy of the
@@ -1,7 +1,7 @@
1
1
  import { mkdtemp, rm, stat } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { addExtension, codeStoreDir, hashExtension, inKitRange, installTarget, isGitSource, listInstalled, readExternalManifest, readLock, storedCodeDir } from "./externalExtensions.js";
4
+ import { addExtension, remoteHosts, codeStoreDir, hashExtension, inKitRange, installTarget, isGitSource, listInstalled, readExternalManifest, readLock, storedCodeDir } from "./externalExtensions.js";
5
5
  import { cachePackage, cachedPackageDir } from "./npxPackages.js";
6
6
  import { trustWorkspace } from "./workspaceTrust.js";
7
7
  import { describeCapability, describeInLanguage } from "./extensions.js";
@@ -51,7 +51,7 @@ const IMAGE = /\.(svg|png|jpe?g|webp)$/i;
51
51
  * the version (its `plugin.json` wins over the entry, as Claude Code computes it), its keywords,
52
52
  * and its look, an image inside its folder only.
53
53
  */
54
- async function offered(marketplace, plugin) {
54
+ async function offered(marketplace, plugin, language) {
55
55
  try {
56
56
  const resolved = resolvePluginSource(marketplace, plugin);
57
57
  if ("source" in resolved && !isGitSource(resolved.source) && (await stat(resolved.source).catch(() => null))?.isDirectory()) {
@@ -66,16 +66,17 @@ async function offered(marketplace, plugin) {
66
66
  };
67
67
  const [logo, banner] = [await image(manifest?.catalog?.logo), await image(manifest?.catalog?.banner)];
68
68
  const version = manifest?.version ?? plugin.version;
69
- const capabilities = (manifest?.capabilities ?? []).map((capability) => describeCapability(capability, getLanguage()));
69
+ const capabilities = (manifest?.capabilities ?? []).map((capability) => describeCapability(capability, language));
70
70
  const requirements = (manifest?.requirements ?? []).map((requirement) => ({ name: requirement.name, for: requirement.for }));
71
- const description = describeInLanguage(plugin.description ?? manifest?.description ?? "", manifest?.descriptions, getLanguage());
72
- return { ...(version ? { version } : {}), keywords: manifest?.keywords ?? [], ...(logo ? { logo } : {}), ...(banner ? { banner } : {}), capabilities, requirements, description };
71
+ const description = describeInLanguage(plugin.description ?? manifest?.description ?? "", manifest?.descriptions, language);
72
+ const remote = manifest ? remoteHosts(manifest) : [];
73
+ return { ...(version ? { version } : {}), keywords: manifest?.keywords ?? [], ...(logo ? { logo } : {}), ...(banner ? { banner } : {}), capabilities, requirements, description, remote };
73
74
  }
74
75
  }
75
76
  catch {
76
77
  // A source the kit doesn't install: the entry's own fields.
77
78
  }
78
- return { ...(plugin.version ? { version: plugin.version } : {}), keywords: [], capabilities: [], requirements: [], description: plugin.description ?? "" };
79
+ return { ...(plugin.version ? { version: plugin.version } : {}), keywords: [], capabilities: [], requirements: [], description: plugin.description ?? "", remote: [] };
79
80
  }
80
81
  /** Whether `offered` is newer than `installed` (x.y.z); a version that can't be compared isn't. */
81
82
  export function isNewer(offered, installed) {
@@ -83,7 +84,7 @@ export function isNewer(offered, installed) {
83
84
  return false;
84
85
  return inKitRange(offered, `>${installed.split("-")[0]}`);
85
86
  }
86
- /** Everything the known marketplaces offer, matching `words` (in its name, description, category, tags or keywords) when given. */
87
+ /** Everything the known marketplaces offer, matching `words` (in its name, description, category, tags or keywords) when given; its texts in `language` (the kit's by default). */
87
88
  export async function extensionCatalog(dirs, options = {}) {
88
89
  const installed = (await listInstalled(dirs)).filter((extension) => !extension.shadowed);
89
90
  const words = (options.words ?? []).map((word) => word.toLowerCase());
@@ -92,7 +93,7 @@ export async function extensionCatalog(dirs, options = {}) {
92
93
  const entries = [];
93
94
  for (const marketplace of await knownMarketplaces(dirs, options.official)) {
94
95
  for (const plugin of marketplace.manifest?.plugins ?? []) {
95
- const look = await offered(marketplace, plugin);
96
+ const look = await offered(marketplace, plugin, options.language ?? getLanguage());
96
97
  const topics = [...new Set([...look.keywords, ...(plugin.tags ?? []), ...(plugin.keywords ?? []), ...(plugin.category ? [plugin.category] : [])].map((topic) => topic.toLowerCase()))];
97
98
  if (wanted && !topics.some((topic) => wanted.has(topic)))
98
99
  continue;
@@ -117,6 +118,7 @@ export async function extensionCatalog(dirs, options = {}) {
117
118
  ...(look.banner ? { banner: look.banner } : {}),
118
119
  capabilities: look.capabilities,
119
120
  requirements: look.requirements,
121
+ remote: look.remote,
120
122
  });
121
123
  }
122
124
  }
@@ -52,5 +52,10 @@ export declare function missingOptions(declared: Record<string, ExtensionOption>
52
52
  export declare function storeOptionValues(scopeDir: string, name: string, declared: Record<string, ExtensionOption>, stored: Record<string, OptionValue>, changes: Record<string, OptionValue | undefined>): Promise<Record<string, OptionValue>>;
53
53
  /** Removes an extension's sensitive values. */
54
54
  export declare function removeSecrets(scopeDir: string, name: string): Promise<void>;
55
+ /**
56
+ * `${user_config.KEY}` in `text` replaced by its value: a list comma-separated, none as empty, as
57
+ * the launcher does for a local server. The kit's own, for a remote server's URL and headers (#61).
58
+ */
59
+ export declare function fillOptions(text: string, values: Record<string, OptionValue>): string;
55
60
  /** The `${user_config.KEY}` a server's values name. */
56
61
  export declare function optionsNamed(values: readonly string[]): string[];
@@ -133,6 +133,16 @@ export async function storeOptionValues(scopeDir, name, declared, stored, change
133
133
  export async function removeSecrets(scopeDir, name) {
134
134
  await rm(extensionSecretsPath(scopeDir, name), { force: true });
135
135
  }
136
+ /**
137
+ * `${user_config.KEY}` in `text` replaced by its value: a list comma-separated, none as empty, as
138
+ * the launcher does for a local server. The kit's own, for a remote server's URL and headers (#61).
139
+ */
140
+ export function fillOptions(text, values) {
141
+ return text.replace(/\$\{user_config\.([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, key) => {
142
+ const value = values[key];
143
+ return value === undefined ? "" : Array.isArray(value) ? value.join(",") : String(value);
144
+ });
145
+ }
136
146
  /** The `${user_config.KEY}` a server's values name. */
137
147
  export function optionsNamed(values) {
138
148
  return [...new Set(values.flatMap((value) => [...value.matchAll(/\$\{user_config\.([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((match) => match[1])))];
@@ -64,6 +64,12 @@ 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 servers that are remote (#61), by session name: the SDK fixes their URL and headers
69
+ * when the session opens, so a changed option takes opening it again, and they may need the
70
+ * person to sign in.
71
+ */
72
+ remoteServers?: string[];
67
73
  /** Its MCP tools that ask the person themselves: interactive mode's step gate doesn't ask first. */
68
74
  selfAskingTools?: string[];
69
75
  /** How the chat shows its tools, by full name, in the kit's language: its line and how it counts in a folded group. */
@@ -26,12 +26,34 @@ export interface ExtensionDirs {
26
26
  /** One of the two scopes an extension can be installed in: the agent's or the project's. */
27
27
  export type ExtensionScope = keyof ExtensionDirs;
28
28
  /** One of a plugin's MCP servers, as its `.mcp.json` (or `plugin.json`'s `mcpServers`) declares it. */
29
- export interface PluginServer {
29
+ /** A server the kit starts on this computer, through its launcher. */
30
+ export interface LocalServer {
30
31
  command: string;
31
32
  args?: string[];
32
33
  /** Its variables: a value may take the agent's with `${VAR}`, and its folders with `${CLAUDE_PLUGIN_ROOT}` and `${CLAUDE_PLUGIN_DATA}`. */
33
34
  env?: Record<string, string>;
34
35
  }
36
+ /**
37
+ * A remote server (#61), over HTTP or SSE: nothing runs here. `${user_config.KEY}` and `${VAR}` in
38
+ * its URL and headers take their values when the session opens.
39
+ */
40
+ export interface RemoteServer {
41
+ type: "http" | "sse";
42
+ url: string;
43
+ headers?: Record<string, string>;
44
+ }
45
+ /** One of an extension's MCP servers, as its `.mcp.json` declares it. */
46
+ export type PluginServer = LocalServer | RemoteServer;
47
+ /** Whether a server is remote (#61). */
48
+ export declare function isRemoteServer(server: PluginServer): server is RemoteServer;
49
+ /**
50
+ * The name a session gives an extension's remote server: its own, prefixed with the extension's.
51
+ * The SDK's CLI keeps OAuth tokens by server name (confirmed empirically, CLI 2.1.300): a server
52
+ * named like one in the runner's own Claude Code would sign in with the runner's token.
53
+ */
54
+ export declare function remoteServerName(extension: string, server: string): string;
55
+ /** The hosts an extension's remote servers talk to, for the person to read before installing it. */
56
+ export declare function remoteHosts(manifest: Pick<ExternalManifest, "servers">): string[];
35
57
  /** How the chat shows one of its tools in one language: `{field}` takes the call's input. */
36
58
  export interface ExternalToolLabel {
37
59
  label: string;
@@ -230,6 +252,7 @@ export declare function addExtension(source: string, scopeDir: string, options?:
230
252
  replaced: boolean;
231
253
  commit?: string;
232
254
  missing: string[];
255
+ remote: string[];
233
256
  }>;
234
257
  /**
235
258
  * Removes an extension from a scope (its files and its lock entry; a linked one's folder is left
@@ -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)
@@ -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;