agent-sh 0.15.1 → 0.15.3

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.
@@ -528,14 +528,15 @@ export class AgentLoop {
528
528
  // Advisable so extensions can inject fallback parsers without
529
529
  // subclassing the protocol.
530
530
  h.define("tool-protocol:extract-calls", (args) => this.toolProtocol.extractToolCalls(args.text, args.streamedCalls));
531
- // System prompt: static identity + behavioral instructions.
532
- // Extensions can use registerInstruction() for a managed section,
533
- // advise system-prompt:frontend to describe their surface high in the
534
- // prompt, or advise this handler directly for full control.
531
+ // System prompt: static identity + behavioral instructions. Extensions can
532
+ // use registerInstruction() for a managed section, advise system-prompt:identity
533
+ // to replace the kernel identity, advise system-prompt:frontend to describe their
534
+ // surface high in the prompt, or advise system-prompt:build directly for full control.
535
+ h.define("system-prompt:identity", () => STATIC_IDENTITY);
535
536
  h.define("system-prompt:build", () => {
536
537
  // The active frontend's surface goes right after the identity; omitted if none.
537
538
  const frontend = (this.handlers.call("system-prompt:frontend") ?? "").trim();
538
- const parts = [STATIC_IDENTITY];
539
+ const parts = [this.handlers.call("system-prompt:identity")];
539
540
  if (frontend)
540
541
  parts.push(frontend);
541
542
  parts.push(STATIC_GUIDE);
@@ -966,7 +967,7 @@ export class AgentLoop {
966
967
  // Execute via handler — extensions can advise to add safe-mode,
967
968
  // logging, metrics, custom permission policies, etc.
968
969
  const defaultOnChunk = (chunk) => {
969
- this.bus.emit("agent:tool-output-chunk", { chunk });
970
+ this.bus.emit("agent:tool-output-chunk", { chunk, toolCallId: tc.id });
970
971
  };
971
972
  const result = await this.handlers.call("tool:execute", { name: tc.name, id: tc.id, args, tool, onChunk: defaultOnChunk,
972
973
  batchIndex, batchTotal: batchTotal > 1 ? batchTotal : undefined,
@@ -1229,8 +1230,10 @@ export class AgentLoop {
1229
1230
  let reasoning = "";
1230
1231
  const reasoningDetailsByIndex = new Map();
1231
1232
  const pendingToolCalls = [];
1232
- // Tool protocol controls what goes in the API tools param vs dynamic context
1233
- const toolView = this.getTools();
1233
+ // Tool protocol controls what goes in the API tools param vs dynamic context.
1234
+ // agent:tools:visible is a filter point on the assembled list — distinct from
1235
+ // getTools(), which other code (e.g. tool bridges) needs unfiltered.
1236
+ const toolView = this.bus.emitPipe("agent:tools:visible", { tools: this.getTools() }).tools;
1234
1237
  const apiTools = this.toolProtocol.getApiTools(toolView);
1235
1238
  const toolPrompt = this.toolProtocol.getToolPrompt(toolView);
1236
1239
  // Dynamic context rides on the trailing message — see
@@ -1242,7 +1245,7 @@ export class AgentLoop {
1242
1245
  // Let extensions transform the message array (compact, summarize, filter, etc.)
1243
1246
  const messages = this.handlers.call("conversation:prepare", rawMessages);
1244
1247
  // Stream filter strips tool tags from display (inline mode only)
1245
- const streamFilter = this.toolProtocol.createStreamFilter(this.getTools().map((t) => t.name));
1248
+ const streamFilter = this.toolProtocol.createStreamFilter(toolView.map((t) => t.name));
1246
1249
  const requestParams = {
1247
1250
  messages,
1248
1251
  tools: apiTools,
@@ -30,6 +30,10 @@ declare module "../core/event-bus.js" {
30
30
  "agent:tools": {
31
31
  tools: ToolDefinition[];
32
32
  };
33
+ /** Filter point: the assembled tool list as the model will see it, after getTools(). */
34
+ "agent:tools:visible": {
35
+ tools: ToolDefinition[];
36
+ };
33
37
  "agent:instructions": {
34
38
  instructions: Array<{
35
39
  name: string;
@@ -131,6 +135,7 @@ declare module "../core/event-bus.js" {
131
135
  };
132
136
  "agent:tool-output-chunk": {
133
137
  chunk: string;
138
+ toolCallId?: string;
134
139
  };
135
140
  "tool:interactive-start": Record<string, never>;
136
141
  "tool:interactive-end": Record<string, never>;
@@ -89,11 +89,15 @@ export default function agentBackend(ctx) {
89
89
  const providerContribs = new Map();
90
90
  // Settings overlay — fields here win over contributing extensions' payloads.
91
91
  const settingsProviders = new Map();
92
- for (const name of getProviderNames()) {
93
- const p = resolveProvider(name);
94
- if (p)
95
- settingsProviders.set(name, p);
96
- }
92
+ const refreshSettingsProviders = () => {
93
+ settingsProviders.clear();
94
+ for (const name of getProviderNames()) {
95
+ const p = resolveProvider(name);
96
+ if (p)
97
+ settingsProviders.set(name, p);
98
+ }
99
+ };
100
+ refreshSettingsProviders();
97
101
  const providerHooks = new Map();
98
102
  // Bakes model id so ModelEndpoint.buildReasoningParams keeps its (level) signature.
99
103
  const bindReasoning = (shapeId, model) => {
@@ -341,6 +345,7 @@ export default function agentBackend(ctx) {
341
345
  let agentLoop = null;
342
346
  let loadedExtensionNames = [];
343
347
  bus.on("agent:providers:changed", () => {
348
+ refreshSettingsProviders();
344
349
  resolvedProviders = computeResolvedProviders();
345
350
  if (!resolved)
346
351
  return;
@@ -72,7 +72,7 @@ export async function runSubagent(opts) {
72
72
  });
73
73
  }
74
74
  const onChunk = bus && tool.showOutput !== false
75
- ? (chunk) => { bus.emit("agent:tool-output-chunk", { chunk }); }
75
+ ? (chunk) => { bus.emit("agent:tool-output-chunk", { chunk, toolCallId: tc.id }); }
76
76
  : undefined;
77
77
  const result = await tool.execute(args, onChunk);
78
78
  if (bus) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanyilun/ashi",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Ash in an interactive TUI — agent-sh's built-in agent without the shell underneath",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
@@ -44,8 +44,9 @@ export class ToolGroup {
44
44
  this.repaint();
45
45
  }
46
46
 
47
- toggleExpanded(): void {
48
- this.expanded = !this.expanded;
47
+ setExpanded(expanded: boolean): void {
48
+ if (this.expanded === expanded) return;
49
+ this.expanded = expanded;
49
50
  this.repaint();
50
51
  }
51
52
 
@@ -6,7 +6,7 @@ import { createCore } from "agent-sh/core";
6
6
  import { loadBuiltinExtensions } from "agent-sh/extensions";
7
7
  import { loadExtensions } from "agent-sh/extension-loader";
8
8
  import { activateAgent } from "agent-sh/agent";
9
- import { getSettings } from "agent-sh/settings";
9
+ import { getSettings, CONFIG_DIR } from "agent-sh/settings";
10
10
  import { Shell } from "agent-sh/shell";
11
11
  import { TerminalBuffer } from "agent-sh/utils/terminal-buffer";
12
12
  import type { Terminal } from "agent-sh/shell/terminal";
@@ -37,7 +37,6 @@ import { createPiTuiRenderer } from "./renderers/pi-tui/index.js";
37
37
  import type { Renderer } from "./renderer.js";
38
38
  import { loadRendererPreference } from "./display-config.js";
39
39
  import { applyOutputMode } from "./terminal-mode.js";
40
- import * as os from "node:os";
41
40
  import * as path from "node:path";
42
41
  import { fileURLToPath } from "node:url";
43
42
 
@@ -150,7 +149,7 @@ async function main(): Promise<void> {
150
149
 
151
150
  const cwd = process.cwd();
152
151
  const cwdSlug = cwd.replace(/\//g, "-").replace(/^-/, "");
153
- const sessionsDir = path.join(os.homedir(), ".agent-sh", "ashi", "history", cwdSlug, "sessions");
152
+ const sessionsDir = path.join(CONFIG_DIR, "ashi", "history", cwdSlug, "sessions");
154
153
  const resumeId = config.continueLast
155
154
  ? MultiSessionStore.readLastSessionId(sessionsDir, { fallbackToLatest: true })
156
155
  : undefined;
@@ -265,9 +264,10 @@ async function main(): Promise<void> {
265
264
  } else {
266
265
  // New-session only: skip on resume so a restored transcript isn't prefixed
267
266
  // with this. List user/installed extensions only — built-ins are always present.
268
- const userExtensions = [...new Set(loaded)];
269
- if (userExtensions.length > 0) {
270
- ctx.bus.emit("ui:info", { message: `extensions: ${userExtensions.join(" · ")}` });
267
+ const all = [...new Set(loaded)];
268
+ const shown = (core.bus.emitPipe("ashi:startup-extensions", { names: all }).names ?? []) as string[];
269
+ if (shown.length > 0) {
270
+ ctx.bus.emit("ui:info", { message: `extensions: ${shown.join(" · ")}` });
271
271
  }
272
272
  }
273
273
 
@@ -1,4 +1,4 @@
1
- import type { App, Renderer } from "./renderer.js";
1
+ import type { App, Renderer, RenderNode } from "./renderer.js";
2
2
  import { InfoLine } from "./chat/lines.js";
3
3
 
4
4
  export interface SelectChoice {
@@ -9,9 +9,11 @@ export interface SelectChoice {
9
9
  export interface SelectOpts {
10
10
  title?: string;
11
11
  items: SelectChoice[];
12
+ body?: string[] | ((width: number) => string[]);
12
13
  }
13
14
  export interface ConfirmOpts {
14
15
  title: string;
16
+ body?: string[] | ((width: number) => string[]);
15
17
  }
16
18
 
17
19
  export interface DialogGuard {
@@ -28,6 +30,16 @@ export function createDialogs(app: App, renderer: Renderer, guard: DialogGuard):
28
30
  const select = (opts: SelectOpts): Promise<string | undefined> => {
29
31
  if (guard.isOpen() || opts.items.length === 0) return Promise.resolve(undefined);
30
32
  return new Promise((resolve) => {
33
+ let bodyNode: RenderNode | null = null;
34
+ if (typeof opts.body === "function") {
35
+ const t = renderer.text();
36
+ t.setRenderFn(opts.body);
37
+ bodyNode = t.node;
38
+ } else if (opts.body && opts.body.length) {
39
+ const t = renderer.text();
40
+ t.setLines(opts.body);
41
+ bodyNode = t.node;
42
+ }
31
43
  const hint = new InfoLine(renderer, opts.title ?? "↑↓ move · enter: select · esc: cancel");
32
44
  const picker = app.createSelectList(
33
45
  opts.items.map((c) => ({ value: c.value, label: c.label, description: c.description })),
@@ -40,6 +52,7 @@ export function createDialogs(app: App, renderer: Renderer, guard: DialogGuard):
40
52
  guard.setOpen(false);
41
53
  app.footerSlot.removeChild(picker.node);
42
54
  app.footerSlot.removeChild(hint.node);
55
+ if (bodyNode) app.footerSlot.removeChild(bodyNode);
43
56
  app.focusInput();
44
57
  app.requestRender();
45
58
  resolve(result);
@@ -47,6 +60,7 @@ export function createDialogs(app: App, renderer: Renderer, guard: DialogGuard):
47
60
  picker.onSelect((item) => close(item.value));
48
61
  picker.onCancel(() => close());
49
62
  guard.setOpen(true);
63
+ if (bodyNode) app.footerSlot.addChild(bodyNode);
50
64
  app.footerSlot.addChild(hint.node);
51
65
  app.footerSlot.addChild(picker.node);
52
66
  app.setFocus(picker.node);
@@ -57,6 +71,7 @@ export function createDialogs(app: App, renderer: Renderer, guard: DialogGuard):
57
71
  const confirm = (opts: ConfirmOpts): Promise<boolean> =>
58
72
  select({
59
73
  title: opts.title,
74
+ body: opts.body,
60
75
  items: [
61
76
  { value: "yes", label: "Yes" },
62
77
  { value: "no", label: "No" },
@@ -12,5 +12,6 @@ declare module "agent-sh/event-bus" {
12
12
  "ashi:dock:above-input": { nodes: RenderNodes; views: RenderNode[] };
13
13
  "ashi:dock:invalidate": Record<string, never>;
14
14
  "ashi:ready": Record<string, never>;
15
+ "ashi:startup-extensions": { names: string[] };
15
16
  }
16
17
  }
@@ -36,6 +36,7 @@ import type { Capture, NestedDiff } from "./capture.js";
36
36
  import { execSync } from "node:child_process";
37
37
  import { readClipboardImage } from "./clipboard-image.js";
38
38
  import { renderDiff, detectLanguage, highlightLine } from "agent-sh/utils/diff-renderer.js";
39
+ import { computeDiff } from "agent-sh/utils/diff.js";
39
40
  import { renderBoxFrame } from "agent-sh/utils/box-frame.js";
40
41
 
41
42
  const GROUPABLE_KINDS = new Set(["read", "search"]);
@@ -217,6 +218,17 @@ export function mountAshi(
217
218
  const dialogs = createDialogs(app, renderer, modalGuard);
218
219
  ctx.define("ui:select", (opts: SelectOpts) => dialogs.select(opts));
219
220
  ctx.define("ui:confirm", (opts: ConfirmOpts) => dialogs.confirm(opts));
221
+ ctx.define(
222
+ "ui:diff",
223
+ (opts: { before?: string | null; after?: string; filePath?: string; boxed?: boolean }) => {
224
+ const diff = computeDiff(opts.before ?? null, opts.after ?? "");
225
+ return buildDiffRenderer(
226
+ diff as Parameters<typeof buildDiffRenderer>[0],
227
+ opts.filePath ?? "",
228
+ opts.boxed !== false,
229
+ );
230
+ },
231
+ );
220
232
  ctx.define("ui:input", (opts: InputOpts) => inputPrompt.prompt(opts));
221
233
  ctx.define("ui:editor:get-text", () => input.getText());
222
234
  ctx.define("ui:editor:set-text", (text: string) => { input.setText(text); });
@@ -306,6 +318,7 @@ export function mountAshi(
306
318
  | { t: "thinking"; ctrl: ThinkingBlock }
307
319
  | { t: "assistant"; ctrl: AssistantMessage }
308
320
  | { t: "pair"; result: ToolResultView }
321
+ | { t: "user" }
309
322
  | { t: "plain" };
310
323
  const chatEntries: ChatEntry[] = [];
311
324
  const appendEntry = (node: RenderNode, entry: ChatEntry): void => {
@@ -325,6 +338,14 @@ export function mountAshi(
325
338
  const activeTools = new Map<string, LiveToolEntry>();
326
339
  const groupMaxVisible = loadGroupMaxVisible();
327
340
 
341
+ let allExpanded = false;
342
+ const makeGroup = (kind: string): ToolGroup => {
343
+ const g = new ToolGroup(renderer, kind, groupMaxVisible);
344
+ g.setExpanded(allExpanded);
345
+ appendEntry(g.node, { t: "group", group: g });
346
+ return g;
347
+ };
348
+
328
349
  let openGroup: ToolGroup | null = null;
329
350
  const sealOpenGroup = (): void => {
330
351
  if (openGroup) { openGroup.seal(); openGroup = null; }
@@ -464,6 +485,7 @@ export function mountAshi(
464
485
  kind: args.kind,
465
486
  rawInput: args.rawInput,
466
487
  });
488
+ result.setExpanded(allExpanded);
467
489
  return { call, result, startedAt: Date.now() };
468
490
  };
469
491
 
@@ -559,7 +581,7 @@ export function mountAshi(
559
581
  .join("")
560
582
  : "";
561
583
  if (raw.startsWith("[Compacted conversation summary]")) return;
562
- appendEntry(renderUserMessage(stripContextWrappers(raw)), { t: "plain" });
584
+ appendEntry(renderUserMessage(stripContextWrappers(raw)), { t: "user" });
563
585
  } else if (m.role === "assistant") {
564
586
  const reasoning = readReasoning(m);
565
587
  if (reasoning) {
@@ -579,8 +601,7 @@ export function mountAshi(
579
601
  const kind = TOOL_KIND[name];
580
602
  if (kind && GROUPABLE_KINDS.has(kind) && renderer.mountToolGroup) {
581
603
  const mergeable = findMergeableGroup(kind);
582
- const group = mergeable
583
- ?? (() => { const g = new ToolGroup(renderer, kind, groupMaxVisible); appendEntry(g.node, { t: "group", group: g }); return g; })();
604
+ const group = mergeable ?? makeGroup(kind);
584
605
  group.addCall(id, name, detailFromArgs(tc.function.arguments));
585
606
  if (id) toolMap.set(id, { kind: "group", group, name });
586
607
  continue;
@@ -641,7 +662,7 @@ export function mountAshi(
641
662
  bus.on("agent:query", ({ query }) => {
642
663
  app.commitScrollback?.();
643
664
  sealOpenGroup();
644
- appendEntry(renderUserMessage(query), { t: "plain" });
665
+ appendEntry(renderUserMessage(query), { t: "user" });
645
666
  activeAssistant = null;
646
667
  app.requestRender();
647
668
  });
@@ -724,8 +745,7 @@ export function mountAshi(
724
745
  if (GROUPABLE_KINDS.has(kind) && renderer.mountToolGroup) {
725
746
  const mergeable = findMergeableGroup(kind);
726
747
  if (!mergeable) sealOpenGroup();
727
- const group = mergeable
728
- ?? (() => { const g = new ToolGroup(renderer, kind, groupMaxVisible); appendEntry(g.node, { t: "group", group: g }); return g; })();
748
+ const group = mergeable ?? makeGroup(kind);
729
749
  group.addCall(id, lookupName, detail);
730
750
  openGroup = group;
731
751
  activeTools.set(id, { kind: "group", group });
@@ -744,7 +764,13 @@ export function mountAshi(
744
764
  app.requestRender();
745
765
  });
746
766
 
747
- bus.on("agent:tool-output-chunk", ({ chunk }) => {
767
+ bus.on("agent:tool-output-chunk", ({ chunk, toolCallId }) => {
768
+ const owner = toolCallId ? activeTools.get(toolCallId) : undefined;
769
+ if (owner?.kind === "pair") {
770
+ owner.pair.result.appendChunk(chunk);
771
+ app.requestRender();
772
+ return;
773
+ }
748
774
  for (const entry of [...activeTools.values()].reverse()) {
749
775
  if (entry.kind === "pair") {
750
776
  entry.pair.result.appendChunk(chunk);
@@ -1242,9 +1268,16 @@ export function mountAshi(
1242
1268
  return { consume: true };
1243
1269
  }
1244
1270
  if (key.matches("ctrl+o")) {
1245
- for (const e of chatEntries) {
1246
- if (e.t === "group") e.group.toggleExpanded();
1247
- else if (e.t === "pair") e.result.toggleExpanded();
1271
+ allExpanded = !allExpanded;
1272
+ // Toggle only the latest turn; re-rendering the whole transcript is O(history).
1273
+ let start = 0;
1274
+ for (let i = chatEntries.length - 1; i >= 0; i--) {
1275
+ if (chatEntries[i]!.t === "user") { start = i; break; }
1276
+ }
1277
+ for (let i = start; i < chatEntries.length; i++) {
1278
+ const e = chatEntries[i]!;
1279
+ if (e.t === "group") e.group.setExpanded(allExpanded);
1280
+ else if (e.t === "pair") e.result.setExpanded(allExpanded);
1248
1281
  }
1249
1282
  app.requestRender();
1250
1283
  return { consume: true };
@@ -133,7 +133,7 @@ export interface App {
133
133
  export interface ToolCallView {
134
134
  node: RenderNode;
135
135
  setStatus(opts: { exitCode: number | null; elapsedMs: number; summary?: string }): void;
136
- toggleExpanded?(): void;
136
+ setExpanded?(expanded: boolean): void;
137
137
  }
138
138
 
139
139
  export interface ToolResultView {
@@ -142,7 +142,7 @@ export interface ToolResultView {
142
142
  /** Width-aware diff closure produced by the edit/write tool at finalize. */
143
143
  setDiffRenderer(fn: (width: number) => string[]): void;
144
144
  finalize(opts: { exitCode: number | null; summary?: string }): void;
145
- toggleExpanded(): void;
145
+ setExpanded(expanded: boolean): void;
146
146
  }
147
147
 
148
148
  export interface ToolGroupChild {
@@ -136,8 +136,9 @@ class SchemaResultComponent extends Container {
136
136
  this.handle.dispatch("status", { ...opts, elapsedMs: 0 });
137
137
  HANDLES.delete(this.handle.toolCallId);
138
138
  }
139
- toggleExpanded(): void {
140
- this.handle.cell.env = { ...this.handle.cell.env, expanded: !this.handle.cell.env.expanded };
139
+ setExpanded(expanded: boolean): void {
140
+ if (this.handle.cell.env.expanded === expanded) return;
141
+ this.handle.cell.env = { ...this.handle.cell.env, expanded };
141
142
  this.repaint();
142
143
  this.handle.cell.callView?.repaint();
143
144
  }
@@ -198,6 +199,6 @@ export function mountResult<S>(model: RenderModel<S>, args: MountArgs, env: Moun
198
199
  appendChunk: (chunk) => comp.appendChunk(chunk),
199
200
  setDiffRenderer: (fn) => comp.setDiffRenderer(fn),
200
201
  finalize: (opts) => comp.finalize(opts),
201
- toggleExpanded: () => comp.toggleExpanded(),
202
+ setExpanded: (expanded) => comp.setExpanded(expanded),
202
203
  };
203
204
  }
@@ -214,10 +214,11 @@ function renderStream(buffer: string, env: Env): string {
214
214
  const lines = display.split("\n");
215
215
  const trimmed = lines.slice(-env.previewLines).join("\n");
216
216
  const remaining = Math.max(0, lines.length - env.previewLines);
217
+ // The preview is the tail, so the hidden lines come before it — note goes above.
217
218
  const overflow = remaining > 0
218
- ? `\n${theme.fg("muted", `... (${remaining} more ${remaining === 1 ? "line" : "lines"})`)}`
219
+ ? `${theme.fg("muted", `... (${remaining} earlier ${remaining === 1 ? "line" : "lines"})`)}\n`
219
220
  : "";
220
- return `${theme.fg("toolOutput", trimmed)}${overflow}`;
221
+ return `${overflow}${theme.fg("toolOutput", trimmed)}`;
221
222
  }
222
223
 
223
224
  function lineCountHint(buffer: string): string {
@@ -10,6 +10,13 @@ export type { StatusSegment } from "./status-footer.js";
10
10
 
11
11
  export type NoticeLevel = "info" | "warn" | "error" | "success";
12
12
 
13
+ export interface DiffOpts {
14
+ before?: string | null;
15
+ after?: string;
16
+ filePath?: string;
17
+ boxed?: boolean;
18
+ }
19
+
13
20
  export interface Contribution {
14
21
  /** Re-pull this surface (call after the content it depends on changes). */
15
22
  refresh(): void;
@@ -26,6 +33,7 @@ export interface Ui {
26
33
  notify(message: string, level?: NoticeLevel): void;
27
34
  select(opts: SelectOpts): Promise<string | undefined>;
28
35
  confirm(opts: ConfirmOpts): Promise<boolean>;
36
+ diff(opts: DiffOpts): (width: number) => string[];
29
37
  input(opts?: InputOpts): Promise<string | undefined>;
30
38
  getEditorText(): string;
31
39
  setEditorText(text: string): void;
@@ -50,6 +58,9 @@ export function createUi(ctx: ExtensionContext): Ui {
50
58
  if (!has("ui:confirm")) return Promise.resolve(false);
51
59
  return ctx.call("ui:confirm", opts) as Promise<boolean>;
52
60
  },
61
+ diff(opts) {
62
+ return has("ui:diff") ? (ctx.call("ui:diff", opts) as (width: number) => string[]) : (() => []);
63
+ },
53
64
  input(opts = {}) {
54
65
  if (!has("ui:input")) return Promise.resolve(undefined);
55
66
  return ctx.call("ui:input", opts) as Promise<string | undefined>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.15.1",
3
+ "version": "0.15.3",
4
4
  "description": "A composable agent runtime — pair any frontend with any agent backend over one shared extension layer",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -623,14 +623,15 @@ export class AgentLoop implements AgentBackend {
623
623
  streamedCalls: ProtocolPendingToolCall[];
624
624
  }) => this.toolProtocol.extractToolCalls(args.text, args.streamedCalls));
625
625
 
626
- // System prompt: static identity + behavioral instructions.
627
- // Extensions can use registerInstruction() for a managed section,
628
- // advise system-prompt:frontend to describe their surface high in the
629
- // prompt, or advise this handler directly for full control.
626
+ // System prompt: static identity + behavioral instructions. Extensions can
627
+ // use registerInstruction() for a managed section, advise system-prompt:identity
628
+ // to replace the kernel identity, advise system-prompt:frontend to describe their
629
+ // surface high in the prompt, or advise system-prompt:build directly for full control.
630
+ h.define("system-prompt:identity", () => STATIC_IDENTITY);
630
631
  h.define("system-prompt:build", () => {
631
632
  // The active frontend's surface goes right after the identity; omitted if none.
632
633
  const frontend = ((this.handlers.call("system-prompt:frontend") as string) ?? "").trim();
633
- const parts: string[] = [STATIC_IDENTITY];
634
+ const parts: string[] = [this.handlers.call("system-prompt:identity") as string];
634
635
  if (frontend) parts.push(frontend);
635
636
  parts.push(STATIC_GUIDE);
636
637
 
@@ -1123,7 +1124,7 @@ export class AgentLoop implements AgentBackend {
1123
1124
  // Execute via handler — extensions can advise to add safe-mode,
1124
1125
  // logging, metrics, custom permission policies, etc.
1125
1126
  const defaultOnChunk = (chunk: string) => {
1126
- this.bus.emit("agent:tool-output-chunk", { chunk });
1127
+ this.bus.emit("agent:tool-output-chunk", { chunk, toolCallId: tc.id });
1127
1128
  };
1128
1129
  const result = await this.handlers.call(
1129
1130
  "tool:execute",
@@ -1410,8 +1411,10 @@ export class AgentLoop implements AgentBackend {
1410
1411
  const reasoningDetailsByIndex = new Map<number, Record<string, unknown>>();
1411
1412
  const pendingToolCalls: PendingToolCall[] = [];
1412
1413
 
1413
- // Tool protocol controls what goes in the API tools param vs dynamic context
1414
- const toolView = this.getTools();
1414
+ // Tool protocol controls what goes in the API tools param vs dynamic context.
1415
+ // agent:tools:visible is a filter point on the assembled list — distinct from
1416
+ // getTools(), which other code (e.g. tool bridges) needs unfiltered.
1417
+ const toolView = this.bus.emitPipe("agent:tools:visible", { tools: this.getTools() }).tools;
1415
1418
  const apiTools = this.toolProtocol.getApiTools(toolView);
1416
1419
  const toolPrompt = this.toolProtocol.getToolPrompt(toolView);
1417
1420
 
@@ -1427,7 +1430,7 @@ export class AgentLoop implements AgentBackend {
1427
1430
 
1428
1431
  // Stream filter strips tool tags from display (inline mode only)
1429
1432
  const streamFilter = this.toolProtocol.createStreamFilter(
1430
- this.getTools().map((t) => t.name),
1433
+ toolView.map((t) => t.name),
1431
1434
  );
1432
1435
 
1433
1436
  const requestParams = {
@@ -29,6 +29,8 @@ declare module "../core/event-bus.js" {
29
29
 
30
30
  "agent:info": AgentIdentity;
31
31
  "agent:tools": { tools: ToolDefinition[] };
32
+ /** Filter point: the assembled tool list as the model will see it, after getTools(). */
33
+ "agent:tools:visible": { tools: ToolDefinition[] };
32
34
  "agent:instructions": { instructions: Array<{ name: string; text: string }> };
33
35
  "agent:skills": { skills: Array<{ name: string; description: string; filePath: string }> };
34
36
 
@@ -86,7 +88,7 @@ declare module "../core/event-bus.js" {
86
88
  kind?: string;
87
89
  resultDisplay?: ToolResultDisplay;
88
90
  };
89
- "agent:tool-output-chunk": { chunk: string };
91
+ "agent:tool-output-chunk": { chunk: string; toolCallId?: string };
90
92
 
91
93
  "tool:interactive-start": Record<string, never>;
92
94
  "tool:interactive-end": Record<string, never>;
@@ -116,10 +116,14 @@ export default function agentBackend(ctx: ExtensionContext): void {
116
116
 
117
117
  // Settings overlay — fields here win over contributing extensions' payloads.
118
118
  const settingsProviders = new Map<string, ResolvedProvider>();
119
- for (const name of getProviderNames()) {
120
- const p = resolveProvider(name);
121
- if (p) settingsProviders.set(name, p);
122
- }
119
+ const refreshSettingsProviders = () => {
120
+ settingsProviders.clear();
121
+ for (const name of getProviderNames()) {
122
+ const p = resolveProvider(name);
123
+ if (p) settingsProviders.set(name, p);
124
+ }
125
+ };
126
+ refreshSettingsProviders();
123
127
 
124
128
  const providerHooks = new Map<string, {
125
129
  reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
@@ -368,6 +372,7 @@ export default function agentBackend(ctx: ExtensionContext): void {
368
372
  let loadedExtensionNames: string[] = [];
369
373
 
370
374
  bus.on("agent:providers:changed", () => {
375
+ refreshSettingsProviders();
371
376
  resolvedProviders = computeResolvedProviders();
372
377
  if (!resolved) return;
373
378
  bus.emit("agent:models-changed", {});
@@ -157,7 +157,7 @@ export async function runSubagent(opts: SubagentOptions): Promise<string> {
157
157
  }
158
158
 
159
159
  const onChunk = bus && tool.showOutput !== false
160
- ? (chunk: string) => { bus.emit("agent:tool-output-chunk", { chunk }); }
160
+ ? (chunk: string) => { bus.emit("agent:tool-output-chunk", { chunk, toolCallId: tc.id }); }
161
161
  : undefined;
162
162
 
163
163
  const result = await tool.execute(args, onChunk);