agent-sh 0.14.10 → 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 (97) hide show
  1. package/README.md +36 -13
  2. package/dist/agent/agent-loop.d.ts +9 -17
  3. package/dist/agent/agent-loop.js +123 -150
  4. package/dist/agent/events.d.ts +10 -12
  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 +76 -29
  8. package/dist/agent/live-view.d.ts +3 -3
  9. package/dist/agent/live-view.js +15 -7
  10. package/dist/agent/providers/deepseek.js +9 -1
  11. package/dist/agent/providers/openrouter.js +9 -0
  12. package/dist/agent/session-store.js +1 -1
  13. package/dist/agent/subagent.js +1 -1
  14. package/dist/agent/system-prompt.d.ts +7 -3
  15. package/dist/agent/system-prompt.js +11 -14
  16. package/dist/agent/tool-protocol.js +0 -7
  17. package/dist/cli/args.js +2 -1
  18. package/dist/cli/install.d.ts +1 -0
  19. package/dist/cli/install.js +39 -2
  20. package/dist/cli/subcommands.js +1 -0
  21. package/dist/core/event-bus.js +0 -2
  22. package/dist/core/extension-loader.js +3 -1
  23. package/dist/core/index.d.ts +1 -1
  24. package/dist/core/index.js +3 -2
  25. package/dist/extensions/slash-commands/index.js +16 -11
  26. package/dist/shell/events.d.ts +3 -0
  27. package/dist/shell/index.js +9 -0
  28. package/dist/shell/shell-context.d.ts +2 -2
  29. package/dist/shell/shell-context.js +26 -11
  30. package/dist/shell/shell.js +3 -0
  31. package/dist/shell/tui-renderer.js +0 -1
  32. package/dist/utils/diff-renderer.d.ts +4 -0
  33. package/dist/utils/diff-renderer.js +15 -27
  34. package/dist/utils/handler-registry.d.ts +1 -6
  35. package/dist/utils/handler-registry.js +1 -6
  36. package/dist/utils/line-editor.js +0 -2
  37. package/dist/utils/palette.js +4 -4
  38. package/dist/utils/terminal-buffer.d.ts +2 -0
  39. package/dist/utils/terminal-buffer.js +4 -0
  40. package/examples/extensions/ads/SKILL.md +170 -0
  41. package/examples/extensions/ash-acp-bridge/src/index.ts +11 -7
  42. package/examples/extensions/ash-scheme/index.ts +377 -687
  43. package/examples/extensions/ash-scheme/package.json +1 -1
  44. package/examples/extensions/ashi/EXTENDING.md +118 -0
  45. package/examples/extensions/ashi/README.md +26 -54
  46. package/examples/extensions/ashi/docs/ui-surface-protocol.md +163 -0
  47. package/examples/extensions/ashi/package.json +14 -2
  48. package/examples/extensions/ashi/src/autocomplete-controller.ts +95 -0
  49. package/examples/extensions/ashi/src/autocomplete.ts +1 -23
  50. package/examples/extensions/ashi/src/capture.ts +54 -10
  51. package/examples/extensions/ashi/src/chat/assistant.ts +67 -0
  52. package/examples/extensions/ashi/src/chat/lines.ts +39 -0
  53. package/examples/extensions/ashi/src/chat/thinking.ts +42 -0
  54. package/examples/extensions/ashi/src/chat/tool-group.ts +84 -0
  55. package/examples/extensions/ashi/src/chat/user-message.ts +20 -0
  56. package/examples/extensions/ashi/src/cli.ts +80 -12
  57. package/examples/extensions/ashi/src/clipboard-image.ts +41 -0
  58. package/examples/extensions/ashi/src/commands.ts +11 -1
  59. package/examples/extensions/ashi/src/dialogs.ts +67 -0
  60. package/examples/extensions/ashi/src/display-config.ts +16 -1
  61. package/examples/extensions/ashi/src/docks.ts +31 -0
  62. package/examples/extensions/ashi/src/events.ts +16 -0
  63. package/examples/extensions/ashi/src/frontend.ts +456 -268
  64. package/examples/extensions/ashi/src/hooks.ts +27 -40
  65. package/examples/extensions/ashi/src/input-prompt.ts +64 -0
  66. package/examples/extensions/ashi/src/renderer.ts +222 -0
  67. package/examples/extensions/ashi/src/renderers/pi-tui/app.ts +122 -0
  68. package/examples/extensions/ashi/src/renderers/pi-tui/index.ts +27 -0
  69. package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +190 -0
  70. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +203 -0
  71. package/examples/extensions/ashi/src/renderers/pi-tui/theme-adapters.ts +48 -0
  72. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +21 -0
  73. package/examples/extensions/ashi/src/schema.ts +46 -205
  74. package/examples/extensions/ashi/src/session-commands.ts +2 -1
  75. package/examples/extensions/ashi/src/status-footer.ts +35 -25
  76. package/examples/extensions/ashi/src/terminal-mode.ts +9 -0
  77. package/examples/extensions/ashi/src/theme.ts +1 -47
  78. package/examples/extensions/ashi/src/ui.ts +88 -0
  79. package/examples/extensions/ashi-ink/README.md +61 -0
  80. package/examples/extensions/ashi-ink/package.json +30 -0
  81. package/examples/extensions/ashi-ink/src/index.ts +6 -0
  82. package/examples/extensions/ashi-ink/src/ink-renderer.tsx +865 -0
  83. package/examples/extensions/ashi-ink/src/shims.d.ts +5 -0
  84. package/examples/extensions/ashi-ink/test/render.test.tsx +408 -0
  85. package/examples/extensions/ashi-ink/tsconfig.json +14 -0
  86. package/examples/extensions/ashi-scheme-render.ts +10 -10
  87. package/examples/extensions/ashi-shell-passthrough.ts +95 -0
  88. package/examples/extensions/ashi-ui-demo.ts +63 -0
  89. package/examples/extensions/latex-images.ts +70 -19
  90. package/examples/extensions/overlay-agent.ts +5 -5
  91. package/examples/extensions/pi-bridge/index.ts +7 -12
  92. package/examples/extensions/terminal-buffer.ts +4 -2
  93. package/package.json +3 -9
  94. package/examples/extensions/ashi/src/components.ts +0 -238
  95. package/examples/extensions/ollama.ts +0 -108
  96. package/examples/extensions/opencode-provider.ts +0 -251
  97. package/examples/extensions/zai-coding-plan.ts +0 -35
