agent-sh 0.13.1 → 0.13.3

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.
@@ -91,7 +91,7 @@ export class AgentLoop {
91
91
  bus;
92
92
  llmClient;
93
93
  handlers;
94
- thinkingLevel = "off";
94
+ thinkingLevel = getSettings().thinkingLevel ?? "off";
95
95
  compositor = null;
96
96
  toolProtocol;
97
97
  instanceId;
@@ -289,6 +289,7 @@ export class AgentLoop {
289
289
  return;
290
290
  }
291
291
  this.thinkingLevel = level;
292
+ updateSettings({ thinkingLevel: level });
292
293
  this.bus.emit("config:changed", {});
293
294
  });
294
295
  onPipe("config:get-thinking", () => {
@@ -998,10 +999,7 @@ export class AgentLoop {
998
999
  responseText = await this.executeLoop(signal);
999
1000
  }
1000
1001
  catch (e) {
1001
- if (signal.aborted && signal.reason !== "silent") {
1002
- this.bus.emit("agent:cancelled", {});
1003
- }
1004
- else if (!signal.aborted) {
1002
+ if (!signal.aborted) {
1005
1003
  if (e instanceof Error)
1006
1004
  console.error("[agent-sh] query failed:\n" + e.stack);
1007
1005
  const msg = this.formatError(e);
@@ -1009,6 +1007,9 @@ export class AgentLoop {
1009
1007
  }
1010
1008
  }
1011
1009
  finally {
1010
+ if (signal.aborted && signal.reason !== "silent") {
1011
+ this.bus.emit("agent:cancelled", {});
1012
+ }
1012
1013
  // Ensure any buffered text in the stream transform pipeline gets
1013
1014
  // flushed as a complete line before response-done closes the box.
1014
1015
  if (responseText && !responseText.endsWith("\n")) {
@@ -1200,7 +1201,7 @@ export class AgentLoop {
1200
1201
  signal });
1201
1202
  // Truncate large outputs to avoid blowing context.
1202
1203
  let content = result.content;
1203
- const maxBytes = tool.maxResultBytes ?? 16_384; // ~4k tokens
1204
+ const maxBytes = tool.maxResultBytes ?? 100_000; // ~25k tokens
1204
1205
  if (content.length > maxBytes) {
1205
1206
  const headBytes = Math.floor(maxBytes * 0.6);
1206
1207
  const tailBytes = maxBytes - headBytes;
@@ -130,11 +130,6 @@ function normalizeBin(pkg) {
130
130
  function maybeNpmBuild(target, pkg) {
131
131
  if (!pkg.scripts?.build)
132
132
  return;
133
- const binPaths = Object.values(normalizeBin(pkg)).map((p) => path.join(target, p));
134
- if (binPaths.length === 0)
135
- return;
136
- if (binPaths.every((p) => fs.existsSync(p)))
137
- return;
138
133
  console.log(`Running npm run build in ${target}...`);
139
134
  const result = spawnSync("npm", ["run", "build"], { cwd: target, stdio: "inherit" });
140
135
  if (result.status !== 0) {
@@ -44,6 +44,8 @@ export interface Settings {
44
44
  defaultProvider?: string;
45
45
  /** Preferred agent backend (extension name, e.g. "pi", "claude-code"). */
46
46
  defaultBackend?: string;
47
+ /** Default thinking/reasoning effort level for new sessions ("off"|"low"|"medium"|"high"). */
48
+ thinkingLevel?: string;
47
49
  /** Shell output lines before spill-to-tempfile kicks in. */
48
50
  shellTruncateThreshold?: number;
49
51
  /** Lines kept from start of spilled shell output. */
@@ -19,6 +19,7 @@ const DEFAULTS = {
19
19
  providers: {},
20
20
  defaultProvider: undefined,
21
21
  defaultBackend: "ash",
22
+ thinkingLevel: undefined,
22
23
  toolMode: "api",
23
24
  coreTools: [],
24
25
  shellTruncateThreshold: 20,
@@ -14,6 +14,12 @@ export interface DiffRenderOptions {
14
14
  /** Enable syntax highlighting on diff lines. Default true. */
15
15
  syntaxHighlight?: boolean;
16
16
  }
17
+ export declare function detectLanguage(filePath?: string): string | undefined;
18
+ /**
19
+ * Syntax-highlight a single line of code.
20
+ * Returns the original text if highlighting fails or no language detected.
21
+ */
22
+ export declare function highlightLine(text: string, language?: string): string;
17
23
  /** Select display mode based on available terminal width. */
18
24
  export declare function selectMode(width: number): DiffDisplayMode;
19
25
  /** Render a diff result as an array of ANSI-formatted terminal lines. */
@@ -25,7 +25,7 @@ const EXT_TO_LANG = {
25
25
  ".hs": "haskell", ".ml": "ocaml", ".clj": "clojure",
26
26
  ".vim": "vim", ".dockerfile": "dockerfile",
27
27
  };
28
- function detectLanguage(filePath) {
28
+ export function detectLanguage(filePath) {
29
29
  if (!filePath)
30
30
  return undefined;
31
31
  const dot = filePath.lastIndexOf(".");
@@ -44,7 +44,7 @@ function detectLanguage(filePath) {
44
44
  * Syntax-highlight a single line of code.
45
45
  * Returns the original text if highlighting fails or no language detected.
46
46
  */
47
- function highlightLine(text, language) {
47
+ export function highlightLine(text, language) {
48
48
  if (!language || text.trim() === "")
49
49
  return text;
50
50
  try {
@@ -275,14 +275,14 @@ function renderUnifiedHunk(hunk, layout) {
275
275
  removedText = lang ? highlightLine(raw, lang) : raw;
276
276
  }
277
277
  if (useTrueColor) {
278
- out.push(padToWidth(`${p.errorBg}${p.error}- ${no} │ ${preserveBg(removedText, p.errorBg)}${p.reset}`, textWidth));
278
+ out.push(padToWidth(`${p.errorBg}${p.error}- ${no} │ ${preserveBg(removedText, p.errorBg)}`, textWidth) + p.reset);
279
279
  }
280
280
  else {
281
281
  out.push(`${p.error}- ${no} │ ${removedText}${p.reset}`);
282
282
  }
283
283
  if (addedText !== null && addedNo !== null) {
284
284
  if (useTrueColor) {
285
- out.push(padToWidth(`${p.successBg}${p.success}+ ${addedNo} │ ${preserveBg(addedText, p.successBg)}${p.reset}`, textWidth));
285
+ out.push(padToWidth(`${p.successBg}${p.success}+ ${addedNo} │ ${preserveBg(addedText, p.successBg)}`, textWidth) + p.reset);
286
286
  }
287
287
  else {
288
288
  out.push(`${p.success}+ ${addedNo} │ ${addedText}${p.reset}`);
@@ -296,7 +296,7 @@ function renderUnifiedHunk(hunk, layout) {
296
296
  const raw = truncateText(line.text, lineTextW);
297
297
  const text = lang ? highlightLine(raw, lang) : raw;
298
298
  if (useTrueColor) {
299
- out.push(padToWidth(`${p.successBg}${p.success}+ ${no} │ ${preserveBg(text, p.successBg)}${p.reset}`, textWidth));
299
+ out.push(padToWidth(`${p.successBg}${p.success}+ ${no} │ ${preserveBg(text, p.successBg)}`, textWidth) + p.reset);
300
300
  }
301
301
  else {
302
302
  out.push(`${p.success}+ ${no} │ ${text}${p.reset}`);
@@ -368,7 +368,7 @@ function renderSplitHunk(hunk, layout) {
368
368
  }
369
369
  else if (row.left.type === "removed") {
370
370
  if (useTrueColor) {
371
- leftCol = padToWidth(`${p.errorBg}${p.error}${leftNo} │ ${preserveBg(leftText, p.errorBg)}${p.reset}`, colWidth);
371
+ leftCol = padToWidth(`${p.errorBg}${p.error}${leftNo} │ ${preserveBg(leftText, p.errorBg)}`, colWidth) + p.reset;
372
372
  }
373
373
  else {
374
374
  leftCol = padToWidth(`${p.error}${leftNo} │ ${leftText}${p.reset}`, colWidth);
@@ -382,7 +382,7 @@ function renderSplitHunk(hunk, layout) {
382
382
  }
383
383
  else if (row.right.type === "added") {
384
384
  if (useTrueColor) {
385
- rightCol = padToWidth(`${p.successBg}${p.success}${rightNo} │ ${preserveBg(rightText, p.successBg)}${p.reset}`, colWidth);
385
+ rightCol = padToWidth(`${p.successBg}${p.success}${rightNo} │ ${preserveBg(rightText, p.successBg)}`, colWidth) + p.reset;
386
386
  }
387
387
  else {
388
388
  rightCol = padToWidth(`${p.success}${rightNo} │ ${rightText}${p.reset}`, colWidth);
@@ -8,6 +8,9 @@
8
8
  import OpenAI from "openai";
9
9
  import type { ChatCompletionMessageParam, ChatCompletionTool } from "openai/resources/chat/completions.js";
10
10
  export type { ChatCompletionMessageParam, ChatCompletionTool };
11
+ export type AgentShMessage = ChatCompletionMessageParam & {
12
+ meta?: Record<string, unknown>;
13
+ };
11
14
  export interface LlmClientConfig {
12
15
  apiKey: string;
13
16
  baseURL?: string;
@@ -6,6 +6,12 @@
6
6
  * (command suggestions, completions).
7
7
  */
8
8
  import OpenAI from "openai";
9
+ function stripMeta(m) {
10
+ if (!("meta" in m))
11
+ return m;
12
+ const { meta: _meta, ...rest } = m;
13
+ return rest;
14
+ }
9
15
  function attributionHeaders(config) {
10
16
  return {
11
17
  "HTTP-Referer": config.appUrl ?? "https://agent-sh.dev",
@@ -40,7 +46,7 @@ export class LlmClient {
40
46
  const body = {
41
47
  ...rest,
42
48
  model: model ?? this.model,
43
- messages,
49
+ messages: messages.map(stripMeta),
44
50
  tools: tools?.length ? tools : undefined,
45
51
  max_tokens: max_tokens ?? 65536,
46
52
  stream: true,
@@ -53,7 +59,7 @@ export class LlmClient {
53
59
  const body = {
54
60
  ...rest,
55
61
  model: model ?? this.model,
56
- messages,
62
+ messages: messages.map(stripMeta),
57
63
  max_tokens: max_tokens ?? 1024,
58
64
  };
59
65
  const response = await this.client.chat.completions.create(body);
@@ -11,7 +11,7 @@
11
11
  * In agent-shell (Emacs):
12
12
  * (setq agent-shell-agentsh-acp-command '("agent-sh-acp"))
13
13
  */
14
- import { createCore, type AgentShellCore } from "agent-sh";
14
+ import { createCore, NoopHistory, type AgentShellCore } from "agent-sh";
15
15
  import { loadExtensions } from "agent-sh/extension-loader";
16
16
  import { loadBuiltinExtensions } from "agent-sh/extensions";
17
17
  import { activateAgent } from "agent-sh/agent";
@@ -483,6 +483,7 @@ async function handleSessionNew(id: number | string, params: Record<string, unkn
483
483
  core = createCore({
484
484
  model: cliArgs.model,
485
485
  provider: cliArgs.provider,
486
+ history: new NoopHistory(),
486
487
  });
487
488
  wireEvents(core);
488
489
 
@@ -1,71 +1,90 @@
1
1
  # ashi
2
2
 
3
- `ash` (agent-sh's built-in agent) running inside pi's TUI substrate, with no shell underneath.
3
+ [![npm version](https://img.shields.io/npm/v/@guanyilun/ashi.svg)](https://www.npmjs.com/package/@guanyilun/ashi)
4
+ [![license](https://img.shields.io/npm/l/@guanyilun/ashi.svg)](https://github.com/guanyilun/agent-sh/blob/main/LICENSE)
4
5
 
5
- A test of agent-sh's claim that the kernel is a frontend-agnostic communication layer: `ashi`
6
- uses `createCore()` from agent-sh, skips `activateShell()`, disables the shell-coupled built-ins
7
- (`shell-context`, `tui-renderer`, `file-autocomplete`), and mounts `@earendil-works/pi-tui` as
8
- the only frontend. Backend, tools, slash commands, providers, and skills come along unchanged.
6
+ `ash` (the built-in agent of [agent-sh](https://github.com/guanyilun/agent-sh)) running as a standalone interactive TUI no shell underneath, just the agent.
7
+
8
+ Same backend, tools, slash commands, providers, and skills as `agent-sh`, mounted in a chat-style interface with session history, branching, and LLM-driven compaction.
9
9
 
10
10
  ## Install
11
11
 
12
12
  ```bash
13
- agent-sh install ashi
14
- export PATH="$HOME/.agent-sh/bin:$PATH"
13
+ npm install -g @guanyilun/ashi
15
14
  ashi
16
15
  ```
17
16
 
18
- `agent-sh install` copies this directory into `~/.agent-sh/extensions/ashi/`, runs
19
- `npm install` and `npm run build` there, and symlinks the built bin into `~/.agent-sh/bin/`.
17
+ Reads `~/.agent-sh/settings.json` for provider profiles and defaults, same file as `agent-sh`. The quickest path is exporting `OPENROUTER_API_KEY` or `OPENAI_API_KEY` and running `ashi`.
20
18
 
21
- ## Configure
19
+ To scaffold the config directory from scratch:
22
20
 
23
- Reads `~/.agent-sh/settings.json` for providers and defaults, same as `agent-sh` itself. The
24
- quickest path is exporting `OPENROUTER_API_KEY` or `OPENAI_API_KEY` and running `ashi`.
21
+ ```bash
22
+ ashi init # creates ~/.agent-sh/settings.json and AGENTS.md
23
+ ashi auth login # store an API key interactively
24
+ ```
25
25
 
26
- See the agent-sh [Usage Guide](https://github.com/guanyilun/agent-sh/blob/main/docs/usage.md)
27
- for the full `settings.json` schema, provider profiles, and model selection details.
26
+ ## Usage
28
27
 
29
- CLI flags mirror `agent-sh`:
28
+ ```bash
29
+ ashi # launch with defaults
30
+ ashi --provider openrouter # pick a provider profile
31
+ ashi --model anthropic/claude-sonnet-4 # override model
32
+ ashi -e claude-code-bridge --backend claude-code # swap the agent backend
33
+ ```
34
+
35
+ ### CLI flags
30
36
 
31
37
  ```
32
38
  --provider <name> Provider profile from ~/.agent-sh/settings.json
33
39
  --model <id> Override model
34
40
  --api-key <key> Direct API key
35
41
  --base-url <url> OpenAI-compatible base URL
36
- --backend <name> Agent backend (default: ash)
42
+ --backend <name> Agent backend (default: ash). Requires the matching
43
+ backend extension to be loaded, e.g. via -e.
37
44
  -e, --extensions Extra extensions to load (comma-separated)
38
45
  ```
39
46
 
40
- Extensions loaded via `-e` follow the standard agent-sh extension contract see
41
- [Extensions](https://github.com/guanyilun/agent-sh/blob/main/docs/extensions.md) for the
42
- `ExtensionContext` API, event bus, content transforms, and custom backends.
47
+ The built-in backend is `ash`. To use a different one (claude-code, opencode, pi), load the corresponding bridge extension with `-e` and pass `--backend <name>`.
43
48
 
44
- ## Keybindings
49
+ ### Management subcommands
45
50
 
46
- Match pi-coding-agent's convention:
51
+ ```
52
+ ashi install <name> [--force] Install an extension into ~/.agent-sh/extensions/
53
+ ashi uninstall <name> Remove an installed extension
54
+ ashi list List installed extensions
55
+ ashi auth login [provider] Store an API key (interactive)
56
+ ashi auth logout <provider> Remove a stored key
57
+ ashi auth list Show configured providers
58
+ ashi init [--force] Scaffold ~/.agent-sh/ (settings, AGENTS.md)
59
+ ```
60
+
61
+ These mirror `agent-sh`'s management commands so `ashi` works as a standalone CLI without needing the full `agent-sh` install.
62
+
63
+ ### Keybindings
47
64
 
48
65
  ```
49
- Esc Cancel active turn
50
- Ctrl+C Clear editor
51
- Ctrl+D Quit (when editor is empty)
52
- Ctrl+T Toggle thinking-block visibility
53
- Ctrl+O Expand/collapse the most recent tool result
66
+ Esc Cancel active turn
67
+ Ctrl+C Clear editor
68
+ Ctrl+D Quit (when editor is empty)
69
+ Ctrl+T Toggle thinking-block visibility (hidden by default)
70
+ Shift+Tab Cycle thinking level (off low → medium → high → …)
71
+ Ctrl+O Expand/collapse the most recent tool result
54
72
  ```
55
73
 
74
+ The current thinking level is shown in the footer as `[level]` next to the model name.
75
+
56
76
  ## Sessions
57
77
 
58
- ashi mirrors pi's session model: many sessions per cwd, fresh by default.
78
+ Many sessions per cwd, fresh by default:
59
79
 
60
80
  ```
61
- /resume Browse past sessions in this cwd (interactive picker)
62
- /new Start a fresh session (discards in-memory context)
63
- /name <text> Set a display name for the current session
64
- /sessions Text dump of all sessions in this cwd
81
+ /resume Browse past sessions in this cwd (interactive picker)
82
+ /new Start a fresh session (discards in-memory context)
83
+ /name <text> Set a display name for the current session
84
+ /sessions Text dump of all sessions in this cwd
65
85
  ```
66
86
 
67
- Each session is its own tree (one JSONL file per session). Every entry has an `id` and
68
- `parentId`; sibling branches stay on disk; you can rewind and branch within a session.
87
+ Each session is its own tree (one JSONL file per session). Every entry has an `id` and `parentId`; sibling branches stay on disk; you can rewind and branch within a session.
69
88
 
70
89
  ```
71
90
  /fork Interactive in-session tree picker
@@ -73,8 +92,7 @@ Each session is its own tree (one JSONL file per session). Every entry has an `i
73
92
  /branch Text dump of the active branch (root → leaf)
74
93
  ```
75
94
 
76
- Storage: `~/.agent-sh/extensions/ashi/history/<cwd-slug>/sessions/<id>.jsonl`. Each line
77
- is a `SessionEntry`:
95
+ Storage: `~/.agent-sh/extensions/ashi/history/<cwd-slug>/sessions/<id>.jsonl`. Each line is a `SessionEntry`:
78
96
 
79
97
  ```typescript
80
98
  type SessionEntry =
@@ -83,65 +101,56 @@ type SessionEntry =
83
101
  | { type: "compaction"; id; parentId; timestamp; summary; firstKeptId; tokensBefore };
84
102
  ```
85
103
 
86
- Raw `AgentMessage` objects are stored verbatim (full tool call arguments, tool results,
87
- etc.) so `/resume` and `/fork` faithfully reconstruct the original conversation — same
88
- shape as pi's session format.
89
-
90
- The kernel side adds three small handlers:
91
- optional `parentSeq`/`getBranch`/`getTree`/`setLeaf` on `NuclearEntry`/`HistoryAdapter`
92
- (useful for tree-aware HistoryAdapters in general — not used by this extension);
93
- `conversation:allocate-seq` and `conversation:reset-for-session` so multi-session adapters
94
- can swap context without nuclear-state bleed-through.
95
-
96
- ashi itself bypasses agent-sh's `NuclearEntry` pipeline entirely by installing a
97
- `NoopHistory` adapter — raw messages are captured directly via `agent:processing-done`
98
- and the `conversation:get-messages` handler.
104
+ Raw `AgentMessage` objects are stored verbatim (full tool call arguments, tool results, etc.) so `/resume` and `/fork` faithfully reconstruct the original conversation.
99
105
 
100
106
  ## Compaction
101
107
 
102
- ashi replaces agent-sh's default deterministic two-tier-pin compaction with a pi-style
103
- LLM-driven path:
108
+ LLM-driven structured compaction, triggered automatically when prompt tokens cross the threshold or manually with `/compact`:
104
109
 
105
- 1. Cut point: walk back from the newest message until ~20K tokens are kept; never cut at
106
- tool results or in the middle of an assistant→tool call group.
107
- 2. LLM summarizes the older span into the pi structured format (Goal / Constraints /
108
- Progress / Decisions / Next Steps / Critical Context).
110
+ 1. Walk back from the newest message until ~20K tokens are kept; never cut at tool results or mid–assistant-tool-call group.
111
+ 2. LLM summarizes the older span into a structured format (Goal / Constraints / Progress / Decisions / Next Steps / Critical Context).
109
112
  3. The live message array becomes `[summary, ...kept messages]`.
110
- 4. The summary lands in the session as a `CompactionEntry` carrying `summary`,
111
- `firstKeptId`, and `tokensBefore` — same shape as pi's compaction. Subsequent
112
- compactions reference the previous one's summary so chains stay coherent.
113
+ 4. The summary is persisted as a `CompactionEntry` carrying `summary`, `firstKeptId`, and `tokensBefore`. Subsequent compactions reference the previous one's summary so chains stay coherent.
113
114
 
114
- Triggered automatically when prompt tokens cross agent-sh's threshold, or manually with
115
- `/compact`. If the LLM call fails or the conversation is too short, falls through to the
116
- default eviction.
115
+ If the LLM call fails or the conversation is too short, falls through to the default eviction.
117
116
 
118
- The cut-point walker, prompt template, serialization, and LLM call all live in this
119
- extension. The kernel side is just the advisable `conversation:compact` seam.
117
+ ## Display configuration
120
118
 
121
- ## What's intentionally missing
119
+ Per-tool compactness lives under `ashi.display` in `~/.agent-sh/settings.json`:
122
120
 
123
- This is a spike, not a clone of pi's full UI. The MVP renders:
121
+ ```json
122
+ {
123
+ "ashi": {
124
+ "display": {
125
+ "default": { "result": "preview", "previewLines": 8 },
126
+ "read": { "result": "hidden" },
127
+ "ls": { "result": "hidden" },
128
+ "grep": { "result": "summary" },
129
+ "bash": { "result": "preview", "previewLines": 12 },
130
+ "edit": { "result": "preview" },
131
+ "write": { "result": "preview" }
132
+ }
133
+ }
134
+ }
135
+ ```
124
136
 
125
- - User submissions, streaming assistant Markdown
126
- - Tool invocations with start/complete state
127
- - Slash commands with autocomplete (`/help`, `/model`, `/backend`, `/resume`, `/new`, `/fork`, …)
128
- - Multi-session tree history with `/resume` and `/fork` pickers
129
- - LLM compaction with summaries that survive across `/resume`
130
- - Loader, errors, info messages
131
- - Inline images via the `image` ContentBlock and the `render:image` handler — the
132
- bundled `latex-images` extension works against ashi without modification
133
- (terminal must support iTerm2 or Kitty graphics)
137
+ `result` modes:
134
138
 
135
- Out of scope for v0: branch summaries on `/fork` navigation (pi has this), `/clone`
136
- (duplicate active branch into a new session), permission dialogs, diff renderer, file-path
137
- autocomplete, session search/rename/delete inside the `/resume` picker, theme selector.
138
- Each can be added by writing a pi-tui Component and subscribing to the corresponding
139
- bus event.
139
+ - `"hidden"` call line only while streaming; line count (`↳ 42 lines`) after completion.
140
+ - `"summary"` 2-line tail while streaming; line count after completion.
141
+ - `"preview"` — last `previewLines` lines of output (default 8), with a `... (N more lines)` hint when content overflows.
142
+
143
+ For `edit_file` / `write_file`, the diff frame is treated as the output and follows the same gating: shown for `preview`, hidden for `hidden`/`summary` (the call line already carries `+12 -3` stats). The line-count hint is suppressed for diff-producing tools so edits stay quiet.
144
+
145
+ Hit `Ctrl+O` to expand the most recent tool result inline regardless of mode. Press again to collapse.
146
+
147
+ Each tool inherits from `default` and is overridden by its own block. Unknown tool names fall through to `default`.
140
148
 
141
149
  ## Extension surface
142
150
 
143
- Other extensions can override how chat entries render without forking ashi.
144
- Hooks are exposed via `ctx.define` (defaults) + `ctx.advise` (override).
151
+ Other extensions can override how chat entries and tool results render without forking ashi. Hooks are exposed via `ctx.define` (defaults) + `ctx.advise` (override).
152
+
153
+ Components returned from these hooks are widgets from [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui) — the TUI framework ashi is built on.
145
154
 
146
155
  ### Chat hooks
147
156
 
@@ -153,9 +162,7 @@ Hooks are exposed via `ctx.define` (defaults) + `ctx.advise` (override).
153
162
 
154
163
  ### Tool hooks (per-tool)
155
164
 
156
- Tool rendering is split into a call line (the input header) and a result body
157
- (streaming output + final state). Each side is dispatched by tool name with a
158
- `:default` fallback:
165
+ Tool rendering is split into a call line (the input header) and a result body (streaming output + final state). Each side is dispatched by tool name with a `:default` fallback:
159
166
 
160
167
  | Hook | Args | Returns |
161
168
  |---|---|---|
@@ -169,9 +176,7 @@ Tool rendering is split into a call line (the input header) and a result body
169
176
  - `ToolCallView` extends `Component` with `setStatus({ exitCode, elapsedMs, summary })` — called once on completion.
170
177
  - `ToolResultView` extends `Component` with `appendChunk(chunk)`, `setDiff(lines)`, `finalize({ exitCode, summary })`, and `toggleExpanded()` — ashi mutates the result view as output streams in and when the user hits `Ctrl+O`.
171
178
 
172
- `mode` and `previewLines` on result args come from `ashi.display.{name}` config
173
- (see below) so renderers can honor the user's compactness preference without
174
- re-implementing the resolution logic.
179
+ `mode` and `previewLines` on result args come from `ashi.display.{name}` config so renderers can honor the user's compactness preference without re-implementing the resolution logic.
175
180
 
176
181
  Example: override how `bash` calls render.
177
182
 
@@ -183,52 +188,22 @@ export default function activate(ctx) {
183
188
  }
184
189
  ```
185
190
 
186
- For non-render concerns (commands, settings, tools, providers) use the standard
187
- agent-sh extension API.
191
+ For non-render concerns (commands, settings, tools, providers) use the standard `agent-sh` extension API. See the [agent-sh extension docs](https://github.com/guanyilun/agent-sh/blob/main/docs/extensions.md).
188
192
 
189
- ## Display configuration
193
+ ## Install from source
190
194
 
191
- Per-tool compactness lives under `ashi.display` in `~/.agent-sh/settings.json`:
195
+ Alternative to the npm install, useful for hacking on ashi itself:
192
196
 
193
- ```json
194
- {
195
- "ashi": {
196
- "display": {
197
- "default": { "result": "preview", "previewLines": 8 },
198
- "read": { "result": "hidden" },
199
- "ls": { "result": "hidden" },
200
- "grep": { "result": "summary" },
201
- "bash": { "result": "preview", "previewLines": 12 },
202
- "edit": { "result": "preview" },
203
- "write": { "result": "preview" }
204
- }
205
- }
206
- }
197
+ ```bash
198
+ agent-sh install ashi # copies examples/extensions/ashi → ~/.agent-sh/extensions/ashi
199
+ export PATH="$HOME/.agent-sh/bin:$PATH"
207
200
  ```
208
201
 
209
- `result` modes:
210
-
211
- - `"hidden"` — call line only while streaming; line count (`↳ 42 lines`) after completion.
212
- - `"summary"` — 2-line tail while streaming; line count after completion.
213
- - `"preview"` — last `previewLines` lines of output (default 8), with a `... (N more lines)` hint when content overflows.
214
-
215
- For `edit_file` / `write_file`, the diff frame is treated as the output and
216
- follows the same gating: shown for `preview`, hidden for `hidden`/`summary`
217
- (the call line already carries `+12 -3` stats). The line-count hint is
218
- suppressed for diff-producing tools so edits stay quiet.
219
-
220
- Hit `Ctrl+O` to expand the most recent tool result inline — shows the full
221
- output buffer and the full diff regardless of mode. Press again to collapse.
222
-
223
- Each tool inherits from `default` and is overridden by its own block. Unknown
224
- tool names fall through to `default`. Built-in defaults aim for compactness
225
- (`read`/`ls` hidden, `grep` summarized) — override under `default` to widen
226
- everything, or under a specific tool to tune one.
202
+ `agent-sh install` runs `npm install` and `npm run build` in the copied directory and symlinks the built bin into `~/.agent-sh/bin/`.
227
203
 
228
204
  ## Development
229
205
 
230
- `@guanyilun/ashi` depends on the published `agent-sh` package. To iterate against
231
- the parent checkout instead, use `npm link`:
206
+ `@guanyilun/ashi` depends on the published `agent-sh` package. To iterate against a local checkout, use `npm link`:
232
207
 
233
208
  ```bash
234
209
  # one-time: register the local agent-sh checkout
@@ -245,6 +220,8 @@ npm run dev # tsx-driven, no compile step
245
220
  # or: npm run build && node dist/cli.js
246
221
  ```
247
222
 
248
- Rebuild agent-sh (`npm run build` at the repo root) whenever you change the
249
- kernel — the link picks up `dist/` directly. To go back to the published
250
- version, run `npm unlink agent-sh && npm install` inside `examples/extensions/ashi`.
223
+ Rebuild agent-sh (`npm run build` at the repo root) whenever you change the kernel — the link picks up `dist/` directly. To go back to the published version, run `npm unlink agent-sh && npm install` inside `examples/extensions/ashi`.
224
+
225
+ ## License
226
+
227
+ MIT
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@guanyilun/ashi",
3
- "version": "0.1.0",
4
- "description": "Ash with a pi-tui frontend — agent-sh's kernel without the shell, rendered through pi's TUI library",
3
+ "version": "0.1.1",
4
+ "description": "Ash in an interactive TUI — agent-sh's built-in agent without the shell underneath",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
7
7
  "bin": {
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@earendil-works/pi-tui": "^0.74.0",
51
- "agent-sh": "^0.13.1",
51
+ "agent-sh": "^0.13.2",
52
52
  "chalk": "^5.5.0",
53
53
  "cli-highlight": "^2.1.11"
54
54
  },
@@ -15,11 +15,27 @@ export function registerCapture(
15
15
  getStore: () => MultiSessionStore,
16
16
  ): Capture {
17
17
  let liveEntryIds: (string | null)[] = [];
18
+ const diffMeta = new Map<string, { diff: unknown; filePath: string }>();
19
+
20
+ ctx.bus.on("agent:tool-completed", (e) => {
21
+ const id = e.toolCallId;
22
+ const body = e.resultDisplay?.body;
23
+ if (id && body?.kind === "diff") {
24
+ diffMeta.set(id, { diff: body.diff, filePath: body.filePath });
25
+ }
26
+ });
27
+
28
+ const enrich = (m: AgentMessage): AgentMessage => {
29
+ if (m.role !== "tool" || !m.tool_call_id) return m;
30
+ const meta = diffMeta.get(m.tool_call_id);
31
+ if (!meta) return m;
32
+ return { ...m, meta: { ...m.meta, diff: meta.diff, filePath: meta.filePath } };
33
+ };
18
34
 
19
35
  const flush = async (): Promise<void> => {
20
36
  const messages = ctx.call("conversation:get-messages") as AgentMessage[] | undefined;
21
37
  if (!messages || messages.length <= liveEntryIds.length) return;
22
- const newMessages = messages.slice(liveEntryIds.length);
38
+ const newMessages = messages.slice(liveEntryIds.length).map(enrich);
23
39
  const newIds = await getStore().current().appendMessages(newMessages);
24
40
  liveEntryIds = [...liveEntryIds, ...newIds];
25
41
  };
@@ -1,11 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * ashi — agent-sh's ash backend with a pi-tui frontend, no shell.
4
- *
5
- * Boots the agent-sh kernel directly, skips the PTY shell and the
6
- * default streaming tui-renderer, and mounts pi-tui as the sole
7
- * frontend. Demonstrates that the kernel is frontend-agnostic — same
8
- * backend, tools, slash commands, providers; different presentation.
3
+ * ashi — ash (agent-sh's built-in agent) in an interactive TUI.
9
4
  */
10
5
  import { createCore, NoopHistory } from "agent-sh/core";
11
6
  import { loadBuiltinExtensions } from "agent-sh/extensions";
@@ -43,10 +38,7 @@ function parseArgs(argv: string[]): AppConfig & { extensions?: string[] } {
43
38
  else if ((a === "-e" || a === "--extensions") && argv[i + 1]) {
44
39
  extensions.push(...argv[++i]!.split(",").map(s => s.trim()).filter(Boolean));
45
40
  } else if (a === "-h" || a === "--help") {
46
- process.stdout.write(`ashi ash backend, pi-tui frontend\n\n` +
47
- `Usage: ashi [--provider <name>] [--model <id>] [--api-key <key>] [--base-url <url>]\n` +
48
- ` [--backend <name>] [-e <ext>[,<ext>...]]\n\n` +
49
- `Reads ~/.agent-sh/settings.json for providers and defaults.\n`);
41
+ process.stdout.write(MANAGEMENT_HELP + "\n");
50
42
  process.exit(0);
51
43
  }
52
44
  }
@@ -54,7 +46,7 @@ function parseArgs(argv: string[]): AppConfig & { extensions?: string[] } {
54
46
  return { shell: "/bin/sh", model, apiKey, baseURL, provider, backend, extensions };
55
47
  }
56
48
 
57
- const MANAGEMENT_HELP = `ashi — ash backend, pi-tui frontend
49
+ const MANAGEMENT_HELP = `ashi — ash (agent-sh's built-in agent) in an interactive TUI
58
50
 
59
51
  Management:
60
52
  ashi install <name> [--force] Install an extension
@@ -153,7 +145,7 @@ async function main(): Promise<void> {
153
145
  registerDefaultToolRenderers(ctx);
154
146
 
155
147
  ctx.advise("conversation:format-prior-history", () => null);
156
- ctx.advise("system-prompt:build", (base) => `${base}\n\n<cwd>${process.cwd()}</cwd>`);
148
+ ctx.advise("system-prompt:build", (next) => `${next()}\n\n<cwd>${process.cwd()}</cwd>`);
157
149
 
158
150
  const handle = mountAshi(ctx, getStore, capture);
159
151
  stopFrontend = handle.stop;
@@ -98,7 +98,8 @@ export class ToolResultBody extends Container {
98
98
  private outputText: Text;
99
99
  private bodyText: Text;
100
100
  private outputBuffer = "";
101
- private diffLines: string[] = [];
101
+ private diffRenderer: ((width: number) => string[]) | null = null;
102
+ private lastDiffWidth = -1;
102
103
  private mode: ToolResultMode;
103
104
  private previewLines: number;
104
105
  private finalized = false;
@@ -120,8 +121,9 @@ export class ToolResultBody extends Container {
120
121
  this.repaint();
121
122
  }
122
123
 
123
- setDiff(lines: string[]): void {
124
- this.diffLines = lines;
124
+ setDiffRenderer(fn: (width: number) => string[]): void {
125
+ this.diffRenderer = fn;
126
+ this.lastDiffWidth = -1;
125
127
  this.repaint();
126
128
  }
127
129
 
@@ -136,12 +138,23 @@ export class ToolResultBody extends Container {
136
138
  this.repaint();
137
139
  }
138
140
 
141
+ override render(width: number): string[] {
142
+ if (this.diffRenderer && width !== this.lastDiffWidth) {
143
+ this.lastDiffWidth = width;
144
+ const showDiff = this.expanded || this.mode === "preview";
145
+ this.bodyText.setText(showDiff ? this.diffRenderer(width).join("\n") : "");
146
+ }
147
+ return super.render(width);
148
+ }
149
+
139
150
  private repaint(): void {
140
- // Hide the framed diff in hidden/summary modes — the stats already live
141
- // on the call line so showing it again is noise.
142
- const hasDiff = this.diffLines.length > 0;
151
+ const hasDiff = this.diffRenderer !== null;
143
152
  const showDiff = hasDiff && (this.expanded || this.mode === "preview");
144
- this.bodyText.setText(showDiff ? this.diffLines.join("\n") : "");
153
+ if (showDiff && this.lastDiffWidth >= 0 && this.diffRenderer) {
154
+ this.bodyText.setText(this.diffRenderer(this.lastDiffWidth).join("\n"));
155
+ } else if (!showDiff) {
156
+ this.bodyText.setText("");
157
+ }
145
158
 
146
159
  // When a diff exists, the textual output ("Edited /path (+12 -3)") just
147
160
  // restates the call line — suppress its line-count hint to keep edits quiet.
@@ -190,7 +203,7 @@ function lineCountHint(buffer: string, exitCode: number | null | undefined): str
190
203
  return `${arrow}${theme.fg("muted", label)}`;
191
204
  }
192
205
 
193
- const GROUP_ICONS: Record<string, string> = { read: "◆", search: "⌕" };
206
+ export const GROUP_ICONS: Record<string, string> = { read: "◆", search: "⌕" };
194
207
 
195
208
  interface GroupChild {
196
209
  name: string;
@@ -1,8 +1,26 @@
1
1
  import { Container, Spacer, Text } from "@earendil-works/pi-tui";
2
2
  import type { ExtensionContext } from "agent-sh/types";
3
3
  import { theme } from "./theme.js";
4
+ import { GROUP_ICONS } from "./components.js";
4
5
  import type { ToolCallArgs, ToolCallView } from "./hooks.js";
5
6
 
7
+ const TOOL_ICON: Record<string, string> = {
8
+ read_file: GROUP_ICONS.read!,
9
+ read: GROUP_ICONS.read!,
10
+ ls: GROUP_ICONS.read!,
11
+ grep: GROUP_ICONS.search!,
12
+ glob: GROUP_ICONS.search!,
13
+ edit: "✎",
14
+ edit_file: "✎",
15
+ write: "✎",
16
+ write_file: "✎",
17
+ };
18
+
19
+ function iconPrefix(name: string): string {
20
+ const icon = TOOL_ICON[name] ?? "⚙";
21
+ return `${theme.fg("warning", icon)} `;
22
+ }
23
+
6
24
  interface StatusOpts { exitCode: number | null; elapsedMs: number; summary?: string }
7
25
 
8
26
  function fmtElapsed(ms: number): string {
@@ -92,7 +110,7 @@ function readLabel(args: ToolCallArgs): string {
92
110
  const to = limit !== undefined ? from + limit - 1 : undefined;
93
111
  range = theme.fg("warning", to ? `:${from}-${to}` : `:${from}`);
94
112
  }
95
- return `${bold("read")} ${accent(path ? relativize(path) : "…")}${range}`;
113
+ return `${iconPrefix("read")}${bold("read")} ${accent(path ? relativize(path) : "…")}${range}`;
96
114
  }
97
115
 
98
116
  function grepLabel(args: ToolCallArgs): string {
@@ -103,31 +121,31 @@ function grepLabel(args: ToolCallArgs): string {
103
121
  const limit = num(r.limit);
104
122
  const extras = [glob ? `(${glob})` : "", limit !== undefined ? `limit ${limit}` : ""].filter(Boolean).join(" ");
105
123
  const tail = extras ? muted(` ${extras}`) : "";
106
- return `${bold("grep")} ${accent(`/${pattern}/`)} ${muted(`in ${scope}`)}${tail}`;
124
+ return `${iconPrefix("grep")}${bold("grep")} ${accent(`/${pattern}/`)} ${muted(`in ${scope}`)}${tail}`;
107
125
  }
108
126
 
109
127
  function globLabel(args: ToolCallArgs): string {
110
128
  const r = parseRaw(args.rawInput);
111
129
  const pattern = str(r.pattern) ?? "…";
112
130
  const scope = relativize(str(r.path) ?? ".");
113
- return `${bold("glob")} ${accent(pattern)} ${muted(`in ${scope}`)}`;
131
+ return `${iconPrefix("glob")}${bold("glob")} ${accent(pattern)} ${muted(`in ${scope}`)}`;
114
132
  }
115
133
 
116
134
  function lsLabel(args: ToolCallArgs): string {
117
135
  const r = parseRaw(args.rawInput);
118
136
  const p = str(r.path) ?? ".";
119
- return `${bold("ls")} ${accent(relativize(p))}`;
137
+ return `${iconPrefix("ls")}${bold("ls")} ${accent(relativize(p))}`;
120
138
  }
121
139
 
122
140
  function pathOnlyLabel(name: string, args: ToolCallArgs): string {
123
141
  const r = parseRaw(args.rawInput);
124
142
  const path = str(r.file_path) ?? str(r.path);
125
- return `${bold(name)} ${accent(path ? relativize(path) : "…")}`;
143
+ return `${iconPrefix(name)}${bold(name)} ${accent(path ? relativize(path) : "…")}`;
126
144
  }
127
145
 
128
146
  function genericLabel(args: ToolCallArgs): string {
129
147
  const detail = args.displayDetail ? ` ${muted(args.displayDetail)}` : "";
130
- return `${bold(args.title)}${detail}`;
148
+ return `${iconPrefix(args.name)}${bold(args.title)}${detail}`;
131
149
  }
132
150
 
133
151
  export function registerDefaultToolRenderers(ctx: ExtensionContext): void {
@@ -40,11 +40,52 @@ import { resumeSession } from "./session-commands.js";
40
40
  import { applyBranchMessages } from "./commands.js";
41
41
  import type { Capture } from "./capture.js";
42
42
  import { execSync } from "node:child_process";
43
- import { renderDiff } from "agent-sh/utils/diff-renderer.js";
43
+ import { renderDiff, detectLanguage, highlightLine } from "agent-sh/utils/diff-renderer.js";
44
44
  import { renderBoxFrame } from "agent-sh/utils/box-frame.js";
45
45
 
46
46
  interface DiffStats { added: number; removed: number; isNewFile: boolean; isIdentical: boolean }
47
47
 
48
+ function buildDiffRenderer(
49
+ diff: DiffStats & Parameters<typeof renderDiff>[0],
50
+ filePath: string,
51
+ ): (width: number) => string[] {
52
+ return (width) => {
53
+ const boxW = Math.max(40, width);
54
+ const contentW = Math.max(20, boxW - 4);
55
+ const inner = diff.isNewFile
56
+ ? renderNewFilePreview(diff, 30, filePath)
57
+ : ((): string[] => {
58
+ const lines = renderDiff(diff, {
59
+ width: contentW, filePath, trueColor: true, maxLines: Number.MAX_SAFE_INTEGER, mode: "unified",
60
+ });
61
+ return lines.length > 1 ? ["", ...lines.slice(1), ""] : lines;
62
+ })();
63
+ return renderBoxFrame(inner, {
64
+ width: boxW,
65
+ style: "rounded",
66
+ title: diffFrameTitle(filePath, diff),
67
+ });
68
+ };
69
+ }
70
+
71
+ function renderNewFilePreview(
72
+ diff: { hunks?: { lines: { type: string; text: string }[] }[] },
73
+ maxLines: number,
74
+ filePath: string,
75
+ ): string[] {
76
+ const lines = diff.hunks?.[0]?.lines.filter((l) => l.type === "added") ?? [];
77
+ const shown = lines.slice(0, maxLines);
78
+ const overflow = lines.length - shown.length;
79
+ const noW = String(shown.length).length || 1;
80
+ const lang = detectLanguage(filePath);
81
+ const body = shown.map((l, i) => {
82
+ const no = String(i + 1).padStart(noW);
83
+ return `${theme.fg("muted", `${no} │`)} ${highlightLine(l.text, lang)}`;
84
+ });
85
+ if (overflow > 0) body.push(theme.fg("muted", `… ${overflow} more lines`));
86
+ return ["", ...body, ""];
87
+ }
88
+
48
89
  function diffFrameTitle(filePath: string, diff: DiffStats): string {
49
90
  const stats = diff.isNewFile
50
91
  ? theme.fg("success", `+${diff.added}`)
@@ -334,7 +375,13 @@ export function mountAshi(
334
375
  if (found.kind === "group") {
335
376
  found.group.recordCompletion(id, 0, summary);
336
377
  } else {
337
- if (text) found.pair.result.appendChunk(text);
378
+ const meta = m.meta as { diff?: unknown; filePath?: string } | undefined;
379
+ if (meta?.diff && typeof meta.filePath === "string") {
380
+ const diff = meta.diff as DiffStats & Parameters<typeof renderDiff>[0];
381
+ if (!diff.isIdentical) {
382
+ found.pair.result.setDiffRenderer(buildDiffRenderer(diff, meta.filePath));
383
+ }
384
+ }
338
385
  found.pair.result.finalize({ exitCode: 0, summary });
339
386
  found.pair.call.setStatus({ exitCode: 0, elapsedMs: 0, summary });
340
387
  }
@@ -485,27 +532,10 @@ export function mountAshi(
485
532
  }
486
533
  const pair = entry.pair;
487
534
  const body = e.resultDisplay?.body;
488
- const ok = e.exitCode === null || e.exitCode === 0;
489
535
  if (body?.kind === "diff") {
490
536
  const diff = body.diff as DiffStats & Parameters<typeof renderDiff>[0];
491
537
  if (!diff.isIdentical) {
492
- const termW = process.stdout.columns ?? 80;
493
- const boxW = Math.max(40, termW);
494
- const contentW = Math.max(20, boxW - 4);
495
- const diffLines = renderDiff(diff, {
496
- width: contentW,
497
- filePath: body.filePath,
498
- trueColor: true,
499
- maxLines: 30,
500
- });
501
- const inner = diffLines.length > 1 ? ["", ...diffLines.slice(1), ""] : diffLines;
502
- const framed = renderBoxFrame(inner, {
503
- width: boxW,
504
- style: "rounded",
505
- title: diffFrameTitle(body.filePath, diff),
506
- bgColor: theme.bgCode(ok ? "toolSuccessBg" : "toolErrorBg"),
507
- });
508
- pair.result.setDiff(framed);
538
+ pair.result.setDiffRenderer(buildDiffRenderer(diff, body.filePath));
509
539
  }
510
540
  }
511
541
  pair.call.setStatus({ exitCode: e.exitCode, elapsedMs: Date.now() - pair.startedAt, summary });
@@ -45,11 +45,13 @@ export interface ToolCallView extends Component {
45
45
  }
46
46
 
47
47
  /** Mutated by ashi as output streams in and when the tool completes.
48
- * setDiff is optional behavior — renderers may no-op if they don't show diffs.
49
- * toggleExpanded flips the view's internal expansion state (Ctrl+O). */
48
+ * setDiffRenderer is optional behavior — renderers may no-op if they don't
49
+ * show diffs. The renderer is called on each terminal-width change so diffs
50
+ * reflow on resize. toggleExpanded flips the view's internal expansion state
51
+ * (Ctrl+O). */
50
52
  export interface ToolResultView extends Component {
51
53
  appendChunk(chunk: string): void;
52
- setDiff(lines: string[]): void;
54
+ setDiffRenderer(fn: (width: number) => string[]): void;
53
55
  finalize(opts: { exitCode: number | null; summary?: string }): void;
54
56
  toggleExpanded(): void;
55
57
  }
@@ -14,6 +14,7 @@ export interface AgentMessage {
14
14
  tool_calls?: ToolCall[];
15
15
  tool_call_id?: string;
16
16
  name?: string;
17
+ meta?: Record<string, unknown>;
17
18
  }
18
19
 
19
20
  export interface SessionHeaderEntry {
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Ollama Cloud — hosted Ollama models (https://ollama.com).
3
+ *
4
+ * Auth: agent-sh auth login ollama-cloud
5
+ * Usage: agent-sh -e ./examples/extensions/ollama-cloud.ts
6
+ */
7
+ import { resolveApiKey } from "agent-sh/auth";
8
+ import type { AgentContext } from "agent-sh/types";
9
+
10
+ const HOST = "https://ollama.com";
11
+ const BASE_URL = `${HOST}/v1`;
12
+ const ID = "ollama-cloud";
13
+
14
+ function buildReasoningParams(level: string, _model?: string): Record<string, unknown> {
15
+ return { reasoning_effort: level === "off" ? "none" : level };
16
+ }
17
+
18
+ async function fetchModels(apiKey: string) {
19
+ const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
20
+ const tagsRes = await fetch(`${HOST}/api/tags`, { headers });
21
+ if (!tagsRes.ok) return [];
22
+ const tagsData = await tagsRes.json() as { models?: { name: string }[] };
23
+ const names = (tagsData.models ?? []).map((m) => m.name);
24
+ if (!names.length) return [];
25
+
26
+ const ctxs = await Promise.all(
27
+ names.map((name) =>
28
+ fetch(`${HOST}/api/show`, {
29
+ method: "POST",
30
+ headers: { ...headers, "Content-Type": "application/json" },
31
+ body: JSON.stringify({ name }),
32
+ })
33
+ .then((r) => r.ok ? r.json() as Promise<{ model_info?: Record<string, unknown> }> : null)
34
+ .then((d) => {
35
+ if (!d?.model_info) return undefined;
36
+ const info = d.model_info;
37
+ const arch = info["general.architecture"] as string | undefined;
38
+ if (arch) {
39
+ const ctx = info[`${arch}.context_length`];
40
+ if (typeof ctx === "number") return ctx;
41
+ }
42
+ for (const [k, v] of Object.entries(info)) {
43
+ if (k.endsWith(".context_length") && typeof v === "number") return v;
44
+ }
45
+ return undefined;
46
+ })
47
+ .catch(() => undefined),
48
+ ),
49
+ );
50
+
51
+ return names.map((name, i) => ({
52
+ id: name,
53
+ contextWindow: ctxs[i],
54
+ echoReasoning: /deepseek/i.test(name),
55
+ }));
56
+ }
57
+
58
+ export default function activate(ctx: AgentContext): void {
59
+ const { key } = resolveApiKey(ID);
60
+ const apiKey = key ?? process.env.OLLAMA_API_KEY;
61
+ if (!apiKey) return;
62
+
63
+ ctx.agent.providers.configure(ID, { reasoningParams: buildReasoningParams });
64
+
65
+ // Register placeholder while catalog loads
66
+ ctx.bus.emit("provider:register", { id: ID, apiKey, baseURL: BASE_URL, models: [] });
67
+
68
+ fetchModels(apiKey).then((models) => {
69
+ if (!models.length) return;
70
+ ctx.bus.emit("provider:register", {
71
+ id: ID,
72
+ apiKey,
73
+ baseURL: BASE_URL,
74
+ defaultModel: models[0]!.id,
75
+ models,
76
+ });
77
+ }).catch(() => { /* keep placeholder */ });
78
+ }
@@ -37,6 +37,10 @@ export default function activate(ctx: ExtensionContext): void {
37
37
  const headers: Record<string, string> = {};
38
38
  if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
39
39
 
40
+ ctx.agent.providers.configure(id, {
41
+ reasoningParams: (level) => ({ reasoning_effort: level === "off" ? "none" : level }),
42
+ });
43
+
40
44
  ctx.bus.emit("provider:register", { id, apiKey: sdkKey, baseURL, models: [] });
41
45
 
42
46
  fetchCatalog(host, headers).then((models) => {
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Z.AI Coding Plan — Zhipu AI's subscription GLM models for coding tools.
3
+ *
4
+ * Auth: agent-sh auth login zai-coding-plan
5
+ * Usage: agent-sh -e ./examples/extensions/zai-coding-plan.ts
6
+ */
7
+ import { resolveApiKey } from "agent-sh/auth";
8
+ import type { AgentContext } from "agent-sh/types";
9
+
10
+ const BASE_URL = "https://api.z.ai/api/coding/paas/v4";
11
+ const ID = "zai-coding-plan";
12
+
13
+ const DEFAULT_MODELS = [
14
+ { id: "glm-5.1", reasoning: true, contextWindow: 200_000 },
15
+ { id: "glm-5-turbo", reasoning: true, contextWindow: 200_000 },
16
+ { id: "glm-4.7", reasoning: true, contextWindow: 204_800 },
17
+ { id: "glm-4.5-air", reasoning: true, contextWindow: 131_072 },
18
+ ];
19
+
20
+ function buildReasoningParams(level: string, _model?: string): Record<string, unknown> {
21
+ if (level === "off") return { thinking: { type: "disabled" } };
22
+ return { thinking: { type: "enabled" }, reasoning_effort: level };
23
+ }
24
+
25
+ export default function activate(ctx: AgentContext): void {
26
+ const { key } = resolveApiKey(ID);
27
+ const apiKey = key ?? process.env.ZAI_API_KEY ?? process.env.ZHIPU_API_KEY;
28
+ if (!apiKey) return;
29
+
30
+ ctx.agent.providers.configure(ID, { reasoningParams: buildReasoningParams });
31
+
32
+ ctx.bus.emit("provider:register", {
33
+ id: ID,
34
+ apiKey: apiKey,
35
+ baseURL: BASE_URL,
36
+ defaultModel: DEFAULT_MODELS[0].id,
37
+ models: DEFAULT_MODELS,
38
+ });
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core/index.js",
@@ -101,6 +101,10 @@
101
101
  "./cli/auth": {
102
102
  "types": "./dist/cli/auth/cli.d.ts",
103
103
  "default": "./dist/cli/auth/cli.js"
104
+ },
105
+ "./auth": {
106
+ "types": "./dist/cli/auth/keys.d.ts",
107
+ "default": "./dist/cli/auth/keys.js"
104
108
  }
105
109
  },
106
110
  "files": [