agent-sh 0.14.11 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +38 -42
  2. package/dist/agent/agent-loop.d.ts +9 -17
  3. package/dist/agent/agent-loop.js +104 -136
  4. package/dist/agent/events.d.ts +8 -11
  5. package/dist/agent/host-types.d.ts +17 -11
  6. package/dist/agent/index.d.ts +1 -1
  7. package/dist/agent/index.js +38 -22
  8. package/dist/agent/providers/deepseek.js +9 -1
  9. package/dist/agent/session-store.js +1 -1
  10. package/dist/agent/system-prompt.d.ts +7 -3
  11. package/dist/agent/system-prompt.js +11 -14
  12. package/dist/agent/tool-protocol.js +0 -7
  13. package/dist/cli/args.js +2 -1
  14. package/dist/cli/install.d.ts +1 -0
  15. package/dist/cli/install.js +29 -1
  16. package/dist/cli/subcommands.js +1 -0
  17. package/dist/core/event-bus.js +0 -2
  18. package/dist/core/extension-loader.js +3 -1
  19. package/dist/core/index.d.ts +1 -1
  20. package/dist/core/index.js +3 -2
  21. package/dist/extensions/slash-commands/index.js +16 -11
  22. package/dist/shell/index.js +9 -0
  23. package/dist/shell/shell-context.d.ts +2 -2
  24. package/dist/shell/shell-context.js +26 -11
  25. package/dist/shell/tui-renderer.js +0 -1
  26. package/dist/utils/diff-renderer.js +2 -9
  27. package/dist/utils/handler-registry.d.ts +1 -6
  28. package/dist/utils/handler-registry.js +1 -6
  29. package/dist/utils/line-editor.js +0 -2
  30. package/dist/utils/palette.js +4 -4
  31. package/dist/utils/terminal-buffer.d.ts +2 -0
  32. package/dist/utils/terminal-buffer.js +4 -0
  33. package/examples/extensions/ash-acp-bridge/src/index.ts +11 -7
  34. package/examples/extensions/ash-scheme/index.ts +104 -74
  35. package/examples/extensions/ashi/EXTENDING.md +2 -0
  36. package/examples/extensions/ashi/README.md +17 -1
  37. package/examples/extensions/ashi/docs/ui-surface-protocol.md +163 -0
  38. package/examples/extensions/ashi/package.json +9 -1
  39. package/examples/extensions/ashi/src/capture.ts +45 -7
  40. package/examples/extensions/ashi/src/chat/assistant.ts +23 -43
  41. package/examples/extensions/ashi/src/chat/lines.ts +20 -1
  42. package/examples/extensions/ashi/src/cli.ts +25 -3
  43. package/examples/extensions/ashi/src/clipboard-image.ts +1 -1
  44. package/examples/extensions/ashi/src/dialogs.ts +67 -0
  45. package/examples/extensions/ashi/src/display-config.ts +7 -0
  46. package/examples/extensions/ashi/src/docks.ts +31 -0
  47. package/examples/extensions/ashi/src/events.ts +16 -0
  48. package/examples/extensions/ashi/src/frontend.ts +134 -27
  49. package/examples/extensions/ashi/src/hooks.ts +6 -12
  50. package/examples/extensions/ashi/src/input-prompt.ts +64 -0
  51. package/examples/extensions/ashi/src/renderers/pi-tui/index.ts +7 -3
  52. package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +67 -10
  53. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +11 -1
  54. package/examples/extensions/ashi/src/schema.ts +3 -0
  55. package/examples/extensions/ashi/src/session-commands.ts +2 -1
  56. package/examples/extensions/ashi/src/status-footer.ts +21 -3
  57. package/examples/extensions/ashi/src/ui.ts +88 -0
  58. package/examples/extensions/ashi-ink/README.md +2 -0
  59. package/examples/extensions/ashi-scheme-render.ts +8 -2
  60. package/examples/extensions/ashi-ui-demo.ts +63 -0
  61. package/examples/extensions/latex-images.ts +57 -9
  62. package/examples/extensions/overlay-agent.ts +5 -5
  63. package/examples/extensions/pi-bridge/index.ts +7 -12
  64. package/package.json +1 -1
@@ -1,7 +1,15 @@
1
1
  import { getSettings } from "../core/settings.js";
2
2
  import { spillOutput } from "../utils/shell-output-spill.js";
