agent-sh 0.14.10 → 0.14.11

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 (60) hide show
  1. package/README.md +47 -20
  2. package/dist/agent/agent-loop.js +20 -15
  3. package/dist/agent/events.d.ts +2 -1
  4. package/dist/agent/index.js +38 -7
  5. package/dist/agent/live-view.d.ts +3 -3
  6. package/dist/agent/live-view.js +15 -7
  7. package/dist/agent/providers/openrouter.js +9 -0
  8. package/dist/agent/subagent.js +1 -1
  9. package/dist/cli/install.js +10 -1
  10. package/dist/shell/events.d.ts +3 -0
  11. package/dist/shell/shell.js +3 -0
  12. package/dist/utils/diff-renderer.d.ts +4 -0
  13. package/dist/utils/diff-renderer.js +15 -20
  14. package/examples/extensions/ads/SKILL.md +170 -0
  15. package/examples/extensions/ash-scheme/index.ts +332 -672
  16. package/examples/extensions/ash-scheme/package.json +1 -1
  17. package/examples/extensions/ashi/EXTENDING.md +116 -0
  18. package/examples/extensions/ashi/README.md +10 -54
  19. package/examples/extensions/ashi/package.json +6 -2
  20. package/examples/extensions/ashi/src/autocomplete-controller.ts +95 -0
  21. package/examples/extensions/ashi/src/autocomplete.ts +1 -23
  22. package/examples/extensions/ashi/src/capture.ts +9 -3
  23. package/examples/extensions/ashi/src/chat/assistant.ts +87 -0
  24. package/examples/extensions/ashi/src/chat/lines.ts +20 -0
  25. package/examples/extensions/ashi/src/chat/thinking.ts +42 -0
  26. package/examples/extensions/ashi/src/chat/tool-group.ts +84 -0
  27. package/examples/extensions/ashi/src/chat/user-message.ts +20 -0
  28. package/examples/extensions/ashi/src/cli.ts +56 -10
  29. package/examples/extensions/ashi/src/clipboard-image.ts +41 -0
  30. package/examples/extensions/ashi/src/commands.ts +11 -1
  31. package/examples/extensions/ashi/src/display-config.ts +9 -1
  32. package/examples/extensions/ashi/src/frontend.ts +340 -259
  33. package/examples/extensions/ashi/src/hooks.ts +33 -40
  34. package/examples/extensions/ashi/src/renderer.ts +222 -0
  35. package/examples/extensions/ashi/src/renderers/pi-tui/app.ts +122 -0
  36. package/examples/extensions/ashi/src/renderers/pi-tui/index.ts +23 -0
  37. package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +133 -0
  38. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +193 -0
  39. package/examples/extensions/ashi/src/renderers/pi-tui/theme-adapters.ts +48 -0
  40. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +21 -0
  41. package/examples/extensions/ashi/src/schema.ts +43 -205
  42. package/examples/extensions/ashi/src/status-footer.ts +15 -23
  43. package/examples/extensions/ashi/src/terminal-mode.ts +9 -0
  44. package/examples/extensions/ashi/src/theme.ts +1 -47
  45. package/examples/extensions/ashi-ink/README.md +59 -0
  46. package/examples/extensions/ashi-ink/package.json +30 -0
  47. package/examples/extensions/ashi-ink/src/index.ts +6 -0
  48. package/examples/extensions/ashi-ink/src/ink-renderer.tsx +865 -0
  49. package/examples/extensions/ashi-ink/src/shims.d.ts +5 -0
  50. package/examples/extensions/ashi-ink/test/render.test.tsx +408 -0
  51. package/examples/extensions/ashi-ink/tsconfig.json +14 -0
  52. package/examples/extensions/ashi-scheme-render.ts +4 -10
  53. package/examples/extensions/ashi-shell-passthrough.ts +95 -0
  54. package/examples/extensions/latex-images.ts +22 -19
  55. package/examples/extensions/terminal-buffer.ts +4 -2
  56. package/package.json +3 -9
  57. package/examples/extensions/ashi/src/components.ts +0 -238
  58. package/examples/extensions/ollama.ts +0 -108
  59. package/examples/extensions/opencode-provider.ts +0 -251
  60. package/examples/extensions/zai-coding-plan.ts +0 -35
