agent-sh 0.9.0 → 0.10.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 (78) hide show
  1. package/README.md +14 -21
  2. package/dist/agent/agent-loop.d.ts +43 -3
  3. package/dist/agent/agent-loop.js +811 -128
  4. package/dist/agent/conversation-state.d.ts +72 -21
  5. package/dist/agent/conversation-state.js +357 -150
  6. package/dist/agent/history-file.d.ts +13 -4
  7. package/dist/agent/history-file.js +110 -36
  8. package/dist/agent/nuclear-form.d.ts +28 -3
  9. package/dist/agent/nuclear-form.js +84 -3
  10. package/dist/agent/skills.d.ts +2 -4
  11. package/dist/agent/skills.js +10 -4
  12. package/dist/agent/subagent.d.ts +23 -0
  13. package/dist/agent/subagent.js +53 -11
  14. package/dist/agent/system-prompt.d.ts +34 -1
  15. package/dist/agent/system-prompt.js +96 -47
  16. package/dist/agent/token-budget.d.ts +5 -4
  17. package/dist/agent/token-budget.js +14 -19
  18. package/dist/agent/tool-protocol.d.ts +23 -1
  19. package/dist/agent/tool-protocol.js +169 -4
  20. package/dist/agent/tools/bash.js +3 -3
  21. package/dist/agent/tools/edit-file.js +9 -6
  22. package/dist/agent/tools/glob.js +4 -2
  23. package/dist/agent/tools/grep.js +27 -3
  24. package/dist/agent/tools/ls.js +5 -6
  25. package/dist/agent/types.d.ts +1 -1
  26. package/dist/context-manager.d.ts +17 -0
  27. package/dist/context-manager.js +37 -4
  28. package/dist/core.js +27 -6
  29. package/dist/event-bus.d.ts +59 -2
  30. package/dist/executor.d.ts +4 -3
  31. package/dist/executor.js +18 -15
  32. package/dist/extension-loader.js +50 -13
  33. package/dist/extensions/agent-backend.d.ts +8 -7
  34. package/dist/extensions/agent-backend.js +69 -48
  35. package/dist/extensions/index.js +0 -1
  36. package/dist/extensions/slash-commands.js +14 -9
  37. package/dist/extensions/tui-renderer.js +62 -78
  38. package/dist/index.js +25 -6
  39. package/dist/settings.d.ts +36 -5
  40. package/dist/settings.js +53 -9
  41. package/dist/shell/input-handler.d.ts +2 -1
  42. package/dist/shell/input-handler.js +82 -73
  43. package/dist/shell/shell.js +19 -2
  44. package/dist/types.d.ts +12 -0
  45. package/dist/utils/ansi.d.ts +5 -0
  46. package/dist/utils/ansi.js +1 -1
  47. package/dist/utils/compositor.d.ts +5 -0
  48. package/dist/utils/compositor.js +31 -3
  49. package/dist/utils/diff-renderer.d.ts +9 -0
  50. package/dist/utils/diff-renderer.js +221 -143
  51. package/dist/utils/diff.d.ts +21 -2
  52. package/dist/utils/diff.js +165 -89
  53. package/dist/utils/handler-registry.d.ts +5 -0
  54. package/dist/utils/handler-registry.js +6 -0
  55. package/dist/utils/line-editor.d.ts +11 -1
  56. package/dist/utils/line-editor.js +44 -5
  57. package/dist/utils/tool-display.d.ts +1 -1
  58. package/dist/utils/tool-display.js +4 -4
  59. package/examples/extensions/ash-acp-bridge/src/index.ts +4 -1
  60. package/examples/extensions/ash-mcp-bridge/index.ts +13 -3
  61. package/examples/extensions/claude-code-bridge/index.ts +198 -51
  62. package/examples/extensions/claude-code-bridge/package.json +1 -0
  63. package/examples/extensions/interactive-prompts.ts +39 -25
  64. package/examples/extensions/overlay-agent.ts +3 -3
  65. package/examples/extensions/peer-mesh.ts +115 -0
  66. package/examples/extensions/pi-bridge/index.ts +2 -2
  67. package/examples/extensions/questionnaire.ts +16 -5
  68. package/examples/extensions/subagents.ts +19 -4
  69. package/examples/extensions/terminal-buffer.ts +163 -0
  70. package/examples/extensions/user-shell.ts +136 -0
  71. package/examples/extensions/web-access.ts +8 -0
  72. package/package.json +36 -2
  73. package/dist/agent/tools/display.d.ts +0 -13
  74. package/dist/agent/tools/display.js +0 -70
  75. package/dist/agent/tools/user-shell.d.ts +0 -13
  76. package/dist/agent/tools/user-shell.js +0 -87
  77. package/dist/extensions/terminal-buffer.d.ts +0 -14
  78. package/dist/extensions/terminal-buffer.js +0 -134
