agent-sh 0.15.1 → 0.15.2

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);
@@ -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;
@@ -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); });
@@ -325,6 +337,14 @@ export function mountAshi(
325
337
  const activeTools = new Map<string, LiveToolEntry>();
326
338
  const groupMaxVisible = loadGroupMaxVisible();
327
339
 
340
+ let allExpanded = false;
341
+ const makeGroup = (kind: string): ToolGroup => {
342
+ const g = new ToolGroup(renderer, kind, groupMaxVisible);
343
+ g.setExpanded(allExpanded);
344
+ appendEntry(g.node, { t: "group", group: g });
345
+ return g;
346
+ };
347
+
328
348
  let openGroup: ToolGroup | null = null;
329
349
  const sealOpenGroup = (): void => {
330
350
  if (openGroup) { openGroup.seal(); openGroup = null; }
@@ -464,6 +484,7 @@ export function mountAshi(
464
484
  kind: args.kind,
465
485
  rawInput: args.rawInput,
466
486
  });
487
+ result.setExpanded(allExpanded);
467
488
  return { call, result, startedAt: Date.now() };
468
489
  };
469
490
 
@@ -579,8 +600,7 @@ export function mountAshi(
579
600
  const kind = TOOL_KIND[name];
580
601
  if (kind && GROUPABLE_KINDS.has(kind) && renderer.mountToolGroup) {
581
602
  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; })();
603
+ const group = mergeable ?? makeGroup(kind);
584
604
  group.addCall(id, name, detailFromArgs(tc.function.arguments));
585
605
  if (id) toolMap.set(id, { kind: "group", group, name });
586
606
  continue;
@@ -724,8 +744,7 @@ export function mountAshi(
724
744
  if (GROUPABLE_KINDS.has(kind) && renderer.mountToolGroup) {
725
745
  const mergeable = findMergeableGroup(kind);
726
746
  if (!mergeable) sealOpenGroup();
727
- const group = mergeable
728
- ?? (() => { const g = new ToolGroup(renderer, kind, groupMaxVisible); appendEntry(g.node, { t: "group", group: g }); return g; })();
747
+ const group = mergeable ?? makeGroup(kind);
729
748
  group.addCall(id, lookupName, detail);
730
749
  openGroup = group;
731
750
  activeTools.set(id, { kind: "group", group });
@@ -1242,9 +1261,10 @@ export function mountAshi(
1242
1261
  return { consume: true };
1243
1262
  }
1244
1263
  if (key.matches("ctrl+o")) {
1264
+ allExpanded = !allExpanded;
1245
1265
  for (const e of chatEntries) {
1246
- if (e.t === "group") e.group.toggleExpanded();
1247
- else if (e.t === "pair") e.result.toggleExpanded();
1266
+ if (e.t === "group") e.group.setExpanded(allExpanded);
1267
+ else if (e.t === "pair") e.result.setExpanded(allExpanded);
1248
1268
  }
1249
1269
  app.requestRender();
1250
1270
  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
  }
@@ -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.2",
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
 
@@ -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