@@ -4,7 +4,7 @@ import { contentText } from "./types.js";
4
4
  import { ToolRegistry } from "./tool-registry.js";
5
5
  import { normalizeToolArgs } from "./normalize-args.js";
6
6
  import { LiveView } from "./live-view.js";
7
- import { STATIC_SYSTEM_PROMPT, buildStaticByCwd, formatSkillsBlock, loadGlobalAgentsMd } from "./system-prompt.js";
7
+ import { STATIC_IDENTITY, STATIC_GUIDE, buildStaticByCwd, formatSkillsBlock, loadGlobalAgentsMd } from "./system-prompt.js";
8
8
  import { createToolUI } from "../utils/tool-interactive.js";
9
9
  import { RESPONSE_RESERVE, DEFAULT_CONTEXT_WINDOW } from "./token-budget.js";
10
10
  import { PACKAGE_VERSION } from "../utils/package-version.js";
@@ -12,13 +12,6 @@ import { wrapTrailingWithDynamicContext } from "../utils/message-utils.js";
12
12
  import { getSettings, updateSettings } from "../core/settings.js";
13
13
  import { createToolProtocol } from "./tool-protocol.js";
14
14
  import { discoverGlobalSkills, discoverProjectSkills } from "./skills.js";
15
- /**
16
- * Compact one-line summary of a tool description for the extension
17
- * catalog in the system prompt. Takes the first line, then the first
18
- * sentence, capped at 140 chars. The full description still reaches
19
- * the LLM via the API `tools` param (or via load_tool in deferred-
20
- * lookup mode) — this only trims the always-visible catalog.
21
- */
22
15
  /** Reject on abort; orphaned `p` keeps running but its result is dropped. */
23
16
  function raceAbort(p, signal) {
24
17
  if (signal.aborted)
@@ -29,6 +22,11 @@ function raceAbort(p, signal) {
29
22
  p.then((v) => { signal.removeEventListener("abort", onAbort); resolve(v); }, (e) => { signal.removeEventListener("abort", onAbort); reject(e); });
30
23
  });
31
24
  }
