@falkenslab/agent-kit 0.27.1 → 0.29.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.
@@ -7,7 +7,7 @@ import { type AgentEvent, type AgentRun, type SessionUsage } from "../core/runne
7
7
  import { type RunFolder } from "../core/runs.js";
8
8
  import { type ExtensionsStatus, type ModeControl } from "../core/session.js";
9
9
  import { type Todo } from "../core/todos.js";
10
- import { type ToolLabels } from "../core/toolLabels.js";
10
+ import { type ToolLabels, type ToolResult } from "../core/toolLabels.js";
11
11
  import type { ToolPhrase } from "../core/messages/index.js";
12
12
  import type { SignIn } from "../core/liveExtensions.js";
13
13
  import { type ExtensionOptionState, type ExtensionScope } from "../core/externalExtensions.js";
@@ -42,6 +42,8 @@ export interface ChatSettings {
42
42
  /** Tool call labels and folded-group phrases on top of the kit's and the extensions'. */
43
43
  formatAction?: (toolName: string, input: unknown) => string;
44
44
  toolPhrase?: (toolName: string) => ToolPhrase | undefined;
45
+ /** The line under a tool call for its result, before the labels' own (`ToolLabel.result`, #79): a string replaces it, `null` hides it, `undefined` leaves it to them. */
46
+ formatResult?: (toolName: string, result: ToolResult) => string | null | undefined;
45
47
  /** Runs the agent: the SDK's (`runQuery()`) by default; another for tests, or a host of its own. */
46
48
  runQuery?: (prompt: AsyncIterable<SDKUserMessage>, options: Options) => AgentRun;
47
49
  /** The SDK's prompt suggestions (on by default). */
@@ -62,6 +64,13 @@ export interface ChatSettings {
62
64
  * state's `panel`, answered with `answer()`.
63
65
  */
64
66
  panels?: "view" | "state";
67
+ /**
68
+ * Signing in to an extension (#72): `true` (the default) opens the link in this computer's
69
+ * browser (`openBrowser()`: not over SSH or without a display), `false` never does, and a
70
+ * function opens it the host's way (Electron's `shell.openExternal`), saying whether it did.
71
+ * The link is shown either way, on a line of its own, for another computer.
72
+ */
73
+ openBrowser?: boolean | ((url: string) => Promise<boolean>);
65
74
  }
66
75
  /** One tool call in the transcript. */
67
76
  export interface TranscriptCall {
@@ -216,9 +225,10 @@ export declare function createChatController(options: Options | SessionOpener, s
216
225
  subscribe(listener: (state: ChatState) => void): () => void;
217
226
  /** Calls `listener` with every event, in order; returns how to stop. */
218
227
  onEvent(listener: (event: ChatEvent) => void): () => void;
219
- /** Tool call labels and phrases with the extensions' (for a view that renders events itself). */
228
+ /** Tool call labels, phrases and result lines with the extensions' (for a view that renders events itself). */
220
229
  formatAction: (toolName: string, toolInput: unknown) => string;
221
230
  toolPhrase: (toolName: string) => ToolPhrase | undefined;
231
+ formatResult: (toolName: string, result: ToolResult) => string | null | undefined;
222
232
  /** The folder the session works in. */
223
233
  cwd: () => string;
224
234
  /** Draws the run's earlier conversation, then sends the initial prompt (for a new run). */
@@ -270,9 +280,10 @@ export declare function createChatController(options: Options | SessionOpener, s
270
280
  * person. The running session is untouched: it's there in a new conversation, or with `reopen()`.
271
281
  */
272
282
  /**
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.
283
+ * Signs in to an installed extension's remote servers (#61): opens each link in this computer's
284
+ * browser as `openBrowser` says (#72) and shows it on a line of its own, for another computer,
285
+ * then says when the sign-in finishes. With `callbackUrl`, the address the browser ended on
286
+ * (from another computer), finishes it instead.
276
287
  */
277
288
  signInExtension(name: string, callbackUrl?: string): Promise<SignIn>;
278
289
  /** Signs out of an installed extension's remote servers (#61). */
@@ -7,7 +7,7 @@ import { runQuery } from "../core/runner.js";
7
7
  import { createRunFolder, listRuns, readConversation, readRunSession } from "../core/runs.js";
8
8
  import { createInputQueue, togglePlanMode } from "../core/session.js";
9
9
  import { hasOpenTodos, parseTodos, TODO_TOOL } from "../core/todos.js";
10
- import { withToolLabels, withToolPhrases } from "../core/toolLabels.js";
10
+ import { withToolLabels, withToolPhrases, withToolResults } from "../core/toolLabels.js";
11
11
  import { createConsoleRenderer } from "../tui/consoleRenderer.js";
12
12
  import { chatExtensionsCommand } from "./extensions.js";
13
13
  import { trustWorkspace } from "../core/workspaceTrust.js";
@@ -16,6 +16,7 @@ import { extensionOptionStates, listInstalled, removeExtension as removeInstalle
16
16
  import { extensionCatalog as readCatalog, installExtension as installFromCatalog, needsConfirmation, updateExtension as updateFromCatalog } from "../core/extensionCatalog.js";
17
17
  import { capHistory, loadHistory, saveHistory, slashCommandToken } from "./history.js";
18
18
  import { firstRun, openSession, runFolderOf, runLabel } from "./runs.js";
19
+ import { openBrowser } from "../core/openBrowser.js";
19
20
  const DEFAULT_EXIT_COMMANDS = ["/exit", "/quit"];
20
21
  const DEFAULT_HISTORY_LIMIT = 100;
21
22
  const DEFAULT_PROMPT_LABEL = "\n> ";
@@ -38,6 +39,7 @@ export async function createChatController(options, settings = {}) {
38
39
  const modeControl = () => current.modeControl ?? settings.modeControl;
39
40
  const formatAction = withToolLabels(() => current.toolLabels ?? settings.toolLabels, settings.formatAction);
40
41
  const toolPhrase = withToolPhrases(() => current.toolLabels ?? settings.toolLabels, settings.toolPhrase);
42
+ const formatResult = withToolResults(() => current.toolLabels ?? settings.toolLabels, settings.formatResult);
41
43
  // State and listeners.
42
44
  const stateListeners = new Set();
43
45
  const eventListeners = new Set();
@@ -424,9 +426,10 @@ export async function createChatController(options, settings = {}) {
424
426
  eventListeners.add(listener);
425
427
  return () => eventListeners.delete(listener);
426
428
  },
427
- /** Tool call labels and phrases with the extensions' (for a view that renders events itself). */
429
+ /** Tool call labels, phrases and result lines with the extensions' (for a view that renders events itself). */
428
430
  formatAction,
429
431
  toolPhrase,
432
+ formatResult,
430
433
  /** The folder the session works in. */
431
434
  cwd: () => current.options.cwd ?? process.cwd(),
432
435
  /** Draws the run's earlier conversation, then sends the initial prompt (for a new run). */
@@ -600,9 +603,10 @@ export async function createChatController(options, settings = {}) {
600
603
  * person. The running session is untouched: it's there in a new conversation, or with `reopen()`.
601
604
  */
602
605
  /**
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
+ * Signs in to an installed extension's remote servers (#61): opens each link in this computer's
607
+ * browser as `openBrowser` says (#72) and shows it on a line of its own, for another computer,
608
+ * then says when the sign-in finishes. With `callbackUrl`, the address the browser ended on
609
+ * (from another computer), finishes it instead.
606
610
  */
607
611
  async signInExtension(name, callbackUrl) {
608
612
  const auth = current.extensionAuth;
@@ -612,8 +616,13 @@ export async function createChatController(options, settings = {}) {
612
616
  else if (result.status === "signed-in")
613
617
  notice(callbackUrl ? t().extensionSignedIn(name) : t().extensionAlreadySignedIn(name));
614
618
  else {
615
- for (const link of result.links)
616
- notice(t().extensionSignInOpen(name, link.url));
619
+ const open = settings.openBrowser === false ? undefined : typeof settings.openBrowser === "function" ? settings.openBrowser : openBrowser;
620
+ for (const link of result.links) {
621
+ const opened = open ? await open(link.url).catch(() => false) : false;
622
+ notice(opened ? t().extensionSignInOpened(name) : t().extensionSignInOpen(name));
623
+ // Alone on its line: a view shows it whole (a terminal link, #72), and it's copied whole.
624
+ notice(link.url);
625
+ }
617
626
  notice(t().extensionSignInPaste(name));
618
627
  // Waits in the background: the person signs in in their browser, whenever they do.
619
628
  void auth.connected(name, 5 * 60_000).then((done) => {
@@ -219,8 +219,8 @@ export interface ExtensionGateSpec {
219
219
  /** `false` turns the gate off: extensions' tools run without asking, as the model decides. */
220
220
  enabled?: boolean;
221
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`.
222
+ * Tools that always ask, on every call and even inside a batch the person approved (never
223
+ * offered "this session" or "always"), by extension and short tool name (`{ canva: ["create-design"] }`): added to its manifest's `confirmTools`.
224
224
  */
225
225
  confirmTools?: Record<string, string[]>;
226
226
  /** In autonomous mode, where no one can be asked: `"deny"` (the default) blocks those tools, `"allow"` runs them. */
@@ -71,6 +71,8 @@ export interface ExtensionContribution {
71
71
  confirmTools?: string[];
72
72
  /** Its MCP servers whose tools that always ask guided mode learns from their `destructiveHint`: an installed extension whose manifest lists no `confirmTools`. */
73
73
  confirmHintServers?: string[];
74
+ /** Its MCP tools whose server asks the person itself (MCP elicitation), by full name: guided mode doesn't ask too (#77). */
75
+ serverConfirmTools?: string[];
74
76
  /**
75
77
  * Its MCP servers that are remote (#61), by session name: the SDK fixes their URL and headers
76
78
  * when the session opens, so a changed option takes opening it again, and they may need the
@@ -58,6 +58,8 @@ export declare function remoteHosts(manifest: Pick<ExternalManifest, "servers">)
58
58
  export interface ExternalToolLabel {
59
59
  label: string;
60
60
  phrase?: ToolPhrase;
61
+ /** The line under the call when it succeeded, instead of its result's first line, written for the model (#79). */
62
+ result?: string;
61
63
  }
62
64
  /** Who made an extension, as Claude Code's `plugin.json` has it. */
63
65
  export interface ExtensionAuthor {
@@ -102,11 +104,16 @@ export interface ExternalManifest {
102
104
  */
103
105
  readOnlyTools?: string[];
104
106
  /**
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.
107
+ * Its tools that always ask the person in guided mode (short names), on every call and even
108
+ * inside a batch they approved, never offering "this session" or "always" (#76, #78): what
109
+ * creates, edits or publishes in their account. Without the key, its servers' `destructiveHint` says which.
108
110
  */
109
111
  confirmTools?: string[];
112
+ /**
113
+ * Its tools whose server asks the person itself before acting, through MCP elicitation (short
114
+ * names, #77): guided mode doesn't ask too while the person can be asked, so it's one question.
115
+ */
116
+ serverConfirmTools?: string[];
110
117
  /** Its tools' chat labels, by short name (of any of its servers) and language (English when the kit's isn't there). */
111
118
  labels: Record<string, Partial<Record<Language, ExternalToolLabel>>>;
112
119
  /** What it says about itself in a session, for the agent to tell the person (`SessionFacts`). */
@@ -141,6 +141,7 @@ export async function readExternalManifest(dir) {
141
141
  servers: await pluginServers(dir, raw),
142
142
  ...(Array.isArray(kit.readOnlyTools) ? { readOnlyTools: kit.readOnlyTools.filter((tool) => typeof tool === "string") } : {}),
143
143
  ...(Array.isArray(kit.confirmTools) ? { confirmTools: kit.confirmTools.filter((tool) => typeof tool === "string") } : {}),
144
+ ...(Array.isArray(kit.serverConfirmTools) ? { serverConfirmTools: kit.serverConfirmTools.filter((tool) => typeof tool === "string") } : {}),
144
145
  labels: kit.labels ?? {},
145
146
  ...(kit.help ? { help: kit.help } : {}),
146
147
  userConfig: parseUserConfig(raw.userConfig),
@@ -367,7 +368,12 @@ function manifestToolLabels(manifest, sessionServers) {
367
368
  const texts = byLanguage[language] ?? byLanguage.en;
368
369
  if (!texts)
369
370
  return [];
370
- const label = { label: (input) => fill(texts.label, input), ...(texts.phrase ? { phrase: texts.phrase } : {}) };
371
+ const label = {
372
+ label: (input) => fill(texts.label, input),
373
+ ...(texts.phrase ? { phrase: texts.phrase } : {}),
374
+ // In the person's language when it succeeded; a failure keeps its own line (#79).
375
+ ...(texts.result ? { result: (result) => (result.isError ? undefined : texts.result) } : {}),
376
+ };
371
377
  return sessionServers.map((server) => [`mcp__${server}__${tool}`, label]);
372
378
  }));
373
379
  }
@@ -429,6 +435,8 @@ function externalExtension(installed, manifest, cache, unavailable = []) {
429
435
  ...(manifest.confirmTools
430
436
  ? { confirmTools: Object.keys(servers).flatMap((server) => manifest.confirmTools.map((tool) => `mcp__${server}__${tool}`)) }
431
437
  : { confirmHintServers: Object.keys(servers) }),
438
+ // And those whose server asks the person itself (#77).
439
+ ...(manifest.serverConfirmTools ? { serverConfirmTools: Object.keys(servers).flatMap((server) => manifest.serverConfirmTools.map((tool) => `mcp__${server}__${tool}`)) } : {}),
432
440
  toolLabels: manifestToolLabels(manifest, Object.keys(servers)),
433
441
  ...(remote.length ? { remoteServers: remote } : {}),
434
442
  ...(manifest.help ? { helpLines: [manifest.help] } : {}),
@@ -10,12 +10,14 @@ export interface GuardedExtension {
10
10
  servers: readonly string[];
11
11
  /** Its tools that always ask (full names): its manifest's `confirmTools` and the agent's own list. */
12
12
  confirmTools: readonly string[];
13
+ /** Its tools whose server asks the person itself (full names, the manifest's `serverConfirmTools`, #77). */
14
+ serverConfirmTools?: readonly string[];
13
15
  }
14
16
  /** One answer the gate gave, for the transcript: what was asked, and what the person (or the mode) said. */
15
17
  export interface GuidedDecision {
16
18
  extension: string;
17
19
  tool: string;
18
- answer: "yes" | "session" | "always" | "no" | "never" | "kept-never" | "autonomous-deny";
20
+ answer: "yes" | "session" | "always" | "no" | "never" | "kept-never" | "autonomous-deny" | "server-asks";
19
21
  }
20
22
  /** The guided gate: its `PreToolUse` hook, and what ends or starts an approved batch. */
21
23
  export interface GuidedGate {
@@ -37,8 +39,10 @@ export interface GuidedGate {
37
39
  * this session, always (kept in the extension's lock), no, never (kept too). Right after
38
40
  * `request_human_approval` is approved, calls go on until the person's next message (a batch),
39
41
  * 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
+ * `destructiveHint`, asked on every call, so they're offered only yes, no and never (#78). An extension whose lock says `off`
43
+ * runs without asking. A tool whose server asks the person itself (`serverConfirmTools`, #77)
44
+ * isn't asked about too in guided mode, where that question reaches the person: one question,
45
+ * the server's. Approving gives no decision of its own, so the other gates still judge.
42
46
  */
43
47
  export declare function createGuidedGate(options: {
44
48
  runDir: string;
@@ -15,8 +15,10 @@ const APPROVAL_TOOL = "mcp__approvals__request_human_approval";
15
15
  * this session, always (kept in the extension's lock), no, never (kept too). Right after
16
16
  * `request_human_approval` is approved, calls go on until the person's next message (a batch),
17
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.
18
+ * `destructiveHint`, asked on every call, so they're offered only yes, no and never (#78). An extension whose lock says `off`
19
+ * runs without asking. A tool whose server asks the person itself (`serverConfirmTools`, #77)
20
+ * isn't asked about too in guided mode, where that question reaches the person: one question,
21
+ * the server's. Approving gives no decision of its own, so the other gates still judge.
20
22
  */
21
23
  export function createGuidedGate(options) {
22
24
  const thisSession = new Set();
@@ -47,10 +49,14 @@ export function createGuidedGate(options) {
47
49
  await record("kept-never");
48
50
  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
51
  }
50
- const alwaysAsks = extension.confirmTools.includes(full) || (await options.isDestructive(full));
51
- if (kept === "always" && !alwaysAsks)
52
+ // Its server asks the person itself, and in guided mode its question reaches them (#77).
53
+ if (mode === "guided" && extension.serverConfirmTools?.includes(full)) {
54
+ await record("server-asks");
52
55
  return {};
53
- if (thisSession.has(full))
56
+ }
57
+ const alwaysAsks = extension.confirmTools.includes(full) || (await options.isDestructive(full));
58
+ // A tool that always asks is asked on every call (#78): no kept "always", no "this session".
59
+ if (!alwaysAsks && (kept === "always" || thisSession.has(full)))
54
60
  return {};
55
61
  if (mode === "autonomous") {
56
62
  if (options.autonomous === "allow")
@@ -61,7 +67,8 @@ export function createGuidedGate(options) {
61
67
  if (batch && !alwaysAsks)
62
68
  return {};
63
69
  const answers = t().confirmAnswers;
64
- const choices = [answers.yes, answers.session, ...(alwaysAsks ? [] : [answers.always]), answers.no, answers.never];
70
+ // For one that always asks, only what covers this call, or makes it stricter (#78).
71
+ const choices = alwaysAsks ? [answers.yes, answers.no, answers.never] : [answers.yes, answers.session, answers.always, answers.no, answers.never];
65
72
  const label = options.labels?.()[full]?.label((pre.tool_input ?? {}));
66
73
  const answer = await askForChoice(options.runDir, {
67
74
  title: t().guidedTitle(extension.name),
@@ -53,7 +53,8 @@ export const de = {
53
53
  extensionLacks: (name, what) => `(${name}: ${what})`,
54
54
  extensionTalksTo: (name, hosts) => `(${name} spricht mit ${hosts}: was dein Agent dorthin sendet, verlässt diesen Computer)`,
55
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)`,
56
+ extensionSignInOpen: (name) => `(zum Anmelden bei ${name} öffne diesen Link; sobald du dort fertig bist, ist sie hier verbunden:)`,
57
+ extensionSignInOpened: (name) => `(Anmeldung bei ${name} in deinem Browser; sobald du dort fertig bist, ist sie hier verbunden. Der Link, für einen anderen Browser:)`,
57
58
  extensionSignInPaste: (name) => `(auf einem anderen Computer? Füge die Adresse ein, bei der der Browser landet: /extensions sign-in ${name} <diese Adresse>)`,
58
59
  extensionSignedIn: (name) => `(bei ${name} angemeldet: ihre Werkzeuge sind bereit)`,
59
60
  extensionSignInWaiting: (name) => `(warte noch auf die Anmeldung bei ${name}; /extensions sign-in ${name} gibt den Link erneut)`,
@@ -73,7 +73,9 @@ export interface Messages {
73
73
  /** In /extensions: a running extension whose remote server needs the person to sign in. */
74
74
  extensionNeedsSignIn(name: string): string;
75
75
  /** The link to sign in to an extension's remote server. */
76
- extensionSignInOpen(name: string, url: string): string;
76
+ /** Before the sign-in link, on its own line next (#72): when the browser couldn't be opened, and when it was. */
77
+ extensionSignInOpen(name: string): string;
78
+ extensionSignInOpened(name: string): string;
77
79
  /** How to finish signing in from another computer. */
78
80
  extensionSignInPaste(name: string): string;
79
81
  extensionSignedIn(name: string): string;
@@ -53,7 +53,8 @@ export const en = {
53
53
  extensionLacks: (name, what) => `(${name} ${what})`,
54
54
  extensionTalksTo: (name, hosts) => `(${name} talks to ${hosts}: what your agent sends there leaves this computer)`,
55
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)`,
56
+ extensionSignInOpen: (name) => `(to sign in to ${name}, open this link; when you're done there, it's connected here:)`,
57
+ extensionSignInOpened: (name) => `(signing in to ${name} in your browser; when you're done there, it's connected here. The link, for another browser:)`,
57
58
  extensionSignInPaste: (name) => `(on another computer? Paste the address the browser ends on: /extensions sign-in ${name} <that address>)`,
58
59
  extensionSignedIn: (name) => `(signed in to ${name}: its tools are ready)`,
59
60
  extensionSignInWaiting: (name) => `(still waiting for the sign-in to ${name}; /extensions sign-in ${name} gives the link again)`,
@@ -53,7 +53,8 @@ export const es = {
53
53
  extensionLacks: (name, what) => `(${name}: ${what})`,
54
54
  extensionTalksTo: (name, hosts) => `(${name} habla con ${hosts}: lo que tu agente le envíe sale de este ordenador)`,
55
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í)`,
56
+ extensionSignInOpen: (name) => `(para iniciar sesión en ${name}, abre este enlace; cuando termines allí, queda conectada aquí:)`,
57
+ extensionSignInOpened: (name) => `(iniciando sesión en ${name} en tu navegador; cuando termines allí, queda conectada aquí. El enlace, para otro navegador:)`,
57
58
  extensionSignInPaste: (name) => `(¿en otro ordenador? Pega la dirección en la que acaba el navegador: /extensions sign-in ${name} <esa dirección>)`,
58
59
  extensionSignedIn: (name) => `(sesión iniciada en ${name}: sus herramientas están listas)`,
59
60
  extensionSignInWaiting: (name) => `(sigo esperando el inicio de sesión en ${name}; /extensions sign-in ${name} vuelve a dar el enlace)`,
@@ -53,7 +53,8 @@ export const fr = {
53
53
  extensionLacks: (name, what) => `(${name} : ${what})`,
54
54
  extensionTalksTo: (name, hosts) => `(${name} échange avec ${hosts} : ce que votre agent y envoie quitte cet ordinateur)`,
55
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)`,
56
+ extensionSignInOpen: (name) => `(pour vous connecter à ${name}, ouvrez ce lien ; une fois terminé là-bas, elle est connectée ici :)`,
57
+ extensionSignInOpened: (name) => `(connexion à ${name} dans votre navigateur ; une fois terminé là-bas, elle est connectée ici. Le lien, pour un autre navigateur :)`,
57
58
  extensionSignInPaste: (name) => `(sur un autre ordinateur ? Collez l'adresse où le navigateur arrive : /extensions sign-in ${name} <cette adresse>)`,
58
59
  extensionSignedIn: (name) => `(connecté à ${name} : ses outils sont prêts)`,
59
60
  extensionSignInWaiting: (name) => `(toujours en attente de la connexion à ${name} ; /extensions sign-in ${name} redonne le lien)`,
@@ -195,7 +195,7 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
195
195
  return [];
196
196
  const servers = Object.keys(added[index]?.mcpServers ?? {});
197
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] }];
198
+ return [{ name: extension.name, scopeDir: installed.scopeDir, servers, confirmTools: [...(added[index]?.confirmTools ?? []), ...own], serverConfirmTools: added[index]?.serverConfirmTools ?? [] }];
199
199
  });
200
200
  const neverTools = (await Promise.all(guardedExtensions.map(async (extension) => {
201
201
  const confirm = (await readLock(extension.scopeDir).catch(() => ({})))[extension.name]?.confirm;
@@ -22,7 +22,23 @@ export interface ToolLabel {
22
22
  label(input: Record<string, unknown>): string;
23
23
  /** How it counts in a folded group's summary, `[one, many]` with `{n}`: `["read {n} page", "read {n} pages"]`. */
24
24
  phrase?: ToolPhrase;
25
+ /**
26
+ * The line under it for its result, in the kit's language (#79): a string replaces the result's
27
+ * first line (written for the model, in English), `null` hides it, `undefined` keeps it. The
28
+ * model's result is never changed.
29
+ */
30
+ result?(result: ToolResult): string | null | undefined;
25
31
  }
32
+ /** A tool call's result, as the chat shows it. */
33
+ export interface ToolResult {
34
+ isError: boolean;
35
+ text: string;
36
+ }
37
+ /**
38
+ * Labels whose result line is hidden when the call succeeded (#79): for tools whose label already
39
+ * says what happened, so the person doesn't read the model's English under it. A failure still shows.
40
+ */
41
+ export declare function quietResults(labels: ToolLabels): ToolLabels;
26
42
  /** Tools' labels by full name (`mcp__<server>__<tool>`): what the extensions of a session bring. */
27
43
  export type ToolLabels = Readonly<Record<string, ToolLabel>>;
28
44
  /**
@@ -33,6 +49,11 @@ export type ToolLabels = Readonly<Record<string, ToolLabel>>;
33
49
  export declare function withToolLabels(labels: () => ToolLabels | undefined, formatAction?: (toolName: string, toolInput: unknown) => string): (toolName: string, toolInput: unknown) => string;
34
50
  /** A `toolPhrase` that counts the tools in `labels()` with their phrase, and any other with `toolPhrase`. */
35
51
  export declare function withToolPhrases(labels: () => ToolLabels | undefined, toolPhrase?: (toolName: string) => ToolPhrase | undefined): (toolName: string) => ToolPhrase | undefined;
52
+ /**
53
+ * A result formatter (the Ink chat's `formatResult`) that asks `formatResult` first, then the
54
+ * tools' labels (`ToolLabel.result`, #79): `undefined` from both keeps the result's first line.
55
+ */
56
+ export declare function withToolResults(labels: () => ToolLabels | undefined, formatResult?: (toolName: string, result: ToolResult) => string | null | undefined): (toolName: string, result: ToolResult) => string | null | undefined;
36
57
  /** An agent's labels for its own tools: the tool's short name and input, to a label, or `undefined` for the kit's default. */
37
58
  export type ToolDescriber = (shortName: string, input: Record<string, unknown>) => string | undefined;
38
59
  /**
@@ -71,6 +71,13 @@ function describeCore(shortName, input) {
71
71
  return undefined;
72
72
  }
73
73
  }
74
+ /**
75
+ * Labels whose result line is hidden when the call succeeded (#79): for tools whose label already
76
+ * says what happened, so the person doesn't read the model's English under it. A failure still shows.
77
+ */
78
+ export function quietResults(labels) {
79
+ return Object.fromEntries(Object.entries(labels).map(([tool, label]) => [tool, { ...label, result: (result) => (result.isError ? undefined : null) }]));
80
+ }
74
81
  /**
75
82
  * A `formatAction` that shows the tools in `labels()` with their label, and any other with
76
83
  * `formatAction` (the kit's by default). `labels` is read on every call, so it can follow the
@@ -88,6 +95,16 @@ export function withToolLabels(labels, formatAction = createFriendlyToolLabel())
88
95
  export function withToolPhrases(labels, toolPhrase) {
89
96
  return (toolName) => labels()?.[toolName]?.phrase ?? toolPhrase?.(toolName);
90
97
  }
98
+ /**
99
+ * A result formatter (the Ink chat's `formatResult`) that asks `formatResult` first, then the
100
+ * tools' labels (`ToolLabel.result`, #79): `undefined` from both keeps the result's first line.
101
+ */
102
+ export function withToolResults(labels, formatResult) {
103
+ return (toolName, result) => {
104
+ const own = formatResult?.(toolName, result);
105
+ return own !== undefined ? own : labels()?.[toolName]?.result?.(result);
106
+ };
107
+ }
91
108
  /**
92
109
  * Builds a `friendlyToolLabel(toolName, toolInput)` — `describe` lets the host agent
93
110
  * layer its own domain-specific cases (e.g. Playwright's browser_* tools) on top of this
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
+ import { quietResults } from "../../core/toolLabels.js";
3
4
  import { agentKitVersion } from "../../core/version.js";
4
5
  import { awarenessToolLabels } from "./labels.js";
5
6
  import { createAwarenessServer } from "./tools.js";
@@ -44,7 +45,8 @@ export function awareness(options = {}) {
44
45
  mcpServers: { awareness: createAwarenessServer(session, options.guide, catalog ? [createCatalogTool(config, session)] : []) },
45
46
  promptSection: identityPromptSection(spec.identity, { catalog }),
46
47
  readOnlyTools: ["mcp__awareness__about_me", ...(catalog ? ["mcp__awareness__extensions_catalog"] : [])],
47
- toolLabels: awarenessToolLabels(),
48
+ // Their labels already say what happened: no English result line under them (#79).
49
+ toolLabels: quietResults(awarenessToolLabels()),
48
50
  };
49
51
  },
50
52
  };
@@ -1,3 +1,4 @@
1
+ import { quietResults } from "../../core/toolLabels.js";
1
2
  import { folderOf } from "../../core/extensions.js";
2
3
  import { createFileKnowledgeStore } from "./fileKnowledgeStore.js";
3
4
  import { knowledgeToolLabels } from "./labels.js";
@@ -27,7 +28,8 @@ export function knowledge(options) {
27
28
  toolOnlyDirs: [{ dir: knowledgeDir, instead: "the knowledge_* tools" }],
28
29
  readOnlyTools: ["mcp__knowledge__knowledge_index", "mcp__knowledge__knowledge_search", "mcp__knowledge__knowledge_read", "mcp__knowledge__knowledge_check"],
29
30
  selfAskingTools: ["mcp__knowledge__knowledge_retire"],
30
- toolLabels: knowledgeToolLabels(),
31
+ // Their labels already say what happened: no English result line under them (#79).
32
+ toolLabels: quietResults(knowledgeToolLabels()),
31
33
  helpLines: [
32
34
  "You keep a knowledge base (your memory across sessions). Its commands: `/knowledge:ingest` (learn the new or changed files of the sources folder), `/knowledge:query <question>` (answer from it), `/knowledge:lint` (check it and fix what's mechanical).",
33
35
  ],
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
+ import { quietResults } from "../../core/toolLabels.js";
3
4
  import { folderOf } from "../../core/extensions.js";
4
5
  import { readConversation } from "../../core/runs.js";
5
6
  import { memoryToolLabels } from "./labels.js";
@@ -49,7 +50,8 @@ export function memory(options) {
49
50
  toolOnlyDirs: [{ dir: memoryDir, instead: "its tools: recall, remember, forget" }],
50
51
  readOnlyTools: ["mcp__memory__recall"],
51
52
  hooks: { UserPromptSubmit: [{ hooks: [hear] }] },
52
- toolLabels: memoryToolLabels(),
53
+ // Their labels already say what happened: no English result line under them (#79).
54
+ toolLabels: quietResults(memoryToolLabels()),
53
55
  helpLines: [
54
56
  "You keep a memory of the person across all their projects (how they like things done, who they are): `/memory:list` shows it, `/memory:forget <entry>` forgets an entry. Only what they tell you goes in it.",
55
57
  ],
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
+ import { quietResults } from "../../core/toolLabels.js";
3
4
  import { folderOf } from "../../core/extensions.js";
4
5
  import { sourcesToolLabels } from "./labels.js";
5
6
  import { addSource } from "./sources.js";
@@ -29,7 +30,8 @@ export function sources(options) {
29
30
  readOnlyDirs: [sourcesDir],
30
31
  readOnlyTools: ["mcp__sourceFiles__list_sources", "mcp__sourceFiles__extract_text"],
31
32
  selfAskingTools: ["mcp__sourceFiles__request_file", "mcp__sourceFiles__retire_source"],
32
- toolLabels: sourcesToolLabels(),
33
+ // Their labels already say what happened: no English result line under them (#79).
34
+ toolLabels: quietResults(sourcesToolLabels()),
33
35
  // For the host (a page's "add to the chest"): a file the person gave, with the same rules
34
36
  // as the tools (never overwriting, a duplicate recognized) and the person as its origin.
35
37
  api: {
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@ export { allowAnyMcpTool } from "./core/mcpPermissions.js";
5
5
  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
- export { createFriendlyToolLabel, truncate, truncatePath, withToolLabels, withToolPhrases, type ToolDescriber, type ToolLabel, type ToolLabels, } from "./core/toolLabels.js";
8
+ export { createFriendlyToolLabel, truncate, truncatePath, withToolLabels, withToolPhrases, withToolResults, quietResults, type ToolResult, type ToolDescriber, type ToolLabel, type ToolLabels, } from "./core/toolLabels.js";
9
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";
@@ -52,6 +52,7 @@ export { createSaveToSourcesServer, sourcesPromptSection, type SourceToolsOption
52
52
  export { runChatTui, type ChatTuiOptions } from "./tui/chatTui.js";
53
53
  export type { SessionOpener } from "./chat/runs.js";
54
54
  export { createChatController, type ChatController, type ChatSettings, type ChatState, type ChatEvent, type ChatPanel, type ChatQuestion, type TranscriptEntry, type TranscriptCall, type NoticeTone, } from "./chat/chatController.js";
55
+ export { openBrowser, canOpenBrowser } from "./core/openBrowser.js";
55
56
  export { createConsoleRenderer, type ConsoleRenderer, type ConsoleRendererOptions } from "./tui/consoleRenderer.js";
56
57
  export { runChatInk, type InkChatOptions, type HeaderInfo } from "./tui/ink/runChatInk.js";
57
58
  export type { ToolPhrase, ToolDetail, ResultFormatter } from "./tui/ink/toolGroup.js";
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ export { allowAnyMcpTool } from "./core/mcpPermissions.js";
7
7
  export { createPromptLoader } from "./core/promptTemplate.js";
8
8
  export { resolveClaudeAuth } from "./core/claudeAuth.js";
9
9
  export { ensureClaudeAuth } from "./tui/claudeAuth.js";
10
- export { createFriendlyToolLabel, truncate, truncatePath, withToolLabels, withToolPhrases, } from "./core/toolLabels.js";
10
+ export { createFriendlyToolLabel, truncate, truncatePath, withToolLabels, withToolPhrases, withToolResults, quietResults, } from "./core/toolLabels.js";
11
11
  export { buildSessionOptions, createInputQueue, createDeferred, createModeControl, togglePlanMode } from "./core/session.js";
12
12
  export { runQuery } from "./core/runner.js";
13
13
  export { knowledgePluginRoot, knowledgePromptSection } from "./extensions/knowledge/prompt.js";
@@ -53,6 +53,7 @@ export { createManualLoginServer } from "./core/tools/manualLogin.js";
53
53
  export { createSaveToSourcesServer, sourcesPromptSection } from "./extensions/sources/tools.js";
54
54
  export { runChatTui } from "./tui/chatTui.js";
55
55
  export { createChatController, } from "./chat/chatController.js";
56
+ export { openBrowser, canOpenBrowser } from "./core/openBrowser.js";
56
57
  export { createConsoleRenderer } from "./tui/consoleRenderer.js";
57
58
  export { runChatInk } from "./tui/ink/runChatInk.js";
58
59
  export { createProgressView } from "./tui/ink/progressView.js";
@@ -4,6 +4,7 @@ import type { AgentEvent } from "../core/runner.js";
4
4
  import { type Theme } from "./theme.js";
5
5
  import type { ToolLabels } from "../core/toolLabels.js";
6
6
  import { type SessionOpener } from "../chat/runs.js";
7
+ import { type ChatSettings } from "../chat/chatController.js";
7
8
  export { capHistory, loadHistory, saveHistory, slashCommandToken } from "../chat/history.js";
8
9
  /** Options of the chats (`runChatTui()`, and `runChatInk()` through `InkChatOptions`). */
9
10
  export interface ChatTuiOptions {
@@ -14,6 +15,8 @@ export interface ChatTuiOptions {
14
15
  * own domain-specific tools (Playwright's `browser_*`, etc.).
15
16
  */
16
17
  formatAction?: (toolName: string, input: unknown) => string;
18
+ /** Signing in to an extension: whether its link opens in this computer's browser (the default), or how (see `ChatSettings.openBrowser`, #72). */
19
+ openBrowser?: ChatSettings["openBrowser"];
17
20
  /** Printed once before the first prompt, if given. */
18
21
  welcomeMessage?: string;
19
22
  /** Shown before the cursor at each prompt. */
@@ -8,6 +8,7 @@ import { applyTheme } from "./theme.js";
8
8
  import { createConsoleRenderer } from "./consoleRenderer.js";
9
9
  import { runDate } from "../chat/runs.js";
10
10
  import { createChatController } from "../chat/chatController.js";
11
+ import { linkedNotice } from "./hyperlink.js";
11
12
  export { capHistory, loadHistory, saveHistory, slashCommandToken } from "../chat/history.js";
12
13
  const DEFAULT_PROMPT_LABEL = "\n> ";
13
14
  const DEFAULT_EXIT_COMMANDS = ["/exit", "/quit"];
@@ -75,6 +76,7 @@ export async function runChatTui(options, tuiOptions = {}) {
75
76
  modeControl: tuiOptions.modeControl,
76
77
  toolLabels: tuiOptions.toolLabels,
77
78
  formatAction: tuiOptions.formatAction,
79
+ ...(tuiOptions.openBrowser !== undefined ? { openBrowser: tuiOptions.openBrowser } : {}),
78
80
  // Nothing here shows them.
79
81
  promptSuggestions: false,
80
82
  // /resume: a numbered list, and a number typed.
@@ -131,7 +133,8 @@ export async function runChatTui(options, tuiOptions = {}) {
131
133
  renderer.render(event.event);
132
134
  return;
133
135
  case "notice":
134
- writeLine(toned(event.text, event.tone));
136
+ // An address alone (a sign-in link) as a terminal link (#72).
137
+ writeLine(toned(linkedNotice(event.text), event.tone));
135
138
  return;
136
139
  case "turn-start":
137
140
  renderer.startTurn();
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Terminal hyperlinks (OSC 8, #72): a long address the full-screen chat wraps stays one link,
3
+ * since every row it's cut into opens it whole (`wrap-ansi` reopens the link on each row).
4
+ */
5
+ /**
6
+ * Whether the terminal shows OSC 8 links, from what it says about itself: Windows Terminal,
7
+ * VS Code's, iTerm2, WezTerm, Konsole, a recent VTE (GNOME's), DomTerm. `FORCE_HYPERLINK=1` or `0`
8
+ * decides. Elsewhere none: a terminal that doesn't know the sequence may print it.
9
+ */
10
+ export declare function supportsHyperlinks(env?: NodeJS.ProcessEnv, isTTY?: boolean): boolean;
11
+ /** `text` as a link to `url` (OSC 8). */
12
+ export declare function hyperlink(url: string, text?: string): string;
13
+ /** A notice that is an address alone (a sign-in link, #72) as a link when the terminal shows them; any other text as it is. */
14
+ export declare function linkedNotice(text: string, linked?: boolean): string;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Terminal hyperlinks (OSC 8, #72): a long address the full-screen chat wraps stays one link,
3
+ * since every row it's cut into opens it whole (`wrap-ansi` reopens the link on each row).
4
+ */
5
+ /**
6
+ * Whether the terminal shows OSC 8 links, from what it says about itself: Windows Terminal,
7
+ * VS Code's, iTerm2, WezTerm, Konsole, a recent VTE (GNOME's), DomTerm. `FORCE_HYPERLINK=1` or `0`
8
+ * decides. Elsewhere none: a terminal that doesn't know the sequence may print it.
9
+ */
10
+ export function supportsHyperlinks(env = process.env, isTTY = process.stdout.isTTY === true) {
11
+ if (env.FORCE_HYPERLINK !== undefined)
12
+ return env.FORCE_HYPERLINK !== "0";
13
+ if (!isTTY)
14
+ return false;
15
+ if (env.WT_SESSION || env.KONSOLE_VERSION || env.DOMTERM)
16
+ return true;
17
+ if (["vscode", "iTerm.app", "WezTerm"].includes(env.TERM_PROGRAM ?? ""))
18
+ return true;
19
+ return Number(env.VTE_VERSION ?? 0) >= 5000;
20
+ }
21
+ /** `text` as a link to `url` (OSC 8). */
22
+ export function hyperlink(url, text = url) {
23
+ return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
24
+ }
25
+ /** A notice that is an address alone (a sign-in link, #72) as a link when the terminal shows them; any other text as it is. */
26
+ export function linkedNotice(text, linked = supportsHyperlinks()) {
27
+ return linked && /^https?:\/\/\S+$/.test(text) ? hyperlink(text) : text;
28
+ }
@@ -6,6 +6,7 @@ import { Select } from "@inkjs/ui";
6
6
  import { getInteractionPort, setInteractionPort } from "../../core/interaction.js";
7
7
  import { runChatTui } from "../chatTui.js";
8
8
  import { createChatController } from "../../chat/chatController.js";
9
+ import { linkedNotice } from "../hyperlink.js";
9
10
  import * as ui from "../ui.js";
10
11
  import { t } from "../../core/messages/index.js";
11
12
  import { applyLanguage } from "../language.js";
@@ -197,6 +198,8 @@ export async function runChatInk(options, tuiOptions = {}) {
197
198
  toolLabels: tuiOptions.toolLabels,
198
199
  formatAction: tuiOptions.formatAction,
199
200
  toolPhrase: tuiOptions.toolPhrase,
201
+ formatResult: tuiOptions.formatResult,
202
+ ...(tuiOptions.openBrowser !== undefined ? { openBrowser: tuiOptions.openBrowser } : {}),
200
203
  promptSuggestions: tuiOptions.promptSuggestions ?? true,
201
204
  // The /resume list in place of the prompt.
202
205
  pick: (title, choices) => input.pick(title, choices),
@@ -219,7 +222,8 @@ export async function runChatInk(options, tuiOptions = {}) {
219
222
  formatAction: controller.formatAction,
220
223
  toolPhrase: controller.toolPhrase,
221
224
  toolDetail: tuiOptions.toolDetail,
222
- formatResult: tuiOptions.formatResult,
225
+ // The agent's, then the labels' (#79).
226
+ formatResult: controller.formatResult,
223
227
  agentLabel: tuiOptions.agentLabel,
224
228
  width: () => liveWidth(stdout.columns),
225
229
  });
@@ -241,7 +245,8 @@ export async function runChatInk(options, tuiOptions = {}) {
241
245
  model.note(userLine(event.text), "user");
242
246
  return;
243
247
  case "notice":
244
- model.writeLine(toned(event.text, event.tone));
248
+ // An address alone (a sign-in link) as a terminal link, whole however it wraps (#72).
249
+ model.writeLine(toned(linkedNotice(event.text), event.tone));
245
250
  return;
246
251
  case "turn-start":
247
252
  input.setSuggestion(null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@falkenslab/agent-kit",
3
- "version": "0.27.1",
3
+ "version": "0.29.0",
4
4
  "description": "Library for building an agent on top of the Claude Agent SDK",
5
5
  "author": "Francisco Vargas Ruiz",
6
6
  "license": "MIT",
@@ -35,7 +35,12 @@
35
35
  "items": { "type": "string", "minLength": 1 }
36
36
  },
37
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.",
38
+ "description": "Its tools that always ask the person in guided mode (short names), on every call, even inside a batch they approved, never offered \"this session\" or \"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
+ },
42
+ "serverConfirmTools": {
43
+ "description": "Its tools whose server asks the person itself before acting, through MCP elicitation (short names): guided mode doesn't ask too while the person can be asked, so it's one question, the server's. Plan mode still stops them; autonomous mode still denies them unless the agent allows them.",
39
44
  "type": "array",
40
45
  "items": { "type": "string", "minLength": 1 }
41
46
  },
@@ -57,6 +62,11 @@
57
62
  "prefixItems": [{ "type": "string" }, { "type": "string" }],
58
63
  "minItems": 2,
59
64
  "maxItems": 2
65
+ },
66
+ "result": {
67
+ "description": "The line under the call when it succeeded, in this language, instead of the result's first line (written for the model, in English). A failure still shows its own.",
68
+ "type": "string",
69
+ "minLength": 1
60
70
  }
61
71
  }
62
72
  }