package/README.md CHANGED
@@ -1,15 +1,19 @@
1
1
  # agent-sh
2
2
 
3
- A real shell with an AI agent one keystroke away.
4
-
5
3
  [![npm version](https://img.shields.io/npm/v/agent-sh.svg)](https://www.npmjs.com/package/agent-sh)
6
4
  [![license](https://img.shields.io/npm/l/agent-sh.svg)](https://github.com/guanyilun/agent-sh/blob/main/LICENSE)
7
5
 
8
- ![demo](assets/demo.gif)
6
+ A composable agent runtime — pair any frontend with any agent backend, over one shared extension layer.
7
+
8
+ ## Three example apps built on agent-sh
9
9
 
10
- I live in my terminal. A lot of the time I'm not coding — I'm deploying something, poking at a failing `rsync`, figuring out why `docker build` won't start, fixing a one-liner. And very often I need an AI agent to help. Spinning up a full coding agent for this stuff is overkill, and I got tired of copy-pasting errors into a chat window every time.
10
+ agent-sh is small at its core and does its real work through extensions, so the same runtime drives very different apps. Three to start with all sharing the same agent backends, tools, providers, and `~/.agent-sh/settings.json`:
11
11
 
12
- So I built agent-sh. Under the hood it's a normal shell on top of node-pty — your rc config, your aliases, vim and tmux all just work. But at the start of any line, type `>` and you're talking to a small agent that already sees your cwd, your last command, and its output. Nothing to set up, no project to explain.
12
+ ### 1. A shell with the agent one keystroke away bundled with agent-sh
13
+
14
+ A normal shell on top of node-pty — your rc config, your aliases, vim and tmux all just work. But at the start of any line, type `>` and you're talking to a small agent that already sees your cwd, your last command, and its output. Nothing to set up, no project to explain.
15
+
16
+ ![demo](assets/demo.gif)
13
17
 
14
18
  ```
15
19
  ~ $ ls -la # real shell command
@@ -19,10 +23,47 @@ So I built agent-sh. Under the hood it's a normal shell on top of node-pty — y
19
23
  ~ $ > draft a commit message # agent reads your diff and shell history
20
24
  ```
21
25
 
22
- agent-sh is built to be agent-agnostic. The recommended path is the built-in agent `ash` — a lightweight agent designed so extensions can plug into the same tool surface. If you'd rather host an existing coding agent (pi, claude-code, opencode), you can [bring your own](#bring-your-own-agent) — with the trade-off that it manages its own separate tools.
26
+ ```bash
27
+ npm install -g agent-sh
28
+ ```
29
+
30
+ [Quick Start ↓](#quick-start)
31
+
32
+ ### 2. ashi — a standalone coding agent
33
+
34
+ [**`@guanyilun/ashi`**](examples/extensions/ashi/) is the same `ash` agent in a chat-style TUI, with no shell underneath — just the agent. Installed separately, it reuses agent-sh's backend, tools, slash commands, providers, and skills, and adds session history, in-session branching, and LLM-driven compaction.
35
+
36
+ ```bash
37
+ npm install -g @guanyilun/ashi
38
+ ashi
39
+ ```
40
+
41
+ ashi makes the runtime's **decoupled rendering** concrete: the frontend is itself an extension, and even *how* it draws tool calls and results is a swappable render extension. Same agent backend, same conversation — load a different render extension and the whole TUI restyles, no code changes:
42
+
43
+ | pi-style rendering | claude-code-style rendering |
44
+ |---|---|
45
+ | ![ashi rendering tool calls pi-style](assets/ashi-pi-style.png) | ![ashi rendering tool calls claude-code-style](assets/ashi-claude-code-style.png) |
46
+
47
+ ### 3. asHub — a GUI coding agent
48
+
49
+ [**firslov/asHub**](https://github.com/firslov/asHub) is a third-party cross-platform desktop app (Electron) built on the agent-sh runtime: a multi-session sidebar, persistence across restarts, and a live-streaming interface with Markdown, syntax-highlighted code, diffs, and tool-call rendering. macOS / Windows / Linux.
50
+
51
+ It pushes the same decoupling one step further — the frontend isn't a terminal at all, but a full desktop GUI on the same runtime, backends, and tools:
52
+
53
+ ![asHub desktop GUI](assets/ashub.png)
54
+
55
+ ## How it works
56
+
57
+ agent-sh is a **composable agent runtime**. At its center is a pure kernel — a typed event bus, a named-handler registry, and an extension loader — that knows nothing about terminals, LLMs, shells, or rendering. Everything else plugs into it: the agent backend, its tools, provider management, and the frontend that drives it.
58
+
59
+ The frontend and the agent backend are both just components on the bus, so you **mix and match** them freely — wire several frontends to one backend, or keep one frontend and swap the backend underneath — all sharing the **same extension layer** of tools, content transforms, slash commands, and themes. `import { createCore } from "agent-sh"` gives you the headless kernel; load the pieces you want and wire your own I/O.
60
+
61
+ For the kernel design in full — the bus, handlers, the compositor, and the shell ↔ agent boundary — see [Architecture](docs/architecture.md). To embed the runtime in your own frontend, see the [Library Guide](docs/library.md). The rest of this README covers the bundled shell.
23
62
 
24
63
  ## Quick Start
25
64
 
65
+ **This sets up the agent-sh shell** — the frontend bundled in the `agent-sh` package. (For the other frontends, install [ashi](examples/extensions/ashi/) or [asHub](https://github.com/firslov/asHub) instead.)
66
+
26
67
  ### Installation
27
68
 
28
69
  Install from npm:
@@ -139,20 +180,6 @@ All three bridges receive agent-sh's per-query shell context (`<shell_events>`)
139
180
 
140
181
  **Caveat:** pi, claude-code, and opencode each manage their own tool surfaces, so agent-sh extensions that register tools (or skills, instructions, etc.) for the built-in `ash` agent generally won't be visible to a hosted backend. Frontend extensions (themes, content transforms, slash commands, the TUI renderer) keep working — only the agent-side capabilities differ. Use the bridges when you want that agent's toolset; stay on `ash` when you want agent-sh's extension ecosystem.
141
182
 
142
- ## Key Features
143
-
144
- **Real terminal, zero compromise.** Full PTY with your shell config, aliases, and environment. Shell starts instantly — the agent connects asynchronously in the background.
145
-
146
- **One entry point, smart tool selection.** Type `>` and agent-sh figures out how to help. Scratchpad tools (`bash`, `read_file`, `grep`, `glob`) for investigation. Extensions add capabilities like running commands in your live shell. No modes to pick — the agent reasons about which tools to use based on your intent.
147
-
148
- **Context that just works.** Every query includes your cwd, recent commands, and their output. Run a failing test, type `> fix this`, and agent-sh knows exactly what happened. Context management works like shell history — continuous, persistent across restarts, no sessions to manage. See [Context Management](docs/context-management.md).
149
-
150
- **Any LLM, any backend.** agent-sh works with any OpenAI-compatible API out of the box. Define multiple providers in settings and switch models at runtime with `/model <name>`. Or swap in a completely different agent — bundled bridges run [pi](examples/extensions/pi-bridge/), [claude-code](examples/extensions/claude-code-bridge/), or [opencode](examples/extensions/opencode-bridge/) as a drop-in backend (see [Bring your own agent](#bring-your-own-agent)).
151
-
152
- **Extensible by design.** The entire system is built on a typed event bus. Extensions can add custom input modes, content transforms (render LaTeX as images, Mermaid as diagrams), themes, slash commands, or replace the agent backend entirely. The built-in TUI renderer is itself just an extension.
153
-
154
- **Embeddable as a library.** The core is a headless kernel — `import { createCore } from "agent-sh"` to build WebSocket servers, REST APIs, Electron apps, or test harnesses. No terminal required.
155
-
156
183
  ## Documentation
157
184
 
158
185
  Start with **Usage** to get running, then **Architecture** for the mental model.
@@ -135,8 +135,8 @@ export class AgentLoop {
135
135
  }
136
136
  return acc;
137
137
  });
138
- on("agent:submit", ({ query }) => {
139
- this.handleQuery(query).catch(() => { });
138
+ on("agent:submit", ({ query, images }) => {
139
+ this.handleQuery(query, images).catch(() => { });
140
140
  });
141
141
  on("agent:cancel-request", (e) => {
142
142
  this.abortController?.abort(e.silent ? "silent" : undefined);
@@ -260,7 +260,7 @@ export class AgentLoop {
260
260
  budgetTokens: this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
261
261
  }));
262
262
  onPipe("context:snapshot", (payload) => {
263
- payload.messages = this.conversation.getMessages();
263
+ payload.messages = this.conversation.get();
264
264
  payload.contextWindow = this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
265
265
  payload.activeTokens = this.conversation.estimateTokens();
266
266
  return payload;
@@ -657,12 +657,10 @@ export class AgentLoop {
657
657
  // filter, reorder, inject — whatever strategy fits.
658
658
  h.define("conversation:prepare", (messages) => messages);
659
659
  // ── 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());
660
+ // Canonical array (link/replace index space), not forLLM().
661
+ h.define("conversation:get-messages", () => this.conversation.get());
664
662
  h.define("conversation:replace-messages", (msgs) => {
665
- this.conversation.replaceMessages(msgs);
663
+ this.conversation.replace(msgs);
666
664
  });
667
665
  h.define("conversation:estimate-tokens", () => this.conversation.estimateTokens());
668
666
  h.define("conversation:estimate-prompt-tokens", () => this.conversation.estimatePromptTokens());
@@ -671,13 +669,13 @@ export class AgentLoop {
671
669
  const strategy = opts.strategy;
672
670
  if (strategy?.kind === "rewind" || strategy?.kind === "replace") {
673
671
  const before = this.conversation.estimatePromptTokens();
674
- const beforeLen = this.conversation.getMessages().length;
672
+ const beforeLen = this.conversation.get().length;
675
673
  const next = strategy.kind === "rewind"
676
- ? this.conversation.getMessages().slice(0, strategy.toIndex)
674
+ ? this.conversation.get().slice(0, strategy.toIndex)
677
675
  : strategy.messages;
678
- this.conversation.replaceMessages(next);
676
+ this.conversation.replace(next);
679
677
  const after = this.conversation.estimatePromptTokens();
680
- const afterLen = this.conversation.getMessages().length;
678
+ const afterLen = this.conversation.get().length;
681
679
  return { before, after, evictedCount: Math.max(0, beforeLen - afterLen) };
682
680
  }
683
681
  return null;
@@ -755,7 +753,7 @@ export class AgentLoop {
755
753
  return result;
756
754
  });
757
755
  }
758
- async handleQuery(query) {
756
+ async handleQuery(query, images) {
759
757
  // Cancel any in-flight loop (concurrent prompt handling)
760
758
  if (this.abortController) {
761
759
  this.abortController.abort();
@@ -778,7 +776,14 @@ export class AgentLoop {
778
776
  const userContent = queryContext
779
777
  ? `<query_context>\n${queryContext}\n</query_context>\n\n${query}`
780
778
  : query;
781
- this.conversation.addUserMessage(userContent);
779
+ // Fail closed: an image sent to a non-vision model errors and leaves an
780
+ // unsendable message poisoning history, so require declared image support.
781
+ let userImages = images?.length ? images : undefined;
782
+ if (userImages && !this.currentMode.modalities?.includes("image")) {
783
+ this.bus.emit("ui:info", { message: `Current model has no declared image support — ${userImages.length} image(s) dropped.` });
784
+ userImages = undefined;
785
+ }
786
+ this.conversation.addUserMessage(userContent, userImages);
782
787
  this.bus.emit("conversation:message-appended", { role: "user", content: query });
783
788
  responseText = await this.executeLoop(signal);
784
789
  }
@@ -1262,7 +1267,7 @@ export class AgentLoop {
1262
1267
  // wrapTrailingWithDynamicContext for the cache-stability rationale.
1263
1268
  const rawMessages = [
1264
1269
  { role: "system", content: systemPrompt },
1265
- ...wrapTrailingWithDynamicContext(this.conversation.getMessages(), dynamicContext, toolPrompt),
1270
+ ...wrapTrailingWithDynamicContext(this.conversation.forLLM(), dynamicContext, toolPrompt),
1266
1271
  ];
1267
1272
  // Let extensions transform the message array (compact, summarize, filter, etc.)
1268
1273
  const messages = this.handlers.call("conversation:prepare", rawMessages);
@@ -1,5 +1,5 @@
1
1
  import type { ProviderRegistration } from "./host-types.js";
2
- import type { ToolDefinition, ToolResultDisplay } from "./types.js";
2
+ import type { ImageContent, ToolDefinition, ToolResultDisplay } from "./types.js";
3
3
  export interface AgentIdentity {
4
4
  name: string;
5
5
  version: string;
@@ -44,6 +44,7 @@ declare module "../core/event-bus.js" {
44
44
  };
45
45
  "agent:submit": {
46
46
  query: string;
47
+ images?: ImageContent[];
47
48
  };
48
49
  "agent:cancel-request": {
49
50
  silent?: boolean;
@@ -32,6 +32,12 @@ function persistedModelFor(providerName) {
32
32
  return undefined;
33
33
  return getSettings().providers?.[providerName]?.defaultModel;
34
34
  }
35
+ /** The OpenAI SDK silently defaults an empty baseURL to api.openai.com, so a
36
+ * provider with a key but no endpoint would misroute its key there. `openai`
37
+ * is exempt: that default is its endpoint. */
38
+ function usableProvider(p) {
39
+ return !!p?.apiKey && (!!p.baseURL || p.id === "openai");
40
+ }
35
41
  function defaultReasoningBuilder(level) {
36
42
  if (level === "off")
37
43
  return {};
@@ -279,6 +285,8 @@ export default function agentBackend(ctx) {
279
285
  for (const [id, p] of resolvedProviders) {
280
286
  if (!p.apiKey)
281
287
  continue;
288
+ if (!usableProvider(p))
289
+ continue;
282
290
  const shapeId = p.reasoningShape ?? id;
283
291
  for (const model of p.models) {
284
292
  const mc = p.modelCapabilities?.get(model);
@@ -347,13 +355,32 @@ export default function agentBackend(ctx) {
347
355
  loadedExtensionNames = names;
348
356
  resolvedProviders = computeResolvedProviders();
349
357
  const settings = getSettings();
350
- // Built-ins register unconditionally so `auth list` can enumerate them;
351
- // the fallback must skip keyless entries or it lands on openrouter and
352
- // bails at the `!effectiveApiKey` guard below.
353
- const providerName = config.provider
354
- ?? settings.defaultProvider
355
- ?? [...resolvedProviders].find(([, p]) => p.apiKey)?.[0];
356
- const activeProvider = providerName ? resolvedProviders.get(providerName) ?? null : null;
358
+ let providerName = config.provider ?? settings.defaultProvider;
359
+ let activeProvider = providerName ? resolvedProviders.get(providerName) ?? null : null;
360
+ // Inline CLI credentials carry their own endpoint, so they skip the
361
+ // usable-provider fallback that registry-driven selection needs.
362
+ if (!config.apiKey) {
363
+ if (!providerName) {
364
+ const first = [...resolvedProviders].find(([, p]) => usableProvider(p));
365
+ providerName = first?.[0];
366
+ activeProvider = first?.[1] ?? null;
367
+ }
368
+ else if (!usableProvider(activeProvider)) {
369
+ const reason = !activeProvider ? "is not registered"
370
+ : !activeProvider.apiKey ? "has no API key configured"
371
+ : "has no endpoint configured";
372
+ const next = [...resolvedProviders].find(([, p]) => usableProvider(p));
373
+ if (next) {
374
+ bus.emit("ui:error", { message: `Provider "${providerName}" ${reason}; falling back to "${next[0]}".` });
375
+ providerName = next[0];
376
+ activeProvider = next[1];
377
+ }
378
+ else {
379
+ bus.emit("ui:error", { message: `Provider "${providerName}" ${reason}, and no other configured provider has both an API key and an endpoint. Run \`agent-sh auth\` to configure one.` });
380
+ return;
381
+ }
382
+ }
383
+ }
357
384
  // Persisted defaultModel wins over openrouter's hardcoded DEFAULT_MODELS[0].
358
385
  const effectiveApiKey = config.apiKey ?? activeProvider?.apiKey;
359
386
  const effectiveBaseURL = config.baseURL ?? activeProvider?.baseURL;
@@ -468,6 +495,10 @@ export default function agentBackend(ctx) {
468
495
  bus.emit("ui:error", { message: `Provider "${name}" has no API key configured` });
469
496
  return;
470
497
  }
498
+ if (!p.baseURL && p.id !== "openai") {
499
+ bus.emit("ui:error", { message: `Provider "${name}" has no endpoint configured` });
500
+ return;
501
+ }
471
502
  const switchModel = p.defaultModel ?? p.models[0];
472
503
  if (!switchModel) {
473
504
  bus.emit("ui:error", { message: `Provider "${name}" has no models configured` });
@@ -19,7 +19,7 @@ export declare class LiveView {
19
19
  constructor(handlers?: HandlerFunctions, instanceId?: string);
20
20
  private getMessagesJson;
21
21
  private invalidateMessagesCache;
22
- addUserMessage(text: string): void;
22
+ addUserMessage(text: string, images?: ImageContent[]): void;
23
23
  addAssistantMessage(content: string | null, toolCalls?: {
24
24
  id: string;
25
25
  function: {
@@ -34,9 +34,9 @@ export declare class LiveView {
34
34
  appendUserMessage(text: string): void;
35
35
  private hasOpenToolCalls;
36
36
  private flushPendingMessages;
37
- getMessages(): ChatCompletionMessageParam[];
38
- get(): AgentShMessage[];
37
+ /** Send-shaped; may be longer than get() (dangling calls stubbed) — never link()/replace() by these indices. */
39
38
  forLLM(): ChatCompletionMessageParam[];
39
+ get(): AgentShMessage[];
40
40
  replace(msgs: AgentShMessage[]): void;
41
41
  link(index: number, entryId: string): void;
42
42
  /** DeepSeek 400s on tool messages without a matching tool_call;
@@ -1,4 +1,3 @@
1
- import { stripMeta } from "./llm-client.js";
2
1
  export class LiveView {
3
2
  messages = [];
4
3
  messagesDirty = true;
@@ -26,8 +25,19 @@ export class LiveView {
26
25
  this.messagesDirty = true;
27
26
  this.cachedMessagesJson = null;
28
27
  }
29
- addUserMessage(text) {
30
- this.messages.push({ role: "user", content: text });
28
+ addUserMessage(text, images) {
29
+ if (images?.length) {
30
+ const parts = [];
31
+ if (text)
32
+ parts.push({ type: "text", text });
33
+ for (const img of images) {
34
+ parts.push({ type: "image_url", image_url: { url: `data:${img.mimeType};base64,${img.data}` } });
35
+ }
36
+ this.messages.push({ role: "user", content: parts });
37
+ }
38
+ else {
39
+ this.messages.push({ role: "user", content: text });
40
+ }
31
41
  this.invalidateMessagesCache();
32
42
  }
33
43
  addAssistantMessage(content, toolCalls, extras) {
@@ -131,15 +141,13 @@ export class LiveView {
131
141
  }
132
142
  this.invalidateMessagesCache();
133
143
  }
134
- getMessages() {
144
+ /** Send-shaped; may be longer than get() (dangling calls stubbed) — never link()/replace() by these indices. */
145
+ forLLM() {
135
146
  return this.normalizeReasoningConsistency(this.stubDanglingToolCalls(this.dropOrphanToolMessages(this.messages)));
136
147
  }
137
148
  get() {
138
149
  return this.messages;
139
150
  }
140
- forLLM() {
141
- return this.getMessages().map(stripMeta);
142
- }
143
151
  replace(msgs) {
144
152
  this.replaceMessages(msgs);
145
153
  }
@@ -14,6 +14,14 @@ function buildReasoningParams(level, _model) {
14
14
  ? { reasoning: { effort: "none" } }
15
15
  : { reasoning: { effort: level } };
16
16
  }
17
+ /** OpenRouter's input_modalities → the text/image subset; undefined when absent
18
+ * so the fail-closed image guard treats the model as text-only. */
19
+ function toModalities(input) {
20
+ if (!Array.isArray(input))
21
+ return undefined;
22
+ const out = input.filter((v) => v === "text" || v === "image");
23
+ return out.length ? out : undefined;
24
+ }
17
25
  export default function activate(ctx) {
18
26
  const apiKey = resolveApiKey("openrouter").key;
19
27
  ctx.agent.providers.configure("openrouter", { reasoningParams: buildReasoningParams });
@@ -42,6 +50,7 @@ export default function activate(ctx) {
42
50
  reasoning: m.supported_parameters?.includes("reasoning") ?? false,
43
51
  contextWindow: m.context_length,
44
52
  echoReasoning: userOverrides.get(m.id) ?? patterns.some((re) => re.test(m.id)),
53
+ modalities: toModalities(m.architecture?.input_modalities),
45
54
  })),
46
55
  });
47
56
  }).catch(() => { });
@@ -109,7 +109,7 @@ async function streamOnce(llmClient, systemPrompt, conversation, apiTools, model
109
109
  const stream = await llmClient.stream({
110
110
  messages: [
111
111
  { role: "system", content: systemPrompt },
112
- ...wrapTrailingWithDynamicContext(conversation.getMessages(), dynamicContext ?? ""),
112
+ ...wrapTrailingWithDynamicContext(conversation.forLLM(), dynamicContext ?? ""),
113
113
  ],
114
114
  tools: apiTools.length > 0 ? apiTools : undefined,
115
115
  model,
@@ -14,7 +14,16 @@ const EXT_DIR = path.join(CONFIG_DIR, "extensions");
14
14
  export function listBundled() {
15
15
  if (!fs.existsSync(BUNDLED_DIR))
16
16
  return [];
17
- return fs.readdirSync(BUNDLED_DIR).map((n) => n.replace(/\.(ts|js|mjs)$/, ""));
17
+ const out = [];
18
+ for (const d of fs.readdirSync(BUNDLED_DIR, { withFileTypes: true })) {
19
+ if (d.name.startsWith("."))
20
+ continue;
21
+ if (d.isDirectory())
22
+ out.push(d.name);
23
+ else if (SCRIPT_EXTS.some((ext) => d.name.endsWith(ext)))
24
+ out.push(d.name.replace(/\.[^.]+$/, ""));
25
+ }
26
+ return out;
18
27
  }
19
28
  /** Heuristic: a backend named "pi" is typically provided by an extension called "pi-bridge". */
20
29
  export function suggestBridgeFor(backend) {
@@ -32,6 +32,9 @@ declare module "../core/event-bus.js" {
32
32
  cols: number;
33
33
  rows: number;
34
34
  };
35
+ "shell:host-write": {
36
+ data: string;
37
+ };
35
38
  "shell:buffer-request": Record<string, never>;
36
39
  "shell:buffer-snapshot": {
37
40
  text: string;
@@ -110,6 +110,9 @@ export class Shell {
110
110
  this.bus.on("shell:pty-resize", ({ cols, rows }) => {
111
111
  this.ptyProcess.resize(cols, rows);
112
112
  });
113
+ this.bus.on("shell:host-write", ({ data }) => {
114
+ this.terminal.write(data);
115
+ });
113
116
  // Compat shims for the bus-event API. shell:stdout-hold maps to hard
114
117
  // mute so terminal_keys' stdout-show can't paint through the overlay.
115
118
  let holdRefcount = 0;
@@ -13,6 +13,10 @@ export interface DiffRenderOptions {
13
13
  trueColor?: boolean;
14
14
  /** Enable syntax highlighting on diff lines. Default true. */
15
15
  syntaxHighlight?: boolean;
16
+ /** Draw the `│` rule between the line number and the code (default true). Set false
17
+ * for a flush gutter: `<n> <sigil><code>`, the row background spans the line, and
18
+ * context code is left un-dimmed. */
19
+ gutterLine?: boolean;
16
20
  }
17
21
  export declare function detectLanguage(filePath?: string): string | undefined;
18
22
  /**
@@ -239,25 +239,35 @@ function unifiedLayout(diff, opts) {
239
239
  lineTextW: Math.max(1, textWidth - noW - 5),
240
240
  textWidth,
241
241
  useTrueColor: opts.trueColor !== false,
242
+ gutterLine: opts.gutterLine !== false,
242
243
  lang: opts.syntaxHighlight !== false ? detectLanguage(opts.filePath) : undefined,
243
244
  removedPalette: { rowBg: p.errorBg, emphBg: p.errorBgEmph },
244
245
  addedPalette: { rowBg: p.successBg, emphBg: p.successBgEmph },
245
246
  };
246
247
  }
247
248
  function renderUnifiedHunk(hunk, layout) {
248
- const { noW, lineTextW, textWidth, useTrueColor, lang, removedPalette, addedPalette } = layout;
249
+ const { noW, lineTextW, textWidth, useTrueColor, gutterLine, lang, removedPalette, addedPalette } = layout;
249
250
  const out = [];
250
251
  const pairs = findChangePairs(hunk);
251
252
  const renderedAsPartOfPair = new Set();
252
253
  const bgWidth = Math.max(1, textWidth - noW - 3);
253
254
  const gutter = (n) => `${p.dim}${n} │${p.reset} `;
255
+ const change = (no, sigil, bg, fg, text) => {
256
+ if (!gutterLine) {
257
+ return `${bg}${padToWidth(`${no} ${fg}${sigil}${preserveBg(text, bg)}`, textWidth)}${p.reset}`;
258
+ }
259
+ if (useTrueColor)
260
+ return gutter(no) + padToWidth(`${bg}${fg}${sigil} ${preserveBg(text, bg)}`, bgWidth) + p.reset;
261
+ return `${gutter(no)}${fg}${sigil} ${text}${p.reset}`;
262
+ };
254
263
  for (let i = 0; i < hunk.lines.length; i++) {
255
264
  const line = hunk.lines[i];
256
265
  const no = String(line.type === "removed" ? (line.oldNo ?? "") : (line.newNo ?? line.oldNo ?? "")).padStart(noW);
257
266
  if (line.type === "context") {
258
267
  const raw = truncateText(line.text, lineTextW);
259
268
  const text = lang ? highlightLine(raw, lang) : raw;
260
- out.push(`${gutter(no)} ${p.dim}${text}${p.reset}`);
269
+ // 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}`);
261
271
  continue;
262
272
  }
263
273
  if (line.type === "removed") {
@@ -276,19 +286,9 @@ function renderUnifiedHunk(hunk, layout) {
276
286
  const raw = truncateText(line.text, lineTextW);
277
287
  removedText = lang ? highlightLine(raw, lang) : raw;
278
288
  }
279
- if (useTrueColor) {
280
- out.push(gutter(no) + padToWidth(`${p.errorBg}${p.error}- ${preserveBg(removedText, p.errorBg)}`, bgWidth) + p.reset);
281
- }
282
- else {
283
- out.push(`${gutter(no)}${p.error}- ${removedText}${p.reset}`);
284
- }
289
+ out.push(change(no, "-", p.errorBg, p.error, removedText));
285
290
  if (addedText !== null && addedNo !== null) {
286
- if (useTrueColor) {
287
- out.push(gutter(addedNo) + padToWidth(`${p.successBg}${p.success}+ ${preserveBg(addedText, p.successBg)}`, bgWidth) + p.reset);
288
- }
289
- else {
290
- out.push(`${gutter(addedNo)}${p.success}+ ${addedText}${p.reset}`);
291
- }
291
+ out.push(change(addedNo, "+", p.successBg, p.success, addedText));
292
292
  }
293
293
  continue;
294
294
  }
@@ -297,12 +297,7 @@ function renderUnifiedHunk(hunk, layout) {
297
297
  continue;
298
298
  const raw = truncateText(line.text, lineTextW);
299
299
  const text = lang ? highlightLine(raw, lang) : raw;
300
- if (useTrueColor) {
301
- out.push(gutter(no) + padToWidth(`${p.successBg}${p.success}+ ${preserveBg(text, p.successBg)}`, bgWidth) + p.reset);
302
- }
303
- else {
304
- out.push(`${gutter(no)}${p.success}+ ${text}${p.reset}`);
305
- }
300
+ out.push(change(no, "+", p.successBg, p.success, text));
306
301
  }
307
302
  }
308
303
  return out;