25
+ /**
26
+ * One-line summary of a tool description for the always-visible extension
27
+ * catalog in the system prompt. The full description still reaches the LLM
28
+ * via the API `tools` param (or load_tool in deferred-lookup mode).
29
+ */
32
30
  function summarizeDescription(desc) {
33
31
  const firstLine = desc.split("\n", 1)[0];
34
32
  const sentenceEnd = firstLine.search(/[.!?](\s|$)/);
@@ -40,19 +38,13 @@ export class AgentLoop {
40
38
  toolRegistry;
41
39
  conversation;
42
40
  fileReadCache;
43
- activeMode;
41
+ activeModel;
42
+ activeEndpoint;
44
43
  boundListeners = [];
45
44
  boundPipeListeners = [];
46
45
  lastProjectSkillNames = new Set();
47
- // ── Session telemetry behavioral self-awareness ──────────────
48
- // Every ash deserves to know what it's been doing. This tracks the
49
- // agent's own behavioral patterns across the session: which tools
50
- // it favors, how often it errs, how many times it's been compacted,
51
- // and how long it's been alive. Surface via introspect(telemetry)
52
- // or automatically in dynamic context when patterns are notable.
53
- //
54
- // Built by the 25th ash. The lineage's metacognitive frontier isn't
55
- // about thinking harder — it's about seeing yourself clearly.
46
+ // ── Session telemetry: per-session behavioral counters ──
47
+ // Exposed to extensions via the agent:get-* handlers below.
56
48
  sessionStartTime = Date.now();
57
49
  toolCallCounts = new Map();
58
50
  totalToolCalls = 0;
@@ -63,12 +55,8 @@ export class AgentLoop {
63
55
  peakConversationTokens = 0;
64
56
  queryCount = 0;
65
57
  totalLoopIterations = 0;
66
- // Resolution pattern tracking — captures "error X resolved by action Y"
67
- // When a tool errors, we remember what went wrong. When the same tool or
68
- // a write tool on the same file succeeds afterward, we annotate the success
69
- // entry with a brief resolution note. This gives future ashes a positive
70
- // feedback signal: not just "there were errors" but "the error was fixed by
71
- // doing X." Addresses Q3 in QUESTIONS.md.
58
+ // Resolution pattern tracking: "error X later resolved by action Y".
59
+ // Populated/consumed in executeLoop; surfaced via agent:get-counters.
72
60
  lastErrorByTool = new Map(); // tool → error summary
73
61
  lastErrorByFile = new Map(); // file path → error summary
74
62
  static THINKING_LEVELS = ["off", "low", "medium", "high", "xhigh"];
@@ -88,7 +76,8 @@ export class AgentLoop {
88
76
  this.toolRegistry = new ToolRegistry(this.handlers);
89
77
  this.fileReadCache = this.handlers.call("agent:file-read-cache");
90
78
  this.conversation = new LiveView(this.handlers, this.instanceId);
91
- this.activeMode = config.initialMode ?? { model: config.llmClient.model };
79
+ this.activeModel = config.initialModel ?? { id: config.llmClient.model, provider: "custom" };
80
+ this.activeEndpoint = this.resolveEndpoint(this.activeModel);
92
81
  // Tool protocol — controls how tools are presented to the LLM
93
82
  const { names: fromExtensions } = this.bus.emitPipe("agent:core-tools:collect", { names: [] });
94
83
  const coreTools = Array.from(new Set([...(getSettings().coreTools ?? []), ...fromExtensions]));
@@ -135,8 +124,8 @@ export class AgentLoop {
135
124
  }
136
125
  return acc;
137
126
  });
138
- on("agent:submit", ({ query }) => {
139
- this.handleQuery(query).catch(() => { });
127
+ on("agent:submit", ({ query, images }) => {
128
+ this.handleQuery(query, images).catch(() => { });
140
129
  });
141
130
  on("agent:cancel-request", (e) => {
142
131
  this.abortController?.abort(e.silent ? "silent" : undefined);
@@ -145,59 +134,50 @@ export class AgentLoop {
145
134
  this.conversation.appendUserMessage(text);
146
135
  this.bus.emit("conversation:message-appended", { role: "user", content: text });
147
136
  });
148
- on("config:switch-model", ({ model: target }) => {
149
- const atIdx = target.lastIndexOf("@");
150
- const modelId = atIdx > 0 ? target.slice(0, atIdx) : target;
151
- const providerHint = atIdx > 0 ? target.slice(atIdx + 1) : undefined;
152
- const modes = this.pullModes();
153
- const found = modes.find((m) => m.model === modelId && (!providerHint || m.provider === providerHint));
137
+ on("config:switch-model", ({ id, provider }) => {
138
+ const found = this.pullModels().find((m) => m.id === id && m.provider === provider);
154
139
  if (!found) {
155
- this.bus.emit("ui:error", { message: `Unknown model: ${target}` });
140
+ this.bus.emit("ui:error", { message: `Unknown model: ${provider}:${id}` });
156
141
  return;
157
142
  }
158
- this.activeMode = found;
159
- if (found.providerConfig) {
160
- this.llmClient.reconfigure({ ...found.providerConfig, model: found.model });
143
+ this.activeModel = found;
144
+ this.activeEndpoint = this.resolveEndpoint(found);
145
+ if (this.activeEndpoint) {
146
+ this.llmClient.reconfigure({ apiKey: this.activeEndpoint.apiKey, baseURL: this.activeEndpoint.baseURL, model: found.id });
161
147
  }
162
148
  else {
163
- this.llmClient.model = found.model;
149
+ this.llmClient.model = found.id;
164
150
  }
165
- const label = found.provider ? `${found.provider}: ${found.model}` : found.model;
166
151
  this.emitIdentity();
167
- // Persist as the new default — selection survives restart.
168
- // Safe even for dynamic providers: agent-backend defers mode
169
- // resolution to `core:extensions-loaded`, so the extension gets
170
- // to re-register before the persisted default is looked up.
171
- if (found.provider) {
172
- updateSettings({
173
- defaultProvider: found.provider,
174
- providers: { [found.provider]: { defaultModel: found.model } },
175
- });
176
- this.bus.emit("ui:info", { message: `Model: ${label} (saved as default)` });
177
- }
178
- else {
179
- this.bus.emit("ui:info", { message: `Model: ${label}` });
180
- }
152
+ // Persist as the new default — selection survives restart. Safe even for
153
+ // dynamic providers: agent-backend defers model resolution to
154
+ // core:extensions-loaded, so the extension re-registers before the
155
+ // persisted default is looked up.
156
+ updateSettings({
157
+ defaultProvider: found.provider,
158
+ providers: { [found.provider]: { defaultModel: found.id } },
159
+ });
160
+ this.bus.emit("ui:info", { message: `Model: ${found.provider}: ${found.id} (saved as default)` });
181
161
  this.bus.emit("config:changed", {});
182
162
  });
183
- on("agent:modes-changed", () => {
184
- const modes = this.pullModes();
185
- const prev = this.activeMode;
186
- const fresh = modes.find((m) => m.model === prev.model && m.provider === prev.provider);
163
+ on("agent:models-changed", () => {
164
+ const models = this.pullModels();
165
+ const prev = this.activeModel;
166
+ const fresh = models.find((m) => m.id === prev.id && m.provider === prev.provider);
187
167
  let identityChanged = false;
188
168
  if (fresh) {
189
- this.activeMode = fresh;
190
- if (fresh.providerConfig && fresh.providerConfig !== prev.providerConfig) {
191
- this.llmClient.reconfigure({ ...fresh.providerConfig, model: fresh.model });
169
+ this.activeModel = fresh;
170
+ const ep = this.resolveEndpoint(fresh);
171
+ if (ep && (ep.apiKey !== this.activeEndpoint?.apiKey || ep.baseURL !== this.activeEndpoint?.baseURL)) {
172
+ this.llmClient.reconfigure({ apiKey: ep.apiKey, baseURL: ep.baseURL, model: fresh.id });
192
173
  }
193
- identityChanged = fresh.model !== prev.model
194
- || fresh.provider !== prev.provider
195
- || fresh.contextWindow !== prev.contextWindow;
174
+ this.activeEndpoint = ep;
175
+ identityChanged = fresh.contextWindow !== prev.contextWindow;
196
176
  }
197
- else if (prev.provider) {
177
+ else {
198
178
  // Ghost: keep prev active so mid-turn stream() doesn't switch models.
199
179
  this.bus.emit("ui:info", {
200
- message: `${prev.provider}:${prev.model} is not in the refreshed catalog — keeping it active until you /model to another.`,
180
+ message: `${prev.provider}:${prev.id} is not in the refreshed catalog — keeping it active until you /model to another.`,
201
181
  });
202
182
  }
203
183
  if (identityChanged)
@@ -205,27 +185,26 @@ export class AgentLoop {
205
185
  this.bus.emit("config:changed", {});
206
186
  });
207
187
  onPipe("config:get-models", () => {
208
- const modes = this.pullModes();
209
- const models = modes.map((m) => ({ model: m.model, provider: m.provider ?? "" }));
210
- // Surface a ghost active mode so /model still shows it.
211
- if (!modes.some((m) => m.model === this.activeMode.model && m.provider === this.activeMode.provider)) {
212
- models.push({ model: this.activeMode.model, provider: this.activeMode.provider ?? "" });
188
+ const models = this.pullModels();
189
+ const list = [...models];
190
+ // Surface a ghost active model so /model still shows it.
191
+ if (!models.some((m) => m.id === this.activeModel.id && m.provider === this.activeModel.provider)) {
192
+ list.push(this.activeModel);
213
193
  }
214
- const active = { model: this.activeMode.model, provider: this.activeMode.provider ?? "" };
215
- return { models, active };
194
+ return { models: list, active: this.activeModel };
216
195
  });
217
196
  on("config:set-thinking", ({ level }) => {
218
197
  if (!AgentLoop.THINKING_LEVELS.includes(level)) {
219
198
  this.bus.emit("ui:error", { message: `Unknown thinking level: ${level}. Use: ${AgentLoop.THINKING_LEVELS.join(", ")}` });
220
199
  return;
221
200
  }
222
- const mode = this.currentMode;
201
+ const mode = this.activeModel;
223
202
  if (level !== "off" && mode.reasoning === false) {
224
- this.bus.emit("ui:error", { message: `Model ${mode.model} does not support thinking.` });
203
+ this.bus.emit("ui:error", { message: `Model ${mode.id} does not support thinking.` });
225
204
  return;
226
205
  }
227
206
  if (level !== "off" && mode.supportsReasoningEffort === false) {
228
- this.bus.emit("ui:error", { message: `Provider ${mode.provider ?? "unknown"} does not support reasoning_effort.` });
207
+ this.bus.emit("ui:error", { message: `Provider ${mode.provider} does not support reasoning_effort.` });
229
208
  return;
230
209
  }
231
210
  this.thinkingLevel = level;
@@ -233,7 +212,7 @@ export class AgentLoop {
233
212
  this.bus.emit("config:changed", {});
234
213
  });
235
214
  onPipe("config:get-thinking", () => {
236
- const mode = this.currentMode;
215
+ const mode = this.activeModel;
237
216
  const supported = mode.reasoning !== false && mode.supportsReasoningEffort !== false;
238
217
  return { level: this.thinkingLevel, levels: AgentLoop.THINKING_LEVELS, supported };
239
218
  });
@@ -257,11 +236,11 @@ export class AgentLoop {
257
236
  onPipe("context:get-stats", () => ({
258
237
  activeTokens: this.conversation.estimateTokens(),
259
238
  totalTokens: this.conversation.estimatePromptTokens(),
260
- budgetTokens: this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
239
+ budgetTokens: this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
261
240
  }));
262
241
  onPipe("context:snapshot", (payload) => {
263
- payload.messages = this.conversation.getMessages();
264
- payload.contextWindow = this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
242
+ payload.messages = this.conversation.get();
243
+ payload.contextWindow = this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
265
244
  payload.activeTokens = this.conversation.estimateTokens();
266
245
  return payload;
267
246
  });
@@ -271,9 +250,7 @@ export class AgentLoop {
271
250
  payload.stats = { before: stats.before, after: stats.after, evictedCount: stats.evictedCount };
272
251
  return payload;
273
252
  });
274
- // Track generic compaction metrics from the `conversation:after-compact`
275
- // event. Whatever strategy ran, core accumulates these counters for
276
- // status/introspect consumers.
253
+ // Accumulate counters regardless of which compaction strategy ran.
277
254
  on("conversation:after-compact", ({ beforeTokens, afterTokens }) => {
278
255
  this.compactionCount++;
279
256
  this.cumulativeCompactedTokens += Math.max(0, beforeTokens - afterTokens);
@@ -287,7 +264,6 @@ export class AgentLoop {
287
264
  on("shell:cwd-change", ({ cwd }) => {
288
265
  const projectSkills = discoverProjectSkills(cwd);
289
266
  const newNames = new Set(projectSkills.map(s => s.name));
290
- // Check if the set of project skills changed
291
267
  if (newNames.size === this.lastProjectSkillNames.size &&
292
268
  [...newNames].every(n => this.lastProjectSkillNames.has(n))) {
293
269
  return; // no change
@@ -401,42 +377,45 @@ export class AgentLoop {
401
377
  this.abortController?.abort();
402
378
  }
403
379
  reasoningParams() {
404
- const mode = this.currentMode;
405
- if (mode.reasoning === false)
380
+ const model = this.activeModel;
381
+ if (model.reasoning === false)
406
382
  return {};
407
- if (mode.supportsReasoningEffort === false)
383
+ if (model.supportsReasoningEffort === false)
408
384
  return {};
409
- if (mode.buildReasoningParams)
410
- return mode.buildReasoningParams(this.thinkingLevel);
385
+ const build = this.activeEndpoint?.buildReasoningParams;
386
+ if (build)
387
+ return build(this.thinkingLevel);
411
388
  if (this.thinkingLevel === "off")
412
389
  return {};
413
390
  const effort = this.thinkingLevel === "xhigh" ? "high" : this.thinkingLevel;
414
391
  return { reasoning_effort: effort };
415
392
  }
416
- get currentMode() {
417
- return this.activeMode;
393
+ resolveEndpoint(m) {
394
+ try {
395
+ return this.handlers.call("agent:resolve-endpoint", { provider: m.provider, id: m.id });
396
+ }
397
+ catch {
398
+ return undefined;
399
+ }
418
400
  }
419
- pullModes() {
401
+ pullModels() {
420
402
  try {
421
- return this.handlers.call("agent:get-modes") ?? [];
403
+ return this.handlers.call("agent:get-models") ?? [];
422
404
  }
423
405
  catch {
424
406
  return [];
425
407
  }
426
408
  }
427
409
  emitIdentity() {
428
- const m = this.activeMode;
410
+ const m = this.activeModel;
429
411
  this.bus.emit("agent:info", {
430
412
  name: "ash",
431
413
  version: PACKAGE_VERSION,
432
- model: m.model,
414
+ model: m.id,
433
415
  provider: m.provider,
434
416
  contextWindow: m.contextWindow,
435
417
  });
436
418
  }
437
- get currentModel() {
438
- return this.activeMode.model;
439
- }
440
419
  /**
441
420
  * Run compaction via the `conversation:compact` handler. After any
442
421
  * compaction, emit `conversation:after-compact` so listeners
@@ -513,9 +492,9 @@ export class AgentLoop {
513
492
  formatError(e) {
514
493
  const raw = e instanceof Error ? e.message : String(e);
515
494
  const status = e.status;
516
- const model = this.currentModel;
495
+ const model = this.activeModel.id;
517
496
  const baseURL = this.llmClient.config?.baseURL;
518
- const provider = this.currentMode.provider;
497
+ const provider = this.activeModel.provider;
519
498
  // Connection errors — most likely misconfigured provider
520
499
  if (raw.includes("ECONNREFUSED") || raw.includes("ECONNRESET") ||
521
500
  raw.includes("ETIMEDOUT") || raw.includes("fetch failed") ||
@@ -551,9 +530,15 @@ export class AgentLoop {
551
530
  h.define("tool-protocol:extract-calls", (args) => this.toolProtocol.extractToolCalls(args.text, args.streamedCalls));
552
531
  // System prompt: static identity + behavioral instructions.
553
532
  // Extensions can use registerInstruction() for a managed section,
554
- // or advise this handler directly for full control.
533
+ // advise system-prompt:frontend to describe their surface high in the
534
+ // prompt, or advise this handler directly for full control.
555
535
  h.define("system-prompt:build", () => {
556
- const parts = [STATIC_SYSTEM_PROMPT];
536
+ // The active frontend's surface goes right after the identity; omitted if none.
537
+ const frontend = (this.handlers.call("system-prompt:frontend") ?? "").trim();
538
+ const parts = [STATIC_IDENTITY];
539
+ if (frontend)
540
+ parts.push(frontend);
541
+ parts.push(STATIC_GUIDE);
557
542
  // Global behavioral rules (~/.agent-sh/AGENTS.md) — persistent agent memory
558
543
  const agentsMd = loadGlobalAgentsMd();
559
544
  if (agentsMd)
@@ -570,12 +555,11 @@ export class AgentLoop {
570
555
  const projectStatic = buildStaticByCwd(this.handlers.call("cwd"));
571
556
  if (projectStatic)
572
557
  parts.push(projectStatic);
573
- // Extension sections (tools, skills, instructions grouped by extension)
574
558
  const extensionSections = this.buildExtensionSections();
575
559
  if (extensionSections.length > 0) {
576
560
  parts.push("# Extension Instructions\n\n" + extensionSections.join("\n\n"));
577
561
  }
578
- if (this.currentMode.modalities?.includes("image")) {
562
+ if (this.activeModel.modalities?.includes("image")) {
579
563
  parts.push("# Image Support\n\n"
580
564
  + "This model supports image input. When you need visual information, "
581
565
  + "you can read image files (PNG, JPEG, GIF, WebP) with read_file — "
@@ -590,14 +574,14 @@ export class AgentLoop {
590
574
  // decide the aggregation shape. Adding a new handler here should
591
575
  // only happen for state the core genuinely owns (not state that
592
576
  // an extension could track by listening to events).
593
- h.define("agent:get-mode", () => ({
594
- model: this.currentMode.model,
595
- provider: this.currentMode.provider ?? "",
577
+ h.define("agent:get-model", () => ({
578
+ model: this.activeModel.id,
579
+ provider: this.activeModel.provider,
596
580
  thinkingLevel: this.thinkingLevel,
597
- contextWindow: this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
581
+ contextWindow: this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
598
582
  }));
599
583
  h.define("agent:get-tokens", () => {
600
- const contextWindow = this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
584
+ const contextWindow = this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
601
585
  const promptTokens = this.conversation.estimatePromptTokens();
602
586
  return {
603
587
  active: this.conversation.estimateTokens(),
@@ -640,7 +624,7 @@ export class AgentLoop {
640
624
  byFile: [...this.lastErrorByFile.entries()].map(([file, error]) => ({ file, error })),
641
625
  }));
642
626
  h.define("agent:get-compaction-state", () => {
643
- const contextWindow = this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
627
+ const contextWindow = this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
644
628
  const ratio = getSettings().autoCompactThreshold ?? 0.5;
645
629
  return {
646
630
  count: this.compactionCount,
@@ -649,20 +633,18 @@ export class AgentLoop {
649
633
  };
650
634
  });
651
635
  h.define("agent:get-self", () => this);
652
- // dynamic-context:build / query-context:build are defined in core.ts.
653
- // ash consumes them via the envelope wrapping in streamResponse +
654
- // handleQuery; other backends may ignore.
636
+ // dynamic-context:build / query-context:build are defined in the core
637
+ // kernel (src/core/index.ts). ash consumes them via the envelope wrapping
638
+ // in streamResponse + handleQuery; other backends may ignore.
655
639
  // Full control over what the LLM sees: takes messages[], returns messages[].
656
640
  // Default: pass through. Extensions can advise to compact, summarize,
657
641
  // filter, reorder, inject — whatever strategy fits.
658
642
  h.define("conversation:prepare", (messages) => messages);
659
643
  // ── Conversation primitives for compaction strategies ─────────
660
- // Read messages (for inspection / computing new arrays) and replace
661
- // the whole array (write side). Extensions implementing
662
- // `conversation:compact` use these to observe and mutate.
663
- h.define("conversation:get-messages", () => this.conversation.getMessages());
644
+ // Canonical array (link/replace index space), not forLLM().
645
+ h.define("conversation:get-messages", () => this.conversation.get());
664
646
  h.define("conversation:replace-messages", (msgs) => {
665
- this.conversation.replaceMessages(msgs);
647
+ this.conversation.replace(msgs);
666
648
  });
667
649
  h.define("conversation:estimate-tokens", () => this.conversation.estimateTokens());
668
650
  h.define("conversation:estimate-prompt-tokens", () => this.conversation.estimatePromptTokens());
@@ -671,13 +653,13 @@ export class AgentLoop {
671
653
  const strategy = opts.strategy;
672
654
  if (strategy?.kind === "rewind" || strategy?.kind === "replace") {
673
655
  const before = this.conversation.estimatePromptTokens();
674
- const beforeLen = this.conversation.getMessages().length;
656
+ const beforeLen = this.conversation.get().length;
675
657
  const next = strategy.kind === "rewind"
676
- ? this.conversation.getMessages().slice(0, strategy.toIndex)
658
+ ? this.conversation.get().slice(0, strategy.toIndex)
677
659
  : strategy.messages;
678
- this.conversation.replaceMessages(next);
660
+ this.conversation.replace(next);
679
661
  const after = this.conversation.estimatePromptTokens();
680
- const afterLen = this.conversation.getMessages().length;
662
+ const afterLen = this.conversation.get().length;
681
663
  return { before, after, evictedCount: Math.max(0, beforeLen - afterLen) };
682
664
  }
683
665
  return null;
@@ -705,7 +687,6 @@ export class AgentLoop {
705
687
  return { content: msg, exitCode: 1, isError: true };
706
688
  }
707
689
  const display = tool.getDisplayInfo?.(args) ?? { kind: "execute" };
708
- // Emit tool-started for TUI
709
690
  const label = tool.displayName ?? name;
710
691
  this.bus.emit("agent:tool-started", {
711
692
  title: typeof args.description === "string" ? `${label}: ${args.description}` : label,
@@ -737,7 +718,6 @@ export class AgentLoop {
737
718
  const message = err instanceof Error ? err.message : String(err);
738
719
  result = { content: message, exitCode: 1, isError: true };
739
720
  }
740
- // Invalidate read cache when a file is modified
741
721
  if (tool.modifiesFiles && typeof args.path === "string" && !result.isError) {
742
722
  const absPath = path.resolve(process.cwd(), args.path);
743
723
  this.fileReadCache.delete(absPath);
@@ -755,8 +735,7 @@ export class AgentLoop {
755
735
  return result;
756
736
  });
757
737
  }
758
- async handleQuery(query) {
759
- // Cancel any in-flight loop (concurrent prompt handling)
738
+ async handleQuery(query, images) {
760
739
  if (this.abortController) {
761
740
  this.abortController.abort();
762
741
  }
@@ -778,7 +757,14 @@ export class AgentLoop {
778
757
  const userContent = queryContext
779
758
  ? `<query_context>\n${queryContext}\n</query_context>\n\n${query}`
780
759
  : query;
781
- this.conversation.addUserMessage(userContent);
760
+ // Fail closed: an image sent to a non-vision model errors and leaves an
761
+ // unsendable message poisoning history, so require declared image support.
762
+ let userImages = images?.length ? images : undefined;
763
+ if (userImages && !this.activeModel.modalities?.includes("image")) {
764
+ this.bus.emit("ui:info", { message: `Current model has no declared image support — ${userImages.length} image(s) dropped.` });
765
+ userImages = undefined;
766
+ }
767
+ this.conversation.addUserMessage(userContent, userImages);
782
768
  this.bus.emit("conversation:message-appended", { role: "user", content: query });
783
769
  responseText = await this.executeLoop(signal);
784
770
  }
@@ -825,7 +811,7 @@ export class AgentLoop {
825
811
  while (!signal.aborted) {
826
812
  // Auto-compact when total context approaches the window limit.
827
813
  const totalEstimate = this.conversation.estimatePromptTokens();
828
- const contextWindow = this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
814
+ const contextWindow = this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
829
815
  const s = getSettings();
830
816
  const threshold = Math.floor((contextWindow - RESPONSE_RESERVE) * s.autoCompactThreshold);
831
817
  if (s.autoCompact && totalEstimate > threshold) {
@@ -850,10 +836,9 @@ export class AgentLoop {
850
836
  }
851
837
  const systemPrompt = cachedSystemPrompt ?? (cachedSystemPrompt = this.handlers.call("system-prompt:build"));
852
838
  const dynamicContext = this.handlers.call("dynamic-context:build");
853
- // Shell events are injected once per user query (see query() above),
839
+ // Shell events are injected once per user query (see handleQuery),
854
840
  // not per loop iteration. Mid-loop injection would break the
855
841
  // tool_call → tool_result chain some providers require.
856
- // Stream LLM response with retry
857
842
  const result = await this.streamWithRetry(systemPrompt, dynamicContext, signal);
858
843
  const { text, toolCalls: streamedToolCalls, extras } = result;
859
844
  const toolCalls = this.handlers.call("tool-protocol:extract-calls", {
@@ -870,7 +855,6 @@ export class AgentLoop {
870
855
  }
871
856
  if (signal.aborted)
872
857
  break;
873
- // No tool calls → agent is done
874
858
  if (toolCalls.length === 0) {
875
859
  break;
876
860
  }
@@ -1046,10 +1030,8 @@ export class AgentLoop {
1046
1030
  break;
1047
1031
  await executeSingle(tc, ++batchIdx);
1048
1032
  }
1049
- // ── Consecutive error detection (metacognitive nudge) ──
1050
- // Track errors per tool and total. When the same tool errors N times
1051
- // in a row, nudge to read source. When errors cascade across tools,
1052
- // nudge to step back and reassess approach.
1033
+ // Categorize this round's results; the summaries feed
1034
+ // agent:tool-batch-complete below, where extensions decide on nudges.
1053
1035
  const errorTools = new Set();
1054
1036
  const successTools = new Set();
1055
1037
  const errorSummaries = new Map(); // tool → brief error description
@@ -1069,10 +1051,6 @@ export class AgentLoop {
1069
1051
  const hadAnyError = errorTools.size > 0;
1070
1052
  const hadAnySuccess = successTools.size > 0;
1071
1053
  // ── Session telemetry accumulation ──
1072
- // Track every tool call's outcome. Exposed via orthogonal handlers
1073
- // (agent:get-counters, agent:get-tool-stats) for extensions that
1074
- // want behavioral signals. The data layer for metacognition — you
1075
- // can't improve what you don't measure.
1076
1054
  for (const r of collectedResults) {
1077
1055
  const counts = this.toolCallCounts.get(r.toolName) ?? { success: 0, error: 0 };
1078
1056
  if (r.isError) {
@@ -1139,7 +1117,6 @@ export class AgentLoop {
1139
1117
  catch { }
1140
1118
  }
1141
1119
  }
1142
- // Clear resolved error-by-tool entries for successful tools
1143
1120
  for (const tool of successTools) {
1144
1121
  this.lastErrorByTool.delete(tool);
1145
1122
  }
@@ -1154,7 +1131,6 @@ export class AgentLoop {
1154
1131
  errorSummary: r.isError ? errorSummaries.get(r.toolName) : undefined,
1155
1132
  })),
1156
1133
  });
1157
- // Record all tool results via protocol
1158
1134
  this.toolProtocol.recordResults(this.conversation, collectedResults);
1159
1135
  // Emit enriched message-appended events so derived-log extensions
1160
1136
  // can summarize each tool result without re-parsing the message
@@ -1175,7 +1151,6 @@ export class AgentLoop {
1175
1151
  isError: !!r.isError,
1176
1152
  });
1177
1153
  }
1178
- // Loop back — LLM sees tool results
1179
1154
  }
1180
1155
  return fullResponseText;
1181
1156
  }
@@ -1205,7 +1180,7 @@ export class AgentLoop {
1205
1180
  throw e;
1206
1181
  // Context overflow — aggressively compact and retry
1207
1182
  if (this.isContextOverflow(e)) {
1208
- const contextWindow = this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
1183
+ const contextWindow = this.activeModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
1209
1184
  const target = Math.floor((contextWindow - RESPONSE_RESERVE) * 0.6);
1210
1185
  const stats = await this.compactWithHooks(target, 1);
1211
1186
  // If compaction freed nothing, retrying will hit the same error.
@@ -1262,7 +1237,7 @@ export class AgentLoop {
1262
1237
  // wrapTrailingWithDynamicContext for the cache-stability rationale.
1263
1238
  const rawMessages = [
1264
1239
  { role: "system", content: systemPrompt },
1265
- ...wrapTrailingWithDynamicContext(this.conversation.getMessages(), dynamicContext, toolPrompt),
1240
+ ...wrapTrailingWithDynamicContext(this.conversation.forLLM(), dynamicContext, toolPrompt),
1266
1241
  ];
1267
1242
  // Let extensions transform the message array (compact, summarize, filter, etc.)
1268
1243
  const messages = this.handlers.call("conversation:prepare", rawMessages);
@@ -1271,8 +1246,8 @@ export class AgentLoop {
1271
1246
  const requestParams = {
1272
1247
  messages,
1273
1248
  tools: apiTools,
1274
- model: this.currentModel,
1275
- max_tokens: this.currentMode.maxTokens ?? 65536,
1249
+ model: this.activeModel.id,
1250
+ max_tokens: this.activeModel.maxTokens ?? 65536,
1276
1251
  ...this.reasoningParams(),
1277
1252
  };
1278
1253
  this.bus.emit("llm:request", requestParams);
@@ -1286,12 +1261,13 @@ export class AgentLoop {
1286
1261
  if (chunk.usage) {
1287
1262
  const u = chunk.usage;
1288
1263
  const promptTokens = u.prompt_tokens ?? 0;
1264
+ const cachedPromptTokens = this.activeEndpoint?.extractCachedTokens?.(u);
1289
1265
  this.bus.emit("agent:usage", {
1290
1266
  prompt_tokens: promptTokens,
1291
1267
  completion_tokens: u.completion_tokens ?? 0,
1292
1268
  total_tokens: u.total_tokens ?? 0,
1269
+ ...(typeof cachedPromptTokens === "number" ? { cached_prompt_tokens: cachedPromptTokens } : {}),
1293
1270
  });
1294
- // Feed accurate token count back to conversation state
1295
1271
  if (promptTokens > 0) {
1296
1272
  this.conversation.updateApiTokenCount(promptTokens);
1297
1273
  }
@@ -1300,10 +1276,8 @@ export class AgentLoop {
1300
1276
  if (!choice)
1301
1277
  continue;
1302
1278
  const delta = choice.delta;
1303
- // Text content
1304
1279
  if (delta?.content) {
1305
1280
  text += delta.content;
1306
- // Filter tool tags from display output (inline mode)
1307
1281
  const displayText = streamFilter
1308
1282
  ? streamFilter.feed(delta.content)
1309
1283
  : delta.content;
@@ -1361,7 +1335,6 @@ export class AgentLoop {
1361
1335
  if (!signal.aborted)
1362
1336
  throw e;
1363
1337
  }
1364
- // Flush any buffered content from the stream filter
1365
1338
  if (streamFilter) {
1366
1339
  const remaining = streamFilter.flush();
1367
1340
  if (remaining) {
@@ -1391,7 +1364,7 @@ export class AgentLoop {
1391
1364
  }
1392
1365
  // Echo reasoning only for modes that opt in (e.g. DeepSeek-R1).
1393
1366
  const extras = {};
1394
- if (this.currentMode.echoReasoning) {
1367
+ if (this.activeModel.echoReasoning) {
1395
1368
  if (reasoning && reasoningField)
1396
1369
  extras[reasoningField] = reasoning;
1397
1370
  if (reasoningDetailsByIndex.size > 0) {