@@ -1,23 +1,22 @@
1
1
  import { AgentLoop } from "../agent/agent-loop.js";
2
2
  import { LlmClient } from "../utils/llm-client.js";
3
3
  import { resolveProvider, getProviderNames, getSettings } from "../settings.js";
4
+ /** Read the user's persisted defaultModel for a provider, if any. */
5
+ function persistedModelFor(providerName) {
6
+ if (!providerName)
7
+ return undefined;
8
+ return getSettings().providers?.[providerName]?.defaultModel;
9
+ }
4
10
  export default function agentBackend(ctx) {
5
11
  const { bus } = ctx;
6
- // ── Resolve providers ──────────────────────────────────────
7
12
  const config = ctx.call("config:get-shell-config") ?? {};
8
- const settings = getSettings();
9
- let activeProvider = null;
13
+ // Seed from settings.json; runtime provider:register events add more.
10
14
  const providerRegistry = new Map();
11
15
  for (const name of getProviderNames()) {
12
16
  const p = resolveProvider(name);
13
17
  if (p)
14
18
  providerRegistry.set(name, p);
15
19
  }
16
- const providerName = config.provider ?? settings.defaultProvider;
17
- if (providerName) {
18
- activeProvider = providerRegistry.get(providerName) ?? null;
19
- }
20
- // ── Build modes ────────────────────────────────────────────
21
20
  const buildModes = () => {
22
21
  const allModes = [];
23
22
  for (const [id, p] of providerRegistry) {
@@ -37,48 +36,38 @@ export default function agentBackend(ctx) {
37
36
  }
38
37
  return allModes;
39
38
  };
40
- const effectiveApiKey = config.apiKey ?? activeProvider?.apiKey;
41
- const effectiveBaseURL = config.baseURL ?? activeProvider?.baseURL;
42
- const effectiveModel = config.model ?? activeProvider?.defaultModel;
43
- let modes = buildModes();
44
- if (modes.length === 0 && effectiveApiKey && effectiveModel) {
45
- modes = [{ model: effectiveModel }];
46
- }
47
- const initialModeIndex = Math.max(0, modes.findIndex((m) => m.model === effectiveModel && (!activeProvider || m.provider === activeProvider.id)));
48
- // ── Create LLM client ─────────────────────────────────────
49
- if (!effectiveApiKey)
50
- return; // No LLM provider configured — skip
51
- if (!effectiveModel) {
52
- bus.emit("ui:error", { message: "No model specified. Use --model or configure a provider with defaultModel in ~/.agent-sh/settings.json" });
53
- return;
54
- }
55
- const llmClient = new LlmClient({
56
- apiKey: effectiveApiKey,
57
- baseURL: effectiveBaseURL,
58
- model: effectiveModel,
59
- });
60
- // Expose LLM client for other extensions (e.g. command-suggest)
39
+ // Placeholder client — reconfigured at core:extensions-loaded. Any
40
+ // stream() call before then fails from the OpenAI SDK; start() won't
41
+ // wire the loop until we've resolved, so users never hit that path.
42
+ const llmClient = new LlmClient({ apiKey: "not-configured", model: "not-configured" });
61
43
  ctx.define("llm:get-client", () => llmClient);
62
- // ── Initial modes (queryable via pipe) ─────────────────────
63
- bus.onPipe("config:get-initial-modes", () => ({
64
- modes,
65
- initialModeIndex,
66
- }));
67
- // ── Create agent loop ──────────────────────────────────────
44
+ let modes = [];
45
+ let initialModeIndex = 0;
46
+ let resolved = false;
47
+ bus.onPipe("config:get-initial-modes", () => ({ modes, initialModeIndex }));
48
+ // AgentLoop must be constructed *before* user extensions activate,
49
+ // because its ctor defines handlers (history:append, etc.) that
50
+ // extensions like superash call synchronously during their own
51
+ // activate. Advise-before-define works for advisers, but plain calls
52
+ // would hit a no-op stub.
68
53
  const agentLoop = new AgentLoop({
69
54
  bus,
70
55
  contextManager: ctx.contextManager,
71
56
  llmClient,
72
- handlers: { define: ctx.define, advise: ctx.advise, call: ctx.call },
57
+ handlers: { define: ctx.define, advise: ctx.advise, call: ctx.call, list: ctx.list },
73
58
  modes,
74
59
  initialModeIndex,
75
60
  compositor: ctx.compositor,
61
+ instanceId: ctx.instanceId,
76
62
  });
77
- // Register as backend
78
63
  bus.emit("agent:register-backend", {
79
64
  name: "ash",
80
65
  kill: () => agentLoop.kill(),
81
66
  start: async () => {
67
+ if (!resolved) {
68
+ bus.emit("ui:error", { message: "Agent backend not started — no LLM provider available. See earlier messages." });
69
+ return;
70
+ }
82
71
  agentLoop.wire();
83
72
  bus.emit("agent:info", {
84
73
  name: "ash",
@@ -89,7 +78,34 @@ export default function agentBackend(ctx) {
89
78
  });
90
79
  },
91
80
  });
92
- // ── Runtime provider registration ──────────────────────────
81
+ bus.on("core:extensions-loaded", () => {
82
+ const settings = getSettings();
83
+ const providerName = config.provider ?? settings.defaultProvider;
84
+ const activeProvider = providerName ? providerRegistry.get(providerName) ?? null : null;
85
+ // User's persisted defaultModel wins over the provider's declared
86
+ // default. Dynamic providers (openrouter) re-register with their
87
+ // hardcoded DEFAULT_MODELS[0] each startup, which would otherwise
88
+ // clobber the user's /model selection.
89
+ const effectiveApiKey = config.apiKey ?? activeProvider?.apiKey;
90
+ const effectiveBaseURL = config.baseURL ?? activeProvider?.baseURL;
91
+ const effectiveModel = config.model ?? persistedModelFor(providerName) ?? activeProvider?.defaultModel;
92
+ if (!effectiveApiKey) {
93
+ bus.emit("ui:error", { message: "No LLM provider configured. Set --api-key, configure a provider in ~/.agent-sh/settings.json, or load a provider extension (e.g. openrouter) that sets OPENROUTER_API_KEY." });
94
+ return;
95
+ }
96
+ if (!effectiveModel) {
97
+ bus.emit("ui:error", { message: "No model specified. Use --model or configure a provider with defaultModel in ~/.agent-sh/settings.json" });
98
+ return;
99
+ }
100
+ modes = buildModes();
101
+ if (modes.length === 0)
102
+ modes = [{ model: effectiveModel }];
103
+ initialModeIndex = Math.max(0, modes.findIndex((m) => m.model === effectiveModel && (!activeProvider || m.provider === activeProvider.id)));
104
+ llmClient.reconfigure({ apiKey: effectiveApiKey, baseURL: effectiveBaseURL, model: effectiveModel });
105
+ bus.emit("config:set-modes", { modes, activeIndex: initialModeIndex });
106
+ resolved = true;
107
+ // start() emits agent:info after wiring.
108
+ });
93
109
  bus.on("provider:register", (p) => {
94
110
  const rawModels = p.models ?? (p.defaultModel ? [p.defaultModel] : []);
95
111
  const modelIds = [];
@@ -124,16 +140,26 @@ export default function agentBackend(ctx) {
124
140
  };
125
141
  });
126
142
  bus.emit("config:add-modes", { modes: addModes });
143
+ // Late-registration reconcile: if this completes the user's persisted
144
+ // default (openrouter's async fetch delivers the full catalog after
145
+ // we've already fallen back to mode 0), quietly switch to it.
146
+ if (!resolved)
147
+ return;
148
+ const pendingProvider = getSettings().defaultProvider;
149
+ if (pendingProvider !== p.id)
150
+ return;
151
+ const pendingModel = persistedModelFor(pendingProvider);
152
+ if (pendingModel && modelIds.includes(pendingModel) && llmClient.model !== pendingModel) {
153
+ bus.emit("config:switch-model", { model: pendingModel });
154
+ }
127
155
  });
128
- // ── Runtime provider switching ─────────────────────────────
129
156
  bus.on("config:switch-provider", ({ provider: name }) => {
130
157
  const p = providerRegistry.get(name);
131
158
  if (!p) {
132
159
  bus.emit("ui:error", { message: `Unknown provider: ${name}` });
133
160
  return;
134
161
  }
135
- const newApiKey = p.apiKey;
136
- if (!newApiKey) {
162
+ if (!p.apiKey) {
137
163
  bus.emit("ui:error", { message: `Provider "${name}" has no API key configured` });
138
164
  return;
139
165
  }
@@ -142,24 +168,19 @@ export default function agentBackend(ctx) {
142
168
  bus.emit("ui:error", { message: `Provider "${name}" has no models configured` });
143
169
  return;
144
170
  }
145
- llmClient.reconfigure({
146
- apiKey: newApiKey,
147
- baseURL: p.baseURL,
148
- model: switchModel,
149
- });
171
+ llmClient.reconfigure({ apiKey: p.apiKey, baseURL: p.baseURL, model: switchModel });
150
172
  const newModes = p.models.map((m) => {
151
173
  const mc = p.modelCapabilities?.get(m);
152
174
  return {
153
175
  model: m,
154
176
  provider: name,
155
- providerConfig: { apiKey: newApiKey, baseURL: p.baseURL },
177
+ providerConfig: { apiKey: p.apiKey, baseURL: p.baseURL },
156
178
  contextWindow: mc?.contextWindow ?? p.contextWindow,
157
179
  reasoning: mc?.reasoning,
158
180
  supportsReasoningEffort: p.supportsReasoningEffort,
159
181
  };
160
182
  });
161
183
  bus.emit("config:set-modes", { modes: newModes });
162
- activeProvider = p;
163
184
  bus.emit("agent:info", { name: "ash", version: "0.4", model: switchModel, provider: name, contextWindow: p.contextWindow });
164
185
  bus.emit("ui:info", { message: `Switched to ${name} (${switchModel})` });
165
186
  bus.emit("config:changed", {});
@@ -5,7 +5,6 @@ export const BUILTIN_EXTENSIONS = [
5
5
  { name: "file-autocomplete", load: () => import("./file-autocomplete.js").then(m => m.default) },
6
6
  { name: "shell-recall", load: () => import("./shell-recall.js").then(m => m.default) },
7
7
  { name: "command-suggest", load: () => import("./command-suggest.js").then(m => m.default) },
8
- { name: "terminal-buffer", load: () => import("./terminal-buffer.js").then(m => m.default) },
9
8
  ];
10
9
  /**
11
10
  * Load built-in extensions sequentially, skipping any in the disabled list.
@@ -79,7 +79,7 @@ export default function activate(ctx) {
79
79
  });
80
80
  register({
81
81
  name: "/compact",
82
- description: "Compact conversation (move full content to nuclear summaries)",
82
+ description: "Compact conversation via the active compaction strategy",
83
83
  handler: () => {
84
84
  bus.emit("agent:compact-request", {});
85
85
  },
@@ -90,19 +90,15 @@ export default function activate(ctx) {
90
90
  handler: () => {
91
91
  const stats = bus.emitPipe("context:get-stats", {
92
92
  activeTokens: 0,
93
- nuclearEntries: 0,
94
- recallArchiveSize: 0,
93
+ totalTokens: 0,
95
94
  budgetTokens: 0,
96
95
  });
97
96
  const pct = stats.budgetTokens > 0
98
97
  ? Math.round((stats.activeTokens / stats.budgetTokens) * 100)
99
98
  : 0;
100
- const lines = [
101
- `Active context: ~${stats.activeTokens.toLocaleString()} tokens / ${stats.budgetTokens.toLocaleString()} budget (${pct}%)`,
102
- `Nuclear entries: ${stats.nuclearEntries} in-context`,
103
- `Recall archive: ${stats.recallArchiveSize} entries`,
104
- ];
105
- bus.emit("ui:info", { message: lines.join("\n") });
99
+ bus.emit("ui:info", {
100
+ message: `Active context: ~${stats.activeTokens.toLocaleString()} tokens / ${stats.budgetTokens.toLocaleString()} budget (${pct}%)`,
101
+ });
106
102
  },
107
103
  });
108
104
  register({
@@ -118,10 +114,19 @@ export default function activate(ctx) {
118
114
  }
119
115
  },
120
116
  });
117
+ // Handler form so extensions can trigger reload programmatically
118
+ // (e.g. an ash-callable reload_extensions tool in superash).
119
+ ctx.define("extensions:reload", async () => {
120
+ return await reloadExtensions(ctx);
121
+ });
121
122
  // ── Extension registration ────────────────────────────────────
122
123
  bus.on("command:register", (cmd) => {
123
124
  register(cmd);
124
125
  });
126
+ bus.on("command:unregister", ({ name }) => {
127
+ const key = name.startsWith("/") ? name : `/${name}`;
128
+ commands.delete(key);
129
+ });
125
130
  // ── Skill commands (/skill:<name>) ────────────────────────────
126
131
  const getSkills = () => {
127
132
  const cwd = contextManager?.getCwd() ?? process.cwd();
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { highlight } from "cli-highlight";
14
14
  import { MarkdownRenderer, wrapLine, MAX_CONTENT_WIDTH } from "../utils/markdown.js";
15
+ import { DEFAULT_CONTEXT_WINDOW } from "../agent/token-budget.js";
15
16
  import { createFencedBlockTransform } from "../utils/stream-transform.js";
16
17
  import { palette as p } from "../utils/palette.js";
17
18
  import { renderToolCall, createSpinner, formatElapsed, SPINNER_FRAMES, } from "../utils/tool-display.js";
@@ -63,12 +64,14 @@ function createRenderState() {
63
64
  isThinking: false,
64
65
  showThinkingText: false,
65
66
  thinkingPending: false,
66
- lastTruncatedDiff: null,
67
67
  };
68
68
  }
69
69
  export default function activate(ctx) {
70
70
  const { bus, define, compositor } = ctx;
71
71
  const s = createRenderState();
72
+ /** Track the shell's cwd so path shortening is relative to where the user actually is. */
73
+ let shellCwd = process.cwd();
74
+ bus.on("shell:cwd-change", (e) => { shellCwd = e.cwd; });
72
75
  /** Shorthand — get the current agent surface. */
73
76
  function out() { return compositor.surface("agent"); }
74
77
  /** Capped width for borders, tool lines, and content — keeps everything aligned. */
@@ -226,7 +229,7 @@ export default function activate(ctx) {
226
229
  s.isThinking = false;
227
230
  if (pendingUsage && s.renderer) {
228
231
  const { prompt_tokens, completion_tokens } = pendingUsage;
229
- const maxTokens = backendInfo?.contextWindow ?? 128_000;
232
+ const maxTokens = backendInfo?.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
230
233
  s.renderer.writeLine("");
231
234
  s.renderer.writeLine(ctx.call("tui:render-usage", prompt_tokens, completion_tokens, maxTokens));
232
235
  drain();
@@ -379,17 +382,37 @@ export default function activate(ctx) {
379
382
  s.renderer.flush();
380
383
  drain();
381
384
  }
385
+ // Diff rendering is handled in the async pipe below so it can yield
386
+ // to the event loop between hunks (keeping the spinner responsive).
387
+ });
388
+ // Async pipe: render diffs via the tui:render-diff handler (extensions can
389
+ // advise to customize). Runs after the sync `on` handler above (which
390
+ // flushes state) and before shell.ts's pipe (which pauses stdout).
391
+ bus.onPipeAsync("permission:request", async (e) => {
392
+ if (!shouldRender())
393
+ return e;
382
394
  if (e.kind === "file-write" && e.metadata?.diff) {
383
395
  showCollapsedThinking();
384
- showFileDiff(e.title, e.metadata.diff);
396
+ const lines = ctx.call("tui:render-diff", e.title, e.metadata.diff, cappedW());
397
+ if (lines.length > 0) {
398
+ if (!s.renderer)
399
+ startAgentResponse();
400
+ contentGap("diff");
401
+ for (const line of lines)
402
+ s.renderer.writeLine(line);
403
+ drain();
404
+ }
405
+ // The diff box IS the visual representation of the upcoming tool call.
406
+ // Mark lastContentKind as "tool" so the tool call line that follows
407
+ // doesn't inject an extra gap between the diff box and the checkmark.
408
+ s.lastContentKind = "tool";
385
409
  }
386
410
  // Don't endAgentResponse() here — permission requests that aren't
387
411
  // file-write diffs are handled inline (auto-approved or by extensions).
388
412
  // Closing the response prematurely causes double separator borders.
413
+ return e;
389
414
  });
390
415
  bus.on("input:keypress", (e) => {
391
- if (e.key === "\x0f")
392
- expandLastDiff(); // Ctrl+O
393
416
  if (e.key === "\x14")
394
417
  toggleThinkingDisplay(); // Ctrl+T
395
418
  });
@@ -518,9 +541,23 @@ export default function activate(ctx) {
518
541
  }
519
542
  let highlighted;
520
543
  try {
521
- highlighted = language
522
- ? highlight(code, { language })
523
- : highlight(code); // auto-detect
544
+ // highlight.js warns to console.error for unsupported languages (elisp, org, etc).
545
+ // Suppress so it doesn't leak into the terminal.
546
+ const origError = console.error;
547
+ console.error = (...args) => {
548
+ const msg = args.join(" ");
549
+ if (msg.includes("Could not find the language"))
550
+ return;
551
+ origError.apply(console, args);
552
+ };
553
+ try {
554
+ highlighted = language
555
+ ? highlight(code, { language })
556
+ : highlight(code); // auto-detect
557
+ }
558
+ finally {
559
+ console.error = origError;
560
+ }
524
561
  }
525
562
  catch {
526
563
  highlighted = code;
@@ -574,6 +611,17 @@ export default function activate(ctx) {
574
611
  }
575
612
  return [];
576
613
  });
614
+ /**
615
+ * Default renderer for standalone diffs (e.g. permission prompts).
616
+ * Extensions can advise this to customize diff rendering:
617
+ *
618
+ * ctx.advise("tui:render-diff", (next, filePath, diff, width) => {
619
+ * return myCustomDiffBox(filePath, diff, width);
620
+ * });
621
+ */
622
+ define("tui:render-diff", (filePath, diff, width) => {
623
+ return renderDiffBody(diff, filePath, width);
624
+ });
577
625
  /** Render a diff as framed box lines (pure — no TUI state side effects). */
578
626
  function renderDiffBody(diff, filePath, width) {
579
627
  if (diff.isIdentical)
@@ -586,18 +634,8 @@ export default function activate(ctx) {
586
634
  maxLines: getSettings().diffMaxLines,
587
635
  trueColor: true,
588
636
  });
589
- const lastLine = diffLines[diffLines.length - 1] ?? "";
590
- const isTruncated = lastLine.includes("… ");
591
- if (isTruncated) {
592
- s.lastTruncatedDiff = { filePath, diff, expanded: false };
593
- }
594
- else {
595
- s.lastTruncatedDiff = null;
596
- }
597
637
  const body = diffLines.length > 1 ? ["", ...diffLines.slice(1), ""] : diffLines;
598
- const footer = isTruncated
599
- ? [` ${p.dim}ctrl+o to expand${p.reset}`]
600
- : undefined;
638
+ const footer = undefined;
601
639
  return renderBoxFrame(body, {
602
640
  width: boxW,
603
641
  style: "rounded",
@@ -625,11 +663,10 @@ export default function activate(ctx) {
625
663
  function extractDetail(extra) {
626
664
  if (extra.locations && extra.locations.length > 0) {
627
665
  const loc = extra.locations[0];
628
- const cwd = process.cwd();
629
666
  const home = process.env.HOME;
630
667
  let fp = loc.path;
631
- if (fp.startsWith(cwd + "/"))
632
- fp = fp.slice(cwd.length + 1);
668
+ if (fp.startsWith(shellCwd + "/"))
669
+ fp = fp.slice(shellCwd.length + 1);
633
670
  else if (home && fp.startsWith(home + "/"))
634
671
  fp = "~/" + fp.slice(home.length + 1);
635
672
  return loc.line ? `${fp}:${loc.line}` : fp;
@@ -642,11 +679,10 @@ export default function activate(ctx) {
642
679
  if (typeof raw.pattern === "string")
643
680
  return raw.pattern;
644
681
  if (typeof raw.path === "string") {
645
- const cwd = process.cwd();
646
682
  const home = process.env.HOME;
647
683
  let fp = raw.path;
648
- if (fp.startsWith(cwd + "/"))
649
- fp = fp.slice(cwd.length + 1);
684
+ if (fp.startsWith(shellCwd + "/"))
685
+ fp = fp.slice(shellCwd.length + 1);
650
686
  else if (home && fp.startsWith(home + "/"))
651
687
  fp = "~/" + fp.slice(home.length + 1);
652
688
  return fp;
@@ -674,7 +710,7 @@ export default function activate(ctx) {
674
710
  locations: extra?.locations,
675
711
  rawInput: extra?.rawInput,
676
712
  displayDetail: extra?.displayDetail,
677
- }, cappedW());
713
+ }, cappedW(), shellCwd);
678
714
  if (extra?.groupContinuation && lines.length > 0) {
679
715
  // Swap the colored kind icon for a muted tree connector,
680
716
  // and strip the tool name prefix — show detail only.
@@ -879,58 +915,6 @@ export default function activate(ctx) {
879
915
  : `${p.success}+${diff.added}${p.reset} ${p.error}-${diff.removed}${p.reset}`;
880
916
  return `${p.dim}${filePath}${p.reset} ${stats}`;
881
917
  }
882
- function showFileDiff(filePath, diff) {
883
- if (diff.isIdentical)
884
- return;
885
- contentGap("diff");
886
- const lines = ctx.call("render:result-body", { kind: "diff", diff, filePath }, cappedW()) ?? [];
887
- if (!s.renderer)
888
- startAgentResponse();
889
- for (const line of lines) {
890
- s.renderer.writeLine(line);
891
- }
892
- drain();
893
- }
894
- function expandLastDiff() {
895
- if (!s.lastTruncatedDiff)
896
- return;
897
- const entry = s.lastTruncatedDiff;
898
- entry.expanded = !entry.expanded;
899
- if (!entry.expanded) {
900
- showFileDiffCached(entry);
901
- return;
902
- }
903
- if (!entry.expandedLines) {
904
- const { filePath, diff } = entry;
905
- const boxW = Math.min(cappedW() - 2, out().columns - 2); // -2 for writeLine indent
906
- const contentW = boxW - 4;
907
- const diffLines = renderDiff(diff, {
908
- width: contentW,
909
- filePath,
910
- maxLines: 500,
911
- trueColor: true,
912
- });
913
- const body = diffLines.length > 1 ? ["", ...diffLines.slice(1), ""] : diffLines;
914
- entry.expandedLines = renderBoxFrame(body, {
915
- width: boxW,
916
- style: "rounded",
917
- borderColor: p.dim,
918
- title: diffTitle(filePath, diff),
919
- footer: [` ${p.dim}ctrl+o to collapse${p.reset}`],
920
- });
921
- }
922
- out().write("\n");
923
- for (const line of entry.expandedLines) {
924
- out().write(line + "\n");
925
- }
926
- }
927
- function showFileDiffCached(entry) {
928
- const lines = ctx.call("render:result-body", { kind: "diff", diff: entry.diff, filePath: entry.filePath }, cappedW()) ?? [];
929
- out().write("\n");
930
- for (const line of lines) {
931
- out().write(line + "\n");
932
- }
933
- }
934
918
  function toggleThinkingDisplay() {
935
919
  s.showThinkingText = !s.showThinkingText;
936
920
  if (s.spinner) {
package/dist/index.js CHANGED
@@ -15,6 +15,13 @@ import { discoverSkills } from "./agent/skills.js";
15
15
  */
16
16
  async function captureShellEnvAsync(shell) {
17
17
  return new Promise((resolve) => {
18
+ let settled = false;
19
+ const done = (result) => {
20
+ if (settled)
21
+ return;
22
+ settled = true;
23
+ resolve(result);
24
+ };
18
25
  try {
19
26
  const shellName = path.basename(shell);
20
27
  const isZsh = shellName.includes("zsh");
@@ -30,8 +37,9 @@ async function captureShellEnvAsync(shell) {
30
37
  output += data.toString("utf-8");
31
38
  });
32
39
  child.on("close", (code) => {
40
+ clearTimeout(timer);
33
41
  if (code !== 0 || !output) {
34
- resolve({});
42
+ done({});
35
43
  return;
36
44
  }
37
45
  const env = {};
@@ -40,18 +48,19 @@ async function captureShellEnvAsync(shell) {
40
48
  if (eq > 0)
41
49
  env[entry.slice(0, eq)] = entry.slice(eq + 1);
42
50
  }
43
- resolve(env);
51
+ done(env);
44
52
  });
45
53
  child.on("error", () => {
46
- resolve({});
54
+ clearTimeout(timer);
55
+ done({});
47
56
  });
48
- setTimeout(() => {
57
+ const timer = setTimeout(() => {
49
58
  child.kill("SIGTERM");
50
- resolve({});
59
+ done({});
51
60
  }, 5000);
52
61
  }
53
62
  catch {
54
- resolve({});
63
+ done({});
55
64
  }
56
65
  });
57
66
  }
@@ -245,6 +254,9 @@ async function main() {
245
254
  if (process.env.DEBUG) {
246
255
  console.error('[agent-sh] Extensions loaded');
247
256
  }
257
+ // Tell deferred-init listeners (agent-backend) that the provider
258
+ // registry is now complete.
259
+ core.bus.emit("core:extensions-loaded", {});
248
260
  // ── Discover skills ───────────────────────────────────────────
249
261
  const skills = discoverSkills(process.cwd());
250
262
  // ── Activate agent backend ────────────────────────────────────
@@ -281,6 +293,13 @@ async function main() {
281
293
  sections += `\n ${p.dim}${s.name}${p.reset}`;
282
294
  }
283
295
  }
296
+ const extSections = bus.emitPipe("banner:collect", { sections: [] }).sections;
297
+ for (const sec of extSections) {
298
+ sections += `\n\n ${p.muted}${sec.label}:${p.reset}`;
299
+ for (const item of sec.items) {
300
+ sections += `\n ${p.dim}${item}${p.reset}`;
301
+ }
302
+ }
284
303
  const hint = `${p.muted}Type ${p.warning}>${p.muted} to ask AI · ${p.warning}>/help${p.muted} for commands${p.reset}`;
285
304
  const borderLine = `${p.muted}${"─".repeat(bannerW)}${p.reset}`;
286
305
  process.stdout.write("\n" + borderLine + "\n" +
@@ -50,26 +50,46 @@ export interface Settings {
50
50
  historyMaxBytes?: number;
51
51
  /** Number of prior history entries to load on startup (default: 50). */
52
52
  historyStartupEntries?: number;
53
- /** Max nuclear entries kept in-context before flushing to history file (default: 200). */
54
- nuclearMaxEntries?: number;
55
53
  /** Auto-compact threshold as fraction of conversation budget (0-1, default 0.5). */
56
54
  autoCompactThreshold?: number;
57
55
  /** Max command output lines shown inline in TUI. */
58
56
  maxCommandOutputLines?: number;
59
57
  /** Max read tool output lines shown inline in TUI (0 = hide). */
60
58
  readOutputMaxLines?: number;
61
- /** Max diff lines shown before "ctrl+o to expand". */
59
+ /** Max diff lines rendered in the TUI (Infinity = no limit). */
62
60
  diffMaxLines?: number;
63
- /** Tool protocol: "api" (all tools), "deferred" (extensions via meta-tool), "inline" (text). */
64
- toolMode?: "api" | "deferred" | "inline";
61
+ /** Tool protocol:
62
+ * "api" all tools sent with full schema.
63
+ * "deferred" — extensions dispatched through `use_extension(name, args)` meta-tool.
64
+ * "deferred-lookup" — extensions loaded on demand via `load_tool(names[])`; once loaded, callable as first-class tools.
65
+ * "inline" — tools described as text.
66
+ */
67
+ toolMode?: "api" | "deferred" | "deferred-lookup" | "inline";
65
68
  /** Additional directories to scan for skills (supports ~ expansion). */
66
69
  skillPaths?: string[];
70
+ /**
71
+ * Enable the "diagnose" tool — lets the agent evaluate JavaScript
72
+ * expressions against its own runtime state. Powerful for introspection
73
+ * (e.g. this.conversation.turns.length) but grants arbitrary code
74
+ * execution within the agent process. Off by default because the
75
+ * agent already has unrestricted bash access — this is a convenience,
76
+ * not a new capability.
77
+ */
78
+ diagnose?: boolean;
67
79
  /** Show a startup banner when agent-sh launches. */
68
80
  startupBanner?: boolean;
69
81
  /** Show a subtle agent-sh indicator in the shell prompt. */
70
82
  promptIndicator?: boolean;
71
83
  /** Names of built-in extensions to disable (e.g. ["command-suggest"]). */
72
84
  disabledBuiltins?: string[];
85
+ /**
86
+ * Names of user extensions in ~/.agent-sh/extensions/ to skip when
87
+ * auto-discovering. Match by basename without extension for files
88
+ * (e.g. "peer-mesh" matches peer-mesh.ts), or by directory name for
89
+ * directory-style extensions (e.g. "superash" matches superash/index.ts).
90
+ * Beats having to rename files to .disabled every time.
91
+ */
92
+ disabledExtensions?: string[];
73
93
  }
74
94
  declare const DEFAULTS: Required<Settings>;
75
95
  /** Load settings from disk (cached after first call). */
@@ -87,6 +107,17 @@ export declare function getSettings(): Settings & typeof DEFAULTS;
87
107
  export declare function getExtensionSettings<T extends Record<string, unknown>>(namespace: string, defaults: T): T;
88
108
  /** Reset cached settings (for testing or after external edit). */
89
109
  export declare function reloadSettings(): void;
110
+ /**
111
+ * Deep-merge a patch into ~/.agent-sh/settings.json on disk.
112
+ *
113
+ * Reads the raw file (preserving unknown keys), merges the patch, writes back
114
+ * with 2-space indentation, and clears the cache so subsequent getSettings()
115
+ * calls see the new values.
116
+ *
117
+ * Used by runtime controls (`/model`, `/backend`) that want their selection
118
+ * to persist as the default across restarts.
119
+ */
120
+ export declare function updateSettings(patch: Record<string, unknown>): void;
90
121
  /**
91
122
  * Expand $ENV_VAR references in a string.
92
123
  * Supports $VAR and ${VAR} syntax.