3
+ // The cwd-drift note applies only under the shell frontend (where the agent shares
4
+ // the user's cwd); other frontends own a fixed cwd.
5
+ const SHELL_EVENTS_NOTE = `When the user runs shell commands, they appear as \`<shell_events>\` inside \`<query_context>\` on your next turn — use them to ground "fix this" / "what just happened" requests.`;
6
+ const CWD_DRIFT_NOTE = `\`<cwd>\` is the working directory your own tool calls run in: relative paths resolve against it, and it follows the user's shell \`cd\`, so it can change from one turn to the next. Always act on the latest \`<cwd>\`, not one from earlier in the conversation.`;
7
+ const PREFERENCES_NOTE = `Treat the user's commands as standing preferences: check them for recurring patterns and apply them proactively, without waiting to be asked.`;
3
8
  export default function activate(ctx) {
4
9
  const { bus } = ctx;
10
+ // The agent shares the user's cwd only under the shell frontend (which installs
11
+ // ctx.shell); other frontends — e.g. ashi — keep their own fixed cwd.
12
+ const ownsAgentCwd = !!ctx.shell;
5
13
  const exchanges = [];
6
14
  let nextId = 1;
7
15
  let currentCwd = process.cwd();
@@ -50,23 +58,30 @@ export default function activate(ctx) {
50
58
  bus.on("shell:agent-exec-start", () => { agentShellActive = true; });
51
59
  bus.on("shell:agent-exec-done", () => { agentShellActive = false; });
52
60
  bus.on("shell:user-exec-exclude-next", () => { nextUserExcluded = true; });
53
- ctx.advise("cwd", () => currentCwd);
61
+ if (ownsAgentCwd)
62
+ ctx.advise("cwd", () => currentCwd);
54
63
  // Advise the core handler directly: this loads before the agent host
55
64
  // attaches `ctx.agent`, so the sugar isn't available yet.
56
65
  ctx.advise("query-context:build", (next) => {
57
66
  const base = next();
58
- const part = (() => {
59
- const cwdTag = `<cwd>${currentCwd}</cwd>`;
60
- const fresh = exchanges.filter((ex) => ex.id > lastSeq && ex.source === "user");
61
- if (fresh.length === 0)
62
- return cwdTag;
67
+ const fresh = exchanges.filter((ex) => ex.id > lastSeq && ex.source === "user");
68
+ let shellEvents = "";
69
+ if (fresh.length > 0) {
63
70
  lastSeq = exchanges[exchanges.length - 1].id;
64
71
  const text = fresh.map(formatExchangeTruncated).filter(Boolean).join("\n");
65
- if (!text)
66
- return cwdTag;
67
- return `${cwdTag}\n<shell_events>\n${text}\n</shell_events>`;
68
- })();
69
- return base ? `${base}\n\n${part}` : part;
72
+ if (text)
73
+ shellEvents = `<shell_events>\n${text}\n</shell_events>`;
74
+ }
75
+ const part = ownsAgentCwd
76
+ ? [`<cwd>${currentCwd}</cwd>`, shellEvents].filter(Boolean).join("\n")
77
+ : shellEvents;
78
+ return [base, part].filter(Boolean).join("\n\n");
79
+ });
80
+ bus.onPipe("agent:instructions", (acc) => {
81
+ const text = [SHELL_EVENTS_NOTE, ownsAgentCwd ? CWD_DRIFT_NOTE : "", PREFERENCES_NOTE]
82
+ .filter(Boolean).join("\n\n");
83
+ acc.instructions.push({ name: "shell-events", text });
84
+ return acc;
70
85
  });
71
86
  ctx.define("shell:context-recent", (n = 25) => {
72
87
  const recent = exchanges.slice(-n);
@@ -222,7 +222,6 @@ export default function activate(ctx) {
222
222
  }
223
223
  }
224
224
  });
225
- // Track token usage for display
226
225
  let pendingUsage = null;
227
226
  bus.on("agent:usage", (e) => { pendingUsage = e; });
228
227
  bus.on("agent:response-done", () => {
@@ -176,17 +176,14 @@ function findChangePairs(hunk) {
176
176
  const lines = hunk.lines;
177
177
  let i = 0;
178
178
  while (i < lines.length) {
179
- // Find a run of removed lines
180
179
  const removedStart = i;
181
180
  while (i < lines.length && lines[i].type === "removed")
182
181
  i++;
183
182
  const removedEnd = i;
184
- // Find a run of added lines immediately after
185
183
  const addedStart = i;
186
184
  while (i < lines.length && lines[i].type === "added")
187
185
  i++;
188
186
  const addedEnd = i;
189
- // Pair them 1:1
190
187
  const removedCount = removedEnd - removedStart;
191
188
  const addedCount = addedEnd - addedStart;
192
189
  const pairCount = Math.min(removedCount, addedCount);
@@ -254,7 +251,7 @@ function renderUnifiedHunk(hunk, layout) {
254
251
  const gutter = (n) => `${p.dim}${n} │${p.reset} `;
255
252
  const change = (no, sigil, bg, fg, text) => {
256
253
  if (!gutterLine) {
257
- return `${bg}${padToWidth(`${no} ${fg}${sigil}${preserveBg(text, bg)}`, textWidth)}${p.reset}`;
254
+ return `${bg}${fg}${padToWidth(`${no} ${sigil} ${preserveBg(text, bg)}`, textWidth)}${p.reset}`;
258
255
  }
259
256
  if (useTrueColor)
260
257
  return gutter(no) + padToWidth(`${bg}${fg}${sigil} ${preserveBg(text, bg)}`, bgWidth) + p.reset;
@@ -267,7 +264,7 @@ function renderUnifiedHunk(hunk, layout) {
267
264
  const raw = truncateText(line.text, lineTextW);
268
265
  const text = lang ? highlightLine(raw, lang) : raw;
269
266
  // The flush gutter dims only the line number; the code stays normal/highlighted.
270
- out.push(!gutterLine ? `${p.dim}${no}${p.reset} ${text}` : `${gutter(no)} ${p.dim}${text}${p.reset}`);
267
+ out.push(!gutterLine ? `${p.dim}${no}${p.reset} ${text}` : `${gutter(no)} ${p.dim}${text}${p.reset}`);
271
268
  continue;
272
269
  }
273
270
  if (line.type === "removed") {
@@ -418,19 +415,16 @@ function buildSplitRows(hunk) {
418
415
  i++;
419
416
  continue;
420
417
  }
421
- // Collect a run of removed lines
422
418
  const removed = [];
423
419
  while (i < lines.length && lines[i].type === "removed") {
424
420
  removed.push(lines[i]);
425
421
  i++;
426
422
  }
427
- // Collect a run of added lines
428
423
  const added = [];
429
424
  while (i < lines.length && lines[i].type === "added") {
430
425
  added.push(lines[i]);
431
426
  i++;
432
427
  }
433
- // Pair them side by side
434
428
  const maxLen = Math.max(removed.length, added.length);
435
429
  for (let k = 0; k < maxLen; k++) {
436
430
  rows.push({
@@ -517,7 +511,6 @@ function trimHunksToFit(hunks, maxLines) {
517
511
  changeCount++;
518
512
  }
519
513
  }
520
- // Separators between hunks
521
514
  const separators = Math.max(0, hunks.length - 1);
522
515
  // How many context lines can we afford?
523
516
  const contextBudget = Math.max(0, maxLines - changeCount - separators);
@@ -50,13 +50,8 @@ export declare class HandlerRegistry {
50
50
  * Returns undefined if no handler is registered.
51
51
  */
52
52
  call(name: string, ...args: any[]): any;
53
- /**
54
- * Check if a named handler exists.
55
- */
56
53
  has(name: string): boolean;
57
- /**
58
- * Names of all registered handlers. For diagnostic/introspection use.
59
- */
54
+ /** Names of all registered handlers — for diagnostics/introspection. */
60
55
  list(): string[];
61
56
  }
62
57
  export {};
@@ -79,15 +79,10 @@ export class HandlerRegistry {
79
79
  }
80
80
  return fn(...args);
81
81
  }
82
- /**
83
- * Check if a named handler exists.
84
- */
85
82
  has(name) {
86
83
  return this.entries.has(name);
87
84
  }
88
- /**
89
- * Names of all registered handlers. For diagnostic/introspection use.
90
- */
85
+ /** Names of all registered handlers — for diagnostics/introspection. */
91
86
  list() {
92
87
  return [...this.entries.keys()];
93
88
  }
@@ -309,11 +309,9 @@ export class LineEditor {
309
309
  pushHistory(line) {
310
310
  if (!line.trim())
311
311
  return;
312
- // Deduplicate: remove if already at top
313
312
  if (this.history.length > 0 && this.history[0] === line)
314
313
  return;
315
314
  this.history.unshift(line);
316
- // Cap history size
317
315
  if (this.history.length > 100)
318
316
  this.history.pop();
319
317
  }
@@ -14,10 +14,10 @@ const defaultPalette = {
14
14
  warning: "\x1b[33m", // yellow
15
15
  error: "\x1b[31m", // red
16
16
  muted: "\x1b[90m", // gray
17
- successBg: "\x1b[48;2;0;60;0m",
18
- errorBg: "\x1b[48;2;50;0;0m",
19
- successBgEmph: "\x1b[48;2;0;112;0m",
20
- errorBgEmph: "\x1b[48;2;90;0;0m",
17
+ successBg: "\x1b[48;2;34;92;43m",
18
+ errorBg: "\x1b[48;2;122;41;54m",
19
+ successBgEmph: "\x1b[48;2;56;166;96m",
20
+ errorBgEmph: "\x1b[48;2;179;89;107m",
21
21
  bold: "\x1b[1m",
22
22
  dim: "\x1b[2m",
23
23
  italic: "\x1b[3m",
@@ -47,6 +47,8 @@ export declare class TerminalBuffer {
47
47
  readScreen(opts?: {
48
48
  includeScrollback?: boolean;
49
49
  }): ScreenSnapshot;
50
+ /** Read the screen and wrap it as a `<terminal_buffer>` context block. */
51
+ formatScreen(maxLines?: number, baseContext?: string): string;
50
52
  /**
51
53
  * Get terminal screen as lines, padded/trimmed to exactly `rows` lines.
52
54
  * Clean text only (ANSI stripped). Reads from the active buffer's
@@ -111,6 +111,10 @@ export class TerminalBuffer {
111
111
  cursorY: buf.cursorY,
112
112
  };
113
113
  }
114
+ /** Read the screen and wrap it as a `<terminal_buffer>` context block. */
115
+ formatScreen(maxLines, baseContext) {
116
+ return formatScreenContext(this.readScreen(), maxLines, baseContext);
117
+ }
114
118
  /**
115
119
  * Get terminal screen as lines, padded/trimmed to exactly `rows` lines.
116
120
  * Clean text only (ANSI stripped). Reads from the active buffer's
@@ -434,10 +434,10 @@ function waitForModelsToSettle(
434
434
  timer = setTimeout(done, Math.max(0, Math.min(quietMs, remaining)));
435
435
  };
436
436
  const done = () => {
437
- core.bus.off("agent:modes-changed", arm);
437
+ core.bus.off("agent:models-changed", arm);
438
438
  resolve();
439
439
  };
440
- core.bus.on("agent:modes-changed", arm);
440
+ core.bus.on("agent:models-changed", arm);
441
441
  arm();
442
442
  });
443
443
  }
@@ -446,15 +446,14 @@ function getModelsPayload(): Record<string, unknown> | undefined {
446
446
  if (!core) return undefined;
447
447
  const info = core.bus.emitPipe("config:get-models", { models: [], active: null });
448
448
  if (!info.models.length) return undefined;
449
- const idFor = (m: { model: string; provider: string }) =>
450
- m.provider ? `${m.model}@${m.provider}` : m.model;
449
+ const idFor = (m: { id: string; provider: string }) => `${m.id}@${m.provider}`;
451
450
  const current = info.active ?? info.models[0]!;
452
451
  return {
453
452
  currentModelId: idFor(current),
454
453
  availableModels: info.models.map((m) => ({
455
454
  modelId: idFor(m),
456
- name: m.provider ? `${m.provider}/${m.model}` : m.model,
457
- description: m.provider ? `Provider: ${m.provider}` : "",
455
+ name: `${m.provider}/${m.id}`,
456
+ description: `Provider: ${m.provider}`,
458
457
  })),
459
458
  };
460
459
  }
@@ -601,7 +600,12 @@ function dispatch(msg: JsonRpcRequest): void {
601
600
  break;
602
601
  case "session/set_model":
603
602
  if (core && params?.modelId) {
604
- core.bus.emit("config:switch-model", { model: params.modelId as string });
603
+ const raw = params.modelId as string;
604
+ const at = raw.lastIndexOf("@");
605
+ core.bus.emit("config:switch-model", {
606
+ id: at > 0 ? raw.slice(0, at) : raw,
607
+ provider: at > 0 ? raw.slice(at + 1) : "",
608
+ });
605
609
  }
606
610
  sendResult(id!, {
607
611
  models: getModelsPayload() ?? {},
@@ -27,7 +27,7 @@ async function withDisplay(
27
27
  ): Promise<ToolResult> {
28
28
  const toolCallId = `scheme-${toolName}-${++callCounter}`;
29
29
  bus.emit("agent:tool-started", {
30
- title: toolName, toolCallId, kind, rawInput, displayDetail,
30
+ title: toolName, toolCallId, kind, rawInput, displayDetail, nested: true,
31
31
  });
32
32
  const result = await run();
33
33
  // Stream the result so the TUI's tracked `output` holds it, not just the summary.
@@ -38,6 +38,7 @@ async function withDisplay(
38
38
  rawOutput: result.content,
39
39
  kind,
40
40
  resultDisplay: result.display,
41
+ nested: true,
41
42
  });
42
43
  return result;
43
44
  }
@@ -1383,23 +1384,23 @@ function unwrapSchemeBool(v: any): any {
1383
1384
  // plain bash tool call drops it before the model ever sees it).
1384
1385
  type HostSig = { name: string; sig: string; ret: string; doc: string };
1385
1386
  const HOST_SIGS: HostSig[] = [
1386
- { name: "bash", sig: '(bash "cmd" [timeout-sec])',
1387
+ { name: "bash", sig: '(bash "cmd" [:timeout sec])',
1387
1388
  ret: "((output . str) (exit-code . n) (error . bool))",
1388
1389
  doc: "run a shell command; full result. Accessors: output-of exit-code-of ok? error?" },
1389
- { name: "sh", sig: '(sh "cmd" [timeout-sec])', ret: "str",
1390
+ { name: "sh", sig: '(sh "cmd" [:timeout sec])', ret: "str",
1390
1391
  doc: "run a shell command, return stdout only (stderr text on failure)" },
1391
- { name: "read-file", sig: '(read-file "path" [offset] [limit])', ret: "str | #f",
1392
- doc: "file contents, or #f on error. offset is 1-indexed; limit caps lines" },
1392
+ { name: "read-file", sig: '(read-file "path" [:offset n] [:limit n])', ret: "str | #f",
1393
+ doc: "file contents, or #f on error. :offset is 1-indexed; :limit caps lines" },
1393
1394
  { name: "write-file", sig: '(write-file "path" "content")', ret: "#t | err-str",
1394
1395
  doc: "overwrite a file" },
1395
- { name: "edit-file", sig: '(edit-file "path" "old" "new" [replace-all])', ret: "#t | err-str",
1396
- doc: "replace exact text; pass #t to replace every occurrence" },
1396
+ { name: "edit-file", sig: '(edit-file "path" "old" "new" [:replace-all #t])', ret: "#t | err-str",
1397
+ doc: "replace exact text; :replace-all #t replaces every occurrence" },
1397
1398
  { name: "grep", sig: '(grep "pat" ["dir"] [:opt val …])',
1398
1399
  ret: "(listof ((file . str) (line . n) (text . str)))",
1399
- doc: "ripgrep search. options: :include :case-insensitive :context-before :context-after :limit :offset" },
1400
+ doc: "ripgrep search. options: :path :include :case-insensitive :context-before :context-after :limit :offset" },
1400
1401
  { name: "grep-files", sig: '(grep-files "pat" ["dir"] [:opt val …])', ret: "(listof str)",
1401
- doc: "files containing a match. options: :include :case-insensitive :limit :offset" },
1402
- { name: "glob", sig: '(glob "pat" ["dir"])', ret: "(listof str)",
1402
+ doc: "files containing a match. options: :path :include :case-insensitive :limit :offset" },
1403
+ { name: "glob", sig: '(glob "pat" [:path "dir"])', ret: "(listof str)",
1403
1404
  doc: "paths matching a glob, mtime-sorted" },
1404
1405
  ];
1405
1406
  const sigLine = (h: HostSig): string => `${h.sig} → ${h.ret}`;
@@ -1429,10 +1430,8 @@ const isKwSym = (x: any): boolean => {
1429
1430
  return typeof n === "string" && n.startsWith(":");
1430
1431
  };
1431
1432
 
1432
- // Split a host primitive's evaluated arg list into leading positionals and a
1433
- // trailing :key value option map. LIPS has no keyword args, so the grep macros
1434
- // quote leading-colon symbols (otherwise they'd be looked up as unbound
1435
- // variables); they arrive here as plain LSymbols named ":foo".
1433
+ // Split a primitive's args into leading positionals and a trailing :key value
1434
+ // option map. An unknown key throws so a wrong option name teaches the valid set.
1436
1435
  function splitArgs(
1437
1436
  args: any[], keyMap: Record<string, string>, numericKeys?: Set<string>,
1438
1437
  ): { positionals: any[]; opts: Record<string, unknown> } {
@@ -1442,22 +1441,30 @@ function splitArgs(
1442
1441
  const opts: Record<string, unknown> = {};
1443
1442
  while (i < args.length) {
1444
1443
  if (!isKwSym(args[i])) { i++; continue; }
1445
- const tgt = keyMap[symName(args[i])!.slice(1)];
1446
- if (tgt !== undefined) {
1447
- const raw = args[i + 1];
1448
- opts[tgt] = numericKeys && numericKeys.has(tgt)
1449
- ? Number(raw)
1450
- : unwrapSchemeBool(toJsStr(raw));
1444
+ const key = symName(args[i])!.slice(1);
1445
+ const tgt = keyMap[key];
1446
+ if (tgt === undefined) {
1447
+ const valid = Object.keys(keyMap).map((k) => `:${k}`).join(" ");
1448
+ throw new Error(`unknown option :${key}; valid options: ${valid || "(none)"}`);
1451
1449
  }
1450
+ const raw = args[i + 1];
1451
+ opts[tgt] = numericKeys && numericKeys.has(tgt)
1452
+ ? Number(raw)
1453
+ : unwrapSchemeBool(toJsStr(raw));
1452
1454
  i += 2;
1453
1455
  }
1454
1456
  return { positionals, opts };
1455
1457
  }
1456
1458
 
1459
+ // Cache executors on globalThis (survives reload's module-cache bust): in
1460
+ // schemeOnly the built-ins get unregistered, but the tool objects outlive it.
1461
+ const EXECUTOR_CACHE: Record<string, ToolExecutor> =
1462
+ ((globalThis as any).__ashSchemeExecutors ??= {});
1457
1463
  function resolveExecutor(ctx: AgentContext, name: string): ToolExecutor {
1458
1464
  const tool = ctx.agent.getTools().find((t) => t.name === name);
1459
- if (!tool) throw new Error(`scheme bridge: tool '${name}' not registered`);
1460
- return (args) => tool.execute(args);
1465
+ if (tool) return (EXECUTOR_CACHE[name] = (args) => tool.execute(args));
1466
+ if (EXECUTOR_CACHE[name]) return EXECUTOR_CACHE[name];
1467
+ throw new Error(`scheme bridge: tool '${name}' not registered`);
1461
1468
  }
1462
1469
 
1463
1470
  function installBindings(
@@ -1470,6 +1477,35 @@ function installBindings(
1470
1477
  grep: ToolExecutor | null,
1471
1478
  glob: ToolExecutor | null,
1472
1479
  ): void {
1480
+ // Optional args are :key value pairs; a trailing positional is still accepted.
1481
+ const READ_KEYMAP: Record<string, string> = { "offset": "offset", "limit": "limit" };
1482
+ const READ_NUMERIC = new Set(["offset", "limit"]);
1483
+ const BASH_KEYMAP: Record<string, string> = { "timeout": "timeout" };
1484
+ const BASH_NUMERIC = new Set(["timeout"]);
1485
+ const EDIT_KEYMAP: Record<string, string> = { "replace-all": "replace_all" };
1486
+ const GLOB_KEYMAP: Record<string, string> = { "path": "path" };
1487
+ const GREP_KEYMAP: Record<string, string> = {
1488
+ "include": "include",
1489
+ "case-insensitive": "case_insensitive",
1490
+ "context-before": "context_before",
1491
+ "context-after": "context_after",
1492
+ "limit": "head_limit",
1493
+ "offset": "offset",
1494
+ "path": "path",
1495
+ };
1496
+ const GREP_NUMERIC = new Set([
1497
+ "context_before", "context_after", "head_limit", "offset",
1498
+ ]);
1499
+
1500
+ // LIPS has no keyword-argument syntax: bind each option key to a self-quoting
1501
+ // symbol so a bare `:offset` yields the symbol instead of an unbound error.
1502
+ for (const km of [READ_KEYMAP, BASH_KEYMAP, EDIT_KEYMAP, GLOB_KEYMAP, GREP_KEYMAP]) {
1503
+ for (const key of Object.keys(km)) {
1504
+ const kw = `:${key}`;
1505
+ env.set(kw, new LSymbol(kw));
1506
+ }
1507
+ }
1508
+
1473
1509
  const runBash = async (command: string, timeoutSec?: number) => {
1474
1510
  const args: Record<string, unknown> = { command: toJsStr(command) };
1475
1511
  if (typeof timeoutSec === "number") args.timeout = timeoutSec;
@@ -1483,9 +1519,17 @@ function installBindings(
1483
1519
  // carries it: a plain bash tool call drops it before the model.
1484
1520
  return { exitCode: result.exitCode ?? (result.isError ? 1 : 0), output, error: result.isError };
1485
1521
  };
1486
- env.set("bash", withSig("bash", async (command: string, timeoutSec?: number) => {
1522
+ const bashTimeout = (positionals: any[], opts: Record<string, unknown>): number | undefined => {
1523
+ const t = opts.timeout !== undefined ? opts.timeout : positionals[1];
1524
+ if (t === undefined || t === null) return undefined;
1525
+ const n = Number(t);
1526
+ return isNaN(n) ? undefined : n;
1527
+ };
1528
+ env.set("bash", withSig("bash", async (...rest: any[]) => {
1529
+ const { positionals, opts } = splitArgs(rest, BASH_KEYMAP, BASH_NUMERIC);
1530
+ const command = positionals[0];
1487
1531
  try {
1488
- const r = await runBash(command, timeoutSec);
1532
+ const r = await runBash(command, bashTimeout(positionals, opts));
1489
1533
  return alist([
1490
1534
  ["output", r.output],
1491
1535
  ["exit-code", r.exitCode],
@@ -1498,17 +1542,22 @@ function installBindings(
1498
1542
  }));
1499
1543
  // Shortcut: stdout as a string. Use `bash` when you need the exit code, or
1500
1544
  // `(ok? (bash "…"))` for a success predicate.
1501
- env.set("sh", withSig("sh", async (command: string, timeoutSec?: number) => {
1545
+ env.set("sh", withSig("sh", async (...rest: any[]) => {
1546
+ const { positionals, opts } = splitArgs(rest, BASH_KEYMAP, BASH_NUMERIC);
1547
+ const command = positionals[0];
1502
1548
  try {
1503
- return (await runBash(command, timeoutSec)).output;
1549
+ return (await runBash(command, bashTimeout(positionals, opts))).output;
1504
1550
  } catch (e: any) {
1505
1551
  logErr("sh", e, { command });
1506
1552
  return "";
1507
1553
  }
1508
1554
  }));
1509
1555
 
1510
- env.set("read-file", withSig("read-file", async (filePath: string, offset?: any, limit?: any) => {
1511
- const args: Record<string, unknown> = { path: toJsStr(filePath), bypass_cache: true };
1556
+ env.set("read-file", withSig("read-file", async (...rest: any[]) => {
1557
+ const { positionals, opts } = splitArgs(rest, READ_KEYMAP, READ_NUMERIC);
1558
+ const args: Record<string, unknown> = { path: toJsStr(positionals[0]), bypass_cache: true };
1559
+ const offset = opts.offset !== undefined ? opts.offset : positionals[1];
1560
+ const limit = opts.limit !== undefined ? opts.limit : positionals[2];
1512
1561
  if (offset !== undefined && offset !== null) {
1513
1562
  const n = Number(offset);
1514
1563
  if (!isNaN(n)) args.offset = n;
@@ -1532,8 +1581,12 @@ function installBindings(
1532
1581
  }));
1533
1582
 
1534
1583
  if (editFile) {
1535
- env.set("edit-file", withSig("edit-file", async (filePath: string, oldStr: string, newStr: string, replaceAll?: any) => {
1536
- filePath = toJsStr(filePath); oldStr = toJsStr(oldStr); newStr = toJsStr(newStr);
1584
+ env.set("edit-file", withSig("edit-file", async (...rest: any[]) => {
1585
+ const { positionals, opts } = splitArgs(rest, EDIT_KEYMAP);
1586
+ const filePath = toJsStr(positionals[0]);
1587
+ const oldStr = toJsStr(positionals[1]);
1588
+ const newStr = toJsStr(positionals[2]);
1589
+ const replaceAll = opts.replace_all !== undefined ? opts.replace_all : positionals[3];
1537
1590
  const toolArgs: Record<string, unknown> = { path: filePath, old_text: oldStr, new_text: newStr };
1538
1591
  if (unwrapSchemeBool(replaceAll) === true) toolArgs.replace_all = true;
1539
1592
  const result = await withDisplay(
@@ -1545,18 +1598,6 @@ function installBindings(
1545
1598
  }
1546
1599
 
1547
1600
  if (grep) {
1548
- const GREP_KEYMAP: Record<string, string> = {
1549
- "include": "include",
1550
- "case-insensitive": "case_insensitive",
1551
- "context-before": "context_before",
1552
- "context-after": "context_after",
1553
- "limit": "head_limit",
1554
- "offset": "offset",
1555
- };
1556
- const GREP_NUMERIC = new Set([
1557
- "context_before", "context_after", "head_limit", "offset",
1558
- ]);
1559
-
1560
1601
  // Ripgrep uses Rust/ERE regex, but models write BRE (the default flavor
1561
1602
  // of plain grep/sed) where \| \( \) \{ \} \+ \? are metacharacters.
1562
1603
  // Translate BRE escapes to their ERE equivalents so the model's intent is
@@ -1569,33 +1610,33 @@ function installBindings(
1569
1610
  .replace(/\\\+/g, "+").replace(/\\\?/g, "?");
1570
1611
  };
1571
1612
 
1572
- // pattern [path] are positional; everything after is :key value options,
1573
- // split out by splitArgs (the grep macro quotes the colon symbols).
1574
- env.set("%grep", withSig("grep", async (...rest: any[]) => {
1613
+ // pattern is positional; search root is the 2nd positional or :path.
1614
+ env.set("grep", withSig("grep", async (...rest: any[]) => {
1575
1615
  const { positionals, opts } = splitArgs(rest, GREP_KEYMAP, GREP_NUMERIC);
1576
- const pStr = toJsStr(positionals[1]);
1577
1616
  const args: Record<string, unknown> = {
1578
1617
  pattern: normalizePattern(String(positionals[0] ?? "")), output_mode: "content", ...opts,
1579
1618
  };
1580
- if (typeof pStr === "string") args.path = pStr;
1619
+ const posPath = toJsStr(positionals[1]);
1620
+ if (args.path === undefined && typeof posPath === "string") args.path = posPath;
1621
+ const pStr = typeof args.path === "string" ? args.path : undefined;
1581
1622
  const result = await grep(args);
1582
1623
  if (result.isError) return nil;
1583
1624
  if (result.content === "No matches found.") return nil;
1584
1625
  const rows: unknown[] = [];
1585
1626
  for (const line of stripPagination(result.content as string)) {
1586
- const parsed = parseGrepLine(line, typeof pStr === "string" ? pStr : undefined);
1627
+ const parsed = parseGrepLine(line, pStr);
1587
1628
  if (parsed) rows.push(parsed);
1588
1629
  }
1589
1630
  return toSchemeList(rows);
1590
1631
  }));
1591
1632
 
1592
- env.set("%grep-files", withSig("grep-files", async (...rest: any[]) => {
1633
+ env.set("grep-files", withSig("grep-files", async (...rest: any[]) => {
1593
1634
  const { positionals, opts } = splitArgs(rest, GREP_KEYMAP, GREP_NUMERIC);
1594
- const pStr = toJsStr(positionals[1]);
1595
1635
  const args: Record<string, unknown> = {
1596
1636
  pattern: normalizePattern(String(positionals[0] ?? "")), output_mode: "files_with_matches", ...opts,
1597
1637
  };
1598
- if (typeof pStr === "string") args.path = pStr;
1638
+ const posPath = toJsStr(positionals[1]);
1639
+ if (args.path === undefined && typeof posPath === "string") args.path = posPath;
1599
1640
  const result = await grep(args);
1600
1641
  if (result.isError || result.content === "No matches found.") return nil;
1601
1642
  return toSchemeList(stripPagination(result.content as string));
@@ -1605,9 +1646,10 @@ function installBindings(
1605
1646
  if (glob) {
1606
1647
  // Strip leading "./" so glob paths match grep's — otherwise eq? on the
1607
1648
  // file field fails across the two.
1608
- env.set("glob", withSig("glob", async (pattern: string, p?: string) => {
1609
- const pStr = toJsStr(p);
1610
- const args: Record<string, unknown> = { pattern: toJsStr(pattern) };
1649
+ env.set("glob", withSig("glob", async (...rest: any[]) => {
1650
+ const { positionals, opts } = splitArgs(rest, GLOB_KEYMAP);
1651
+ const args: Record<string, unknown> = { pattern: toJsStr(positionals[0]) };
1652
+ const pStr = opts.path !== undefined ? String(opts.path) : toJsStr(positionals[1]);
1611
1653
  if (typeof pStr === "string") args.path = pStr;
1612
1654
  const result = await glob(args);
1613
1655
  if (result.isError || result.content === "No files matched.") return nil;
@@ -1687,9 +1729,13 @@ const DESCRIPTION = [
1687
1729
  "char and hash-table ops, …). The only novel surface is the host bindings.",
1688
1730
  "",
1689
1731
  "Calling convention:",
1690
- " - Required arguments are positional: (read-file \"x\"), (grep \"pat\" \"src/\").",
1691
- " - grep / grep-files options are :key value pairs:",
1732
+ " - Required arguments are positional: (read-file \"x\"), (grep \"pat\").",
1733
+ " - Optional arguments are :key value pairs, and the same key reads the same",
1734
+ " on every binding — :offset/:limit on read-file and grep, :timeout on bash:",
1735
+ " (read-file \"x\" :offset 40 :limit 20)",
1692
1736
  " (grep \"TODO\" \"src/\" :include \"*.ts\" :context-after 2)",
1737
+ " (A trailing positional is still accepted for each optional, but :key is",
1738
+ " the canonical form and never depends on argument order.)",
1693
1739
  " - Each binding returns the natural Scheme value for its job (a string, a",
1694
1740
  " list, a boolean); bash returns a record because you usually want the code.",
1695
1741
  "",
@@ -1704,7 +1750,7 @@ const DESCRIPTION = [
1704
1750
  "",
1705
1751
  "Composition is the point: chain read-only bindings in one submission so",
1706
1752
  "intermediate results stay in the Scheme heap instead of the conversation.",
1707
- " (map (lambda (m) (read-file (cdr (assoc 'file m)) (cdr (assoc 'line m)) 3))",
1753
+ " (map (lambda (m) (read-file (cdr (assoc 'file m)) :offset (cdr (assoc 'line m)) :limit 3))",
1708
1754
  " (grep \"TODO\" \"src/\"))",
1709
1755
  "Side-effecting calls (write-file, edit-file, mutating bash) are clearer one",
1710
1756
  "at a time so you can react to each result.",
@@ -1724,8 +1770,7 @@ const DESCRIPTION = [
1724
1770
  ].join("\n");
1725
1771
 
1726
1772
  // Scheme prelude (Lisp `define-macro`s), run after std is bootstrapped.
1727
- // cond/when/unless/newline/assq for convenience; the grep/grep-files macros
1728
- // quote :key option symbols so the bridge can read them as an option map.
1773
+ // cond/when/unless/newline/assq for convenience.
1729
1774
  const PRELUDE = `
1730
1775
  (define-macro (cond . clauses)
1731
1776
  (if (null? clauses)
@@ -1746,21 +1791,6 @@ const PRELUDE = `
1746
1791
  ;; R7RS shims for things models commonly reach for that LIPS doesn't ship.
1747
1792
  (define (newline) (display "\n"))
1748
1793
  (define assq assoc)
1749
-
1750
- ;; grep / grep-files take :key value options. LIPS has no keyword args, so a
1751
- ;; bare :include would be evaluated as an unbound variable; the macro quotes
1752
- ;; leading-colon symbols and the %grep bridge splits them from the positionals.
1753
- (define (%kw-symbol? s)
1754
- (and (symbol? s)
1755
- (let ((str (symbol->string s)))
1756
- (and (> (string-length str) 0)
1757
- (string=? (substring str 0 1) ":")))))
1758
-
1759
- (define (%quote-kw args)
1760
- (map (lambda (a) (if (%kw-symbol? a) (list 'quote a) a)) args))
1761
-
1762
- (define-macro (grep . args) (cons '%grep (%quote-kw args)))
1763
- (define-macro (grep-files . args) (cons '%grep-files (%quote-kw args)))
1764
1794
  `;
1765
1795
 
1766
1796
  export default function activate(ctx: AgentContext): void {
@@ -2,6 +2,8 @@
2
2
 
3
3
  Other extensions can customize how chat entries and tool results render — and even swap the whole TUI renderer — without forking ashi. For non-render concerns (commands, settings, tools, providers) use the standard `agent-sh` extension API; see the [agent-sh extension docs](https://github.com/guanyilun/agent-sh/blob/main/docs/extensions.md).
4
4
 
5
+ To **drive** the UI from an extension — post a notice, add a status segment, pin a dock widget, or open a select/confirm/input dialog — use the UI-surface protocol (bus events + named handlers, no `ctx.ui` object); see [`docs/ui-surface-protocol.md`](docs/ui-surface-protocol.md) and the worked example `examples/extensions/ashi-ui-demo.ts`.
6
+
5
7
  ## Chat hooks
6
8
 
7
9
  These return a renderer-agnostic chat-entry view built from the active renderer's
@@ -7,6 +7,14 @@
7
7
 
8
8
  Same backend, tools, slash commands, providers, and skills as `agent-sh`, mounted in a chat-style interface with session history, branching, and LLM-driven compaction.
9
9
 
10
+ Rendering is **decoupled** — even *how* ashi draws tool calls and results is a swappable render extension. Same agent, same conversation; load a different render extension and the whole TUI restyles, no code changes:
11
+
12
+ | pi-style rendering | claude-code-style rendering |
13
+ |---|---|
14
+ | ![ashi rendering tool calls pi-style](https://raw.githubusercontent.com/guanyilun/agent-sh/main/assets/ashi-pi-style.png) | ![ashi rendering tool calls claude-code-style](https://raw.githubusercontent.com/guanyilun/agent-sh/main/assets/ashi-claude-code-style.png) |
15
+
16
+ The claude-code-style renderer is [ashi-ink](../ashi-ink), a working Ink (React) renderer; see [Extending ashi](#extending-ashi) for the contract.
17
+
10
18
  ## Install
11
19
 
12
20
  ```bash
@@ -151,7 +159,7 @@ Each tool inherits from `default` and is overridden by its own block. Unknown to
151
159
  ## Extending ashi
152
160
 
153
161
  Other extensions can customize chat and tool-result rendering — and even swap the whole
154
- TUI renderer (pi-tui, Ink, …) — without forking ashi. See **[EXTENDING.md](EXTENDING.md)**
162
+ TUI renderer (pi-tui, [Ink](../ashi-ink), …) — without forking ashi. See **[EXTENDING.md](EXTENDING.md)**
155
163
  for the chat/tool render hooks, the declarative tool render schema, and the renderer
156
164
  contract. For non-render concerns (commands, settings, tools, providers), use the
157
165
  standard [agent-sh extension API](https://github.com/guanyilun/agent-sh/blob/main/docs/extensions.md).
@@ -167,6 +175,14 @@ export PATH="$HOME/.agent-sh/bin:$PATH"
167
175
 
168
176
  `agent-sh install` runs `npm install` and `npm run build` in the copied directory and symlinks the built bin into `~/.agent-sh/bin/`.
169
177
 
178
+ By default the copy pulls the **published** `agent-sh` from npm. When ashi's source depends on an unreleased core — a kernel change you haven't published yet — add `--dev` so the install links against the checkout you're running instead:
179
+
180
+ ```bash
181
+ agent-sh install ashi --dev --force
182
+ ```
183
+
184
+ `--dev` repoints the copied package's `agent-sh` dependency at the running host's checkout (the one the global `agent-sh` is linked to). The build then sees the local types, and a later `npm run build` at the repo root flows through without reinstalling — the kernel rebuild rule from [Development](#development) applies. Re-run the install to refresh ashi's own dist after frontend changes.
185
+
170
186
  ## Development
171
187
 
172
188
  `@guanyilun/ashi` depends on the published `agent-sh` package. To iterate against a local checkout, use `npm link`: