agent-sh 0.12.20 → 0.12.22

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 (37) hide show
  1. package/README.md +11 -3
  2. package/dist/agent/agent-loop.d.ts +1 -0
  3. package/dist/agent/agent-loop.js +30 -5
  4. package/dist/agent/conversation-state.d.ts +3 -2
  5. package/dist/agent/conversation-state.js +27 -14
  6. package/dist/agent/normalize-args.d.ts +29 -0
  7. package/dist/agent/normalize-args.js +56 -0
  8. package/dist/agent/subagent.js +2 -0
  9. package/dist/core.d.ts +3 -1
  10. package/dist/core.js +16 -22
  11. package/dist/event-bus.d.ts +9 -2
  12. package/dist/event-bus.js +9 -0
  13. package/dist/extensions/agent-backend.js +104 -24
  14. package/dist/extensions/index.js +8 -3
  15. package/dist/extensions/providers/deepseek.d.ts +8 -0
  16. package/dist/extensions/providers/deepseek.js +23 -0
  17. package/dist/extensions/providers/openai-compatible.d.ts +7 -0
  18. package/dist/extensions/providers/openai-compatible.js +30 -0
  19. package/dist/extensions/providers/openai.d.ts +7 -0
  20. package/dist/extensions/providers/openai.js +39 -0
  21. package/dist/extensions/{openrouter.d.ts → providers/openrouter.d.ts} +1 -1
  22. package/dist/extensions/{openrouter.js → providers/openrouter.js} +5 -3
  23. package/dist/extensions/slash-commands.js +0 -24
  24. package/dist/extensions/tui-renderer.js +28 -15
  25. package/dist/index.js +8 -33
  26. package/dist/settings.d.ts +2 -0
  27. package/dist/settings.js +1 -0
  28. package/dist/types.d.ts +14 -1
  29. package/dist/utils/box-frame.js +14 -8
  30. package/dist/utils/llm-client.d.ts +5 -1
  31. package/dist/utils/llm-client.js +6 -1
  32. package/dist/utils/llm-facade.js +5 -5
  33. package/examples/extensions/pi-bridge/README.md +12 -19
  34. package/examples/extensions/pi-bridge/index.ts +307 -35
  35. package/package.json +1 -1
  36. package/dist/extensions/openai.d.ts +0 -9
  37. package/dist/extensions/openai.js +0 -49
@@ -5,7 +5,7 @@
5
5
  * never writes to stdout. Supports multiple border styles and
6
6
  * optional title/footer sections with dividers.
7
7
  */
8
- import { visibleLen, truncateToWidth } from "./ansi.js";
8
+ import { visibleLen, truncateToWidth, truncateAnsiToWidth } from "./ansi.js";
9
9
  import { palette as p } from "./palette.js";
10
10
  const BORDERS = {
11
11
  rounded: { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│", ml: "├", mr: "┤" },
@@ -32,14 +32,20 @@ export function renderBoxFrame(content, opts) {
32
32
  const output = [];
33
33
  // Top border (with optional left/right titles)
34
34
  if (opts.title || opts.titleRight) {
35
- const leftPart = opts.title
36
- ? `${p.reset} ${opts.title} ${bc}`
37
- : "";
38
- const leftVis = opts.title ? visibleLen(opts.title) + 2 : 0; // +2 for spaces
39
- const rightPart = opts.titleRight
40
- ? `${p.reset} ${opts.titleRight} ${bc}`
41
- : "";
35
+ // Budget: 2 corners + 1 minimum dash + space-padding around each title.
36
+ // Truncate the left title first if combined widths overflow — titleRight
37
+ // is typically short metadata (model name, stats) worth preserving.
38
+ let title = opts.title;
42
39
  const rightVis = opts.titleRight ? visibleLen(opts.titleRight) + 2 : 0;
40
+ const leftBudget = width - 2 - 1 - rightVis; // total - corners - min dash - right
41
+ let leftVis = title ? visibleLen(title) + 2 : 0;
42
+ if (title && leftVis > leftBudget) {
43
+ const maxTitleVis = Math.max(1, leftBudget - 2);
44
+ title = truncateAnsiToWidth(title, maxTitleVis);
45
+ leftVis = visibleLen(title) + 2;
46
+ }
47
+ const leftPart = title ? `${p.reset} ${title} ${bc}` : "";
48
+ const rightPart = opts.titleRight ? `${p.reset} ${opts.titleRight} ${bc}` : "";
43
49
  const dashCount = Math.max(1, width - 2 - leftVis - rightVis);
44
50
  output.push(`${bc}${b.tl}${leftPart}${b.h.repeat(dashCount)}${rightPart}${b.tr}${p.reset}`);
45
51
  }
@@ -33,7 +33,8 @@ export declare class LlmClient {
33
33
  tools?: ChatCompletionTool[];
34
34
  model?: string;
35
35
  max_tokens?: number;
36
- /** Reasoning effort level (e.g. "low", "medium", "high"). Provider-dependent. */
36
+ /** Reasoning effort: "off" | "low" | "medium" | "high". Provider-dependent;
37
+ * "off" matches agent-loop's thinkingLevel and omits the field. */
37
38
  reasoning_effort?: string;
38
39
  signal?: AbortSignal;
39
40
  }): import("openai").APIPromise<import("openai/core/streaming.mjs").Stream<OpenAI.Chat.Completions.ChatCompletionChunk>>;
@@ -45,5 +46,8 @@ export declare class LlmClient {
45
46
  messages: ChatCompletionMessageParam[];
46
47
  model?: string;
47
48
  max_tokens?: number;
49
+ /** Reasoning effort: "off" | "low" | "medium" | "high". Provider-dependent;
50
+ * "off" matches agent-loop's thinkingLevel and omits the field. */
51
+ reasoning_effort?: string;
48
52
  }): Promise<string>;
49
53
  }
@@ -40,6 +40,7 @@ export class LlmClient {
40
40
  * Returns an async iterable of chunks.
41
41
  */
42
42
  stream(opts) {
43
+ const sendEffort = opts.reasoning_effort && opts.reasoning_effort !== "off";
43
44
  const body = {
44
45
  model: opts.model ?? this.model,
45
46
  messages: opts.messages,
@@ -47,7 +48,7 @@ export class LlmClient {
47
48
  max_tokens: opts.max_tokens ?? 65536,
48
49
  stream: true,
49
50
  stream_options: { include_usage: true },
50
- ...(opts.reasoning_effort
51
+ ...(sendEffort
51
52
  ? { reasoning_effort: opts.reasoning_effort }
52
53
  : {}),
53
54
  };
@@ -58,10 +59,14 @@ export class LlmClient {
58
59
  * Returns the text content of the first choice.
59
60
  */
60
61
  async complete(opts) {
62
+ const sendEffort = opts.reasoning_effort && opts.reasoning_effort !== "off";
61
63
  const response = await this.client.chat.completions.create({
62
64
  model: opts.model ?? this.model,
63
65
  messages: opts.messages,
64
66
  max_tokens: opts.max_tokens ?? 1024,
67
+ ...(sendEffort
68
+ ? { reasoning_effort: opts.reasoning_effort }
69
+ : {}),
65
70
  });
66
71
  return response.choices[0]?.message?.content ?? "";
67
72
  }
@@ -1,18 +1,18 @@
1
1
  export function createLlmFacade(handlers) {
2
- const invoke = (messages, maxTokens) => {
3
- const result = handlers.call("llm:invoke", messages, { maxTokens });
2
+ const invoke = (messages, maxTokens, model, reasoningEffort) => {
3
+ const result = handlers.call("llm:invoke", messages, { maxTokens, model, reasoningEffort });
4
4
  if (result === undefined)
5
5
  return Promise.reject(new Error("ctx.llm: no LLM backend available"));
6
6
  return result;
7
7
  };
8
8
  return {
9
9
  get available() { return handlers.list().includes("llm:invoke"); },
10
- ask: ({ query, system, maxTokens }) => {
10
+ ask: ({ query, system, maxTokens, model, reasoningEffort }) => {
11
11
  const messages = [];
12
12
  if (system)
13
13
  messages.push({ role: "system", content: system });
14
14
  messages.push({ role: "user", content: query });
15
- return invoke(messages, maxTokens);
15
+ return invoke(messages, maxTokens, model, reasoningEffort);
16
16
  },
17
17
  session: (opts = {}) => {
18
18
  const messages = [];
@@ -21,7 +21,7 @@ export function createLlmFacade(handlers) {
21
21
  const session = {
22
22
  async send(message) {
23
23
  messages.push({ role: "user", content: message });
24
- const reply = await invoke(messages, opts.maxTokens);
24
+ const reply = await invoke(messages, opts.maxTokens, opts.model, opts.reasoningEffort);
25
25
  messages.push({ role: "assistant", content: reply });
26
26
  return reply;
27
27
  },
@@ -1,14 +1,11 @@
1
1
  # pi-bridge
2
2
 
3
- Runs [pi](https://github.com/nickarora/pi)'s full coding agent as an agent-sh backend. Uses pi's own configuration, models, tools, and extensions — agent-sh just provides the terminal.
3
+ Runs [pi](https://github.com/badlogic/pi-mono) (`@mariozechner/pi-coding-agent`) as an agent-sh backend. Pi brings its own configuration, models, tools, and extensions — agent-sh just provides the terminal.
4
4
 
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- # Copy or symlink into your extensions directory
9
8
  cp -r examples/extensions/pi-bridge ~/.agent-sh/extensions/pi-bridge
10
-
11
- # Install dependencies
12
9
  cd ~/.agent-sh/extensions/pi-bridge
13
10
  npm install
14
11
  ```
@@ -26,26 +23,22 @@ Set as default backend in `~/.agent-sh/settings.json`:
26
23
  Or switch at runtime:
27
24
 
28
25
  ```
29
- ? /backend pi
26
+ > /backend pi
30
27
  ```
31
28
 
32
- ## Requirements
33
-
34
- - pi must be configured separately (`~/.pi/settings.json`) with API keys and model preferences
35
- - agent-sh does not override pi's configuration — it uses whatever pi is set up with
29
+ Pi reads its own settings from `~/.pi/agent/settings.json`. Configure API keys and model preferences there (or run `pi` directly to set up auth) — agent-sh does not override pi's configuration.
36
30
 
37
- ## What this bridge is
31
+ ## What works under pi
38
32
 
39
- A pure protocol translator between pi's event stream and agent-sh's bus events. Pi's built-in tools (command execution, file ops, etc.) are used exactly as pi ships them. The bridge adds no tools of its own.
33
+ These slash commands are routed to pi's SDK when pi is the active backend:
40
34
 
41
- ## What this bridge intentionally does NOT bundle
35
+ - `/model` lists/switches pi's available models (`session.setModel`)
36
+ - `/thinking` — sets pi's thinking level (`off/minimal/low/medium/high/xhigh`)
37
+ - `/compact` — runs `session.compact()` on pi's session
38
+ - `/context` — reports pi's token usage (`session.getContextUsage()`)
42
39
 
43
- Three PTY-access tools are left out on purpose:
40
+ agent-sh's per-query context producers (e.g. `<shell_events>` from `shell-context`) are inlined into pi's prompt before each query, so pi sees the user's recent shell activity even though it doesn't subscribe to agent-sh's shell bus directly.
44
41
 
45
- - `terminal_read` observe the user's live terminal screen
46
- - `terminal_keys` — send keystrokes to the user's PTY
47
- - `user_shell` — run commands in the user's live shell with lasting `cd`/`export`/`source` effects
48
-
49
- These are opt-in capabilities that belong in their own extensions. If you want any of them with pi, write a small companion extension that registers the tool as a pi `ToolDefinition` (TypeBox schema, wired to the relevant bus event: `shell:pty-write`, `shell:exec-request`, or `ctx.terminalBuffer.readScreen()`) and load it alongside pi-bridge.
42
+ ## What this bridge is
50
43
 
51
- Keeping this split means the bridge stays narrow only translating events and the capability surface is composable per-backend.
44
+ A pure protocol translator between pi's event stream and agent-sh's bus events. Pi's built-in tools (command execution, file ops, etc.) are used exactly as pi ships them. The bridge adds no tools of its own.
@@ -1,16 +1,6 @@
1
1
  /**
2
2
  * Pi bridge — runs pi's full coding agent in-process as agent-sh's backend.
3
- *
4
- * Uses pi's own AgentSession with its full configuration: model registry,
5
- * provider settings, extensions, session management, and tool system.
6
- * Agent-sh provides the shell frontend and TUI rendering.
7
- *
8
- * The bridge is a pure protocol translator between pi's event stream and
9
- * agent-sh's bus events. Pi brings its own tools for command execution,
10
- * file ops, etc. PTY-access tools (`terminal_read`, `terminal_keys`,
11
- * `user_shell`) are intentionally NOT bundled here — if you want pi to
12
- * observe or mutate the user's live terminal, load a companion extension
13
- * that registers those tools in pi's ToolDefinition format.
3
+ * Pure protocol translator between pi's event stream and agent-sh's bus.
14
4
  *
15
5
  * Setup:
16
6
  * npm install @mariozechner/pi-agent-core @mariozechner/pi-ai @mariozechner/pi-coding-agent
@@ -26,25 +16,127 @@ import {
26
16
  SessionManager,
27
17
  } from "@mariozechner/pi-coding-agent";
28
18
  import type { ExtensionContext } from "agent-sh/types";
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { resolve as resolvePath } from "node:path";
21
+ import { diffLines } from "diff";
22
+
23
+ const TOOL_KINDS: Record<string, string> = {
24
+ bash: "execute",
25
+ read: "read",
26
+ ls: "read",
27
+ find: "read",
28
+ grep: "search",
29
+ edit: "execute",
30
+ write: "execute",
31
+ };
32
+ const kindForTool = (name: string): string => TOOL_KINDS[name] ?? "execute";
33
+
34
+ type DiffLineRecord = { type: "context" | "added" | "removed"; oldNo: number | null; newNo: number | null; text: string };
35
+ type DiffHunkRecord = { lines: DiffLineRecord[] };
36
+ type DiffResultRecord = { hunks: DiffHunkRecord[]; added: number; removed: number; isIdentical: boolean; isNewFile: boolean };
37
+
38
+ function buildDiffFromTexts(oldText: string, newText: string, isNewFile: boolean): DiffResultRecord | null {
39
+ if (oldText === newText) return null;
40
+ const changes = diffLines(oldText, newText);
41
+ const allLines: DiffLineRecord[] = [];
42
+ let oldNo = 0;
43
+ let newNo = 0;
44
+ let added = 0;
45
+ let removed = 0;
46
+ for (const change of changes) {
47
+ const lines = change.value.replace(/\n$/, "").split("\n");
48
+ for (const text of lines) {
49
+ if (change.added) {
50
+ newNo++;
51
+ allLines.push({ type: "added", oldNo: null, newNo, text });
52
+ added++;
53
+ } else if (change.removed) {
54
+ oldNo++;
55
+ allLines.push({ type: "removed", oldNo, newNo: null, text });
56
+ removed++;
57
+ } else {
58
+ oldNo++;
59
+ newNo++;
60
+ allLines.push({ type: "context", oldNo, newNo, text });
61
+ }
62
+ }
63
+ }
64
+ if (allLines.length === 0) return null;
65
+ return {
66
+ hunks: [{ lines: allLines }],
67
+ added,
68
+ removed,
69
+ isIdentical: false,
70
+ isNewFile,
71
+ };
72
+ }
73
+
74
+ // Pi's edit returns a custom diff string: prefix(+/-/space) + lineNum + " " + text, "..." between hunks.
75
+ function parsePiDiff(raw: unknown): DiffResultRecord | null {
76
+ if (typeof raw !== "string" || raw.length === 0) return null;
77
+ const hunks: DiffHunkRecord[] = [];
78
+ let current: DiffLineRecord[] = [];
79
+ let added = 0;
80
+ let removed = 0;
81
+ let hasOriginal = false;
82
+ let delta = 0;
83
+
84
+ const flush = () => {
85
+ if (current.length > 0) hunks.push({ lines: current });
86
+ current = [];
87
+ };
88
+
89
+ for (const line of raw.split("\n")) {
90
+ if (line.length === 0) continue;
91
+ const prefix = line[0];
92
+ const rest = line.slice(1);
93
+ if (prefix === " " && rest.trim() === "...") { flush(); continue; }
94
+ const m = rest.match(/^\s*(\d+)\s(.*)$/);
95
+ if (!m) continue;
96
+ const num = parseInt(m[1]!, 10);
97
+ const text = m[2]!;
98
+ if (prefix === "+") {
99
+ current.push({ type: "added", oldNo: null, newNo: num, text });
100
+ added++;
101
+ delta++;
102
+ } else if (prefix === "-") {
103
+ current.push({ type: "removed", oldNo: num, newNo: null, text });
104
+ removed++;
105
+ delta--;
106
+ hasOriginal = true;
107
+ } else if (prefix === " ") {
108
+ current.push({ type: "context", oldNo: num, newNo: num + delta, text });
109
+ hasOriginal = true;
110
+ }
111
+ }
112
+ flush();
113
+
114
+ if (hunks.length === 0) return null;
115
+ return { hunks, added, removed, isIdentical: added + removed === 0, isNewFile: !hasOriginal };
116
+ }
29
117
 
30
- // ── Extension entry point ─────────────────────────────────────────
31
118
  export default function activate(ctx: ExtensionContext): void {
32
- const { bus } = ctx;
119
+ const { bus, call } = ctx;
33
120
  const cwd = process.cwd();
34
121
 
35
- // ── Boot pi session (async — register backend synchronously first) ──
36
122
  let session: any = null;
37
123
  let runtime: any = null;
124
+ let modelRegistry: any = null;
38
125
  let booting = true;
39
126
 
127
+ const PI_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
128
+
129
+ // Pi's tool_execution_end omits `args` — cache from start so the end handler can use the path.
130
+ const pendingArgs = new Map<string, any>();
131
+ // Snapshot disk content before pi writes; diffed against args.content at end.
132
+ const pendingWriteSnapshot = new Map<string, { oldContent: string; isNewFile: boolean }>();
133
+
40
134
  const boot = async () => {
41
135
  try {
42
- // Pi loads its own config: ~/.pi/agent/settings.json, models, extensions
43
136
  const services = await createAgentSessionServices({ cwd });
137
+ modelRegistry = services.modelRegistry;
44
138
  const sessionManager = SessionManager.inMemory(cwd);
45
139
 
46
- // createRuntime factory — returns { session, services, ... } as expected
47
- // by createAgentSessionRuntime
48
140
  const createRuntime = async (opts: any) => {
49
141
  const result = await createAgentSessionFromServices({
50
142
  services,
@@ -59,7 +151,6 @@ export default function activate(ctx: ExtensionContext): void {
59
151
  });
60
152
  session = runtime.session;
61
153
 
62
- // Subscribe to pi events → agent-sh bus
63
154
  let fullResponseText = "";
64
155
 
65
156
  session.subscribe((event: AgentEvent) => {
@@ -81,13 +172,46 @@ export default function activate(ctx: ExtensionContext): void {
81
172
  break;
82
173
  }
83
174
 
84
- case "tool_execution_start":
175
+ case "message_end": {
176
+ // Synthesize agent:tool-batch so tui-renderer groups parallel tool calls under one header.
177
+ const msg = (event as any).message;
178
+ if (msg?.role === "assistant" && Array.isArray(msg.content)) {
179
+ const groupMap = new Map<string, Array<{ name: string }>>();
180
+ for (const block of msg.content) {
181
+ if (block?.type === "toolCall" && typeof block.name === "string") {
182
+ const kind = kindForTool(block.name);
183
+ if (!groupMap.has(kind)) groupMap.set(kind, []);
184
+ groupMap.get(kind)!.push({ name: block.name });
185
+ }
186
+ }
187
+ if (groupMap.size > 0) {
188
+ const groups = Array.from(groupMap.entries()).map(([kind, tools]) => ({ kind, tools }));
189
+ bus.emit("agent:tool-batch", { groups });
190
+ }
191
+ }
192
+ break;
193
+ }
194
+
195
+ case "tool_execution_start": {
196
+ const ev = event as any;
197
+ if (ev.toolCallId) pendingArgs.set(ev.toolCallId, ev.args);
198
+ if (ev.toolName === "write" && ev.toolCallId && typeof ev.args?.path === "string") {
199
+ const abs = resolvePath(cwd, ev.args.path);
200
+ let oldContent = "";
201
+ let isNewFile = true;
202
+ if (existsSync(abs)) {
203
+ try { oldContent = readFileSync(abs, "utf8"); isNewFile = false; } catch {}
204
+ }
205
+ pendingWriteSnapshot.set(ev.toolCallId, { oldContent, isNewFile });
206
+ }
85
207
  bus.emit("agent:tool-started", {
86
- title: (event as any).toolName,
87
- toolCallId: (event as any).toolCallId,
88
- kind: (event as any).toolName === "bash" ? "execute" : "read",
208
+ title: ev.toolName,
209
+ toolCallId: ev.toolCallId,
210
+ kind: kindForTool(ev.toolName),
211
+ rawInput: ev.args,
89
212
  });
90
213
  break;
214
+ }
91
215
 
92
216
  case "tool_execution_update": {
93
217
  const pr = (event as any).partialResult as
@@ -103,13 +227,41 @@ export default function activate(ctx: ExtensionContext): void {
103
227
  break;
104
228
  }
105
229
 
106
- case "tool_execution_end":
230
+ case "tool_execution_end": {
231
+ const ev = event as any;
232
+ const args = ev.toolCallId ? pendingArgs.get(ev.toolCallId) : undefined;
233
+ if (ev.toolCallId) pendingArgs.delete(ev.toolCallId);
234
+ let resultDisplay: { body?: { kind: "diff"; diff: unknown; filePath: string } } | undefined;
235
+ if (ev.toolName === "edit" && typeof args?.path === "string") {
236
+ const rawDiff = ev.result?.details?.diff;
237
+ const parsed = parsePiDiff(rawDiff);
238
+ if (parsed) {
239
+ resultDisplay = {
240
+ body: { kind: "diff", diff: parsed, filePath: args.path },
241
+ };
242
+ }
243
+ } else if (ev.toolName === "write" && typeof args?.path === "string" && !ev.isError) {
244
+ const snap = ev.toolCallId ? pendingWriteSnapshot.get(ev.toolCallId) : undefined;
245
+ if (ev.toolCallId) pendingWriteSnapshot.delete(ev.toolCallId);
246
+ if (snap) {
247
+ const newContent = typeof args.content === "string" ? args.content : "";
248
+ const built = buildDiffFromTexts(snap.oldContent, newContent, snap.isNewFile);
249
+ if (built) {
250
+ resultDisplay = {
251
+ body: { kind: "diff", diff: built, filePath: args.path },
252
+ };
253
+ }
254
+ }
255
+ }
107
256
  bus.emit("agent:tool-completed", {
108
- toolCallId: (event as any).toolCallId,
109
- exitCode: (event as any).isError ? 1 : 0,
110
- kind: (event as any).toolName === "bash" ? "execute" : "read",
257
+ toolCallId: ev.toolCallId,
258
+ exitCode: ev.isError ? 1 : 0,
259
+ kind: kindForTool(ev.toolName),
260
+ rawOutput: ev.result,
261
+ resultDisplay,
111
262
  });
112
263
  break;
264
+ }
113
265
 
114
266
  case "agent_end":
115
267
  bus.emitTransform("agent:response-done", {
@@ -120,7 +272,6 @@ export default function activate(ctx: ExtensionContext): void {
120
272
  }
121
273
  });
122
274
 
123
- // Report model info
124
275
  const model = session.model;
125
276
  bus.emit("agent:info", {
126
277
  name: "pi",
@@ -137,8 +288,10 @@ export default function activate(ctx: ExtensionContext): void {
137
288
  }
138
289
  };
139
290
 
140
- // ── Bus listeners (wired on start, unwired on kill) ────────────
141
- const listeners: Array<{ event: string; fn: Function }> = [];
291
+ type ListenerEntry =
292
+ | { kind: "on"; event: string; fn: Function }
293
+ | { kind: "pipe"; event: string; fn: Function };
294
+ const listeners: ListenerEntry[] = [];
142
295
 
143
296
  const wireListeners = () => {
144
297
  const onSubmit = async ({ query }: any) => {
@@ -153,8 +306,12 @@ export default function activate(ctx: ExtensionContext): void {
153
306
  bus.emit("agent:query", { query });
154
307
  bus.emit("agent:processing-start", {});
155
308
 
309
+ // Inline producers raw — outputs already self-tag (<shell_events>...).
310
+ const ctxText = String(call("query-context:build") ?? "").trim();
311
+ const final = ctxText ? `${ctxText}\n\n${query}` : query;
312
+
156
313
  try {
157
- await session.prompt(query);
314
+ await session.prompt(final);
158
315
  } catch (err) {
159
316
  bus.emit("agent:error", {
160
317
  message: err instanceof Error ? err.message : String(err),
@@ -169,33 +326,148 @@ export default function activate(ctx: ExtensionContext): void {
169
326
  session = runtime?.session;
170
327
  };
171
328
 
329
+ const onListModels = () => {
330
+ if (!session || !modelRegistry) return { models: [], active: null };
331
+ const all = modelRegistry.getAvailable() as Array<{ id: string; provider: string }>;
332
+ const cur = session.model;
333
+ return {
334
+ models: all.map((m) => ({ model: m.id, provider: m.provider })),
335
+ active: cur ? { model: cur.id, provider: cur.provider } : null,
336
+ };
337
+ };
338
+
339
+ // Slash command emits `model@provider` for disambiguation; pi looks up by (provider, id).
340
+ const onSwitchModel = async ({ model: target }: { model: string }) => {
341
+ if (!session || !modelRegistry) return;
342
+ const atIdx = target.lastIndexOf("@");
343
+ const modelId = atIdx > 0 ? target.slice(0, atIdx) : target;
344
+ const providerHint = atIdx > 0 ? target.slice(atIdx + 1) : undefined;
345
+
346
+ const candidates = (modelRegistry.getAvailable() as Array<{ id: string; provider: string }>)
347
+ .filter((m) => m.id === modelId && (!providerHint || m.provider === providerHint));
348
+
349
+ if (candidates.length === 0) {
350
+ bus.emit("ui:error", { message: `Unknown model: ${target}` });
351
+ return;
352
+ }
353
+ if (candidates.length > 1) {
354
+ const opts = candidates.map((m) => `${m.id}@${m.provider}`).join(", ");
355
+ bus.emit("ui:error", { message: `Ambiguous model "${modelId}". Use one of: ${opts}` });
356
+ return;
357
+ }
358
+ const picked = candidates[0]!;
359
+ const full = modelRegistry.find(picked.provider, picked.id);
360
+ if (!full) {
361
+ bus.emit("ui:error", { message: `Model not found: ${target}` });
362
+ return;
363
+ }
364
+ try {
365
+ await session.setModel(full);
366
+ bus.emit("agent:info", {
367
+ name: "pi",
368
+ version: "0.66",
369
+ model: `${picked.provider}/${picked.id}`,
370
+ });
371
+ bus.emit("ui:info", { message: `Model: ${picked.provider}: ${picked.id}` });
372
+ bus.emit("config:changed", {});
373
+ } catch (err) {
374
+ bus.emit("ui:error", {
375
+ message: `Failed to switch model: ${err instanceof Error ? err.message : String(err)}`,
376
+ });
377
+ }
378
+ };
379
+
380
+ const onGetThinking = () => {
381
+ const level = session?.thinkingLevel ?? "off";
382
+ return { level, levels: [...PI_THINKING_LEVELS], supported: true };
383
+ };
384
+
385
+ const onSetThinking = ({ level }: { level: string }) => {
386
+ if (!session) return;
387
+ if (!PI_THINKING_LEVELS.includes(level as any)) {
388
+ bus.emit("ui:error", {
389
+ message: `Unknown thinking level: ${level}. Use: ${PI_THINKING_LEVELS.join(", ")}`,
390
+ });
391
+ return;
392
+ }
393
+ session.setThinkingLevel(level);
394
+ bus.emit("ui:info", { message: `Thinking: ${level}` });
395
+ bus.emit("config:changed", {});
396
+ };
397
+
172
398
  bus.on("agent:submit", onSubmit);
173
399
  bus.on("agent:cancel-request", onCancel);
174
400
  bus.on("agent:reset-session", onReset);
401
+ bus.on("config:switch-model", onSwitchModel as any);
402
+ bus.on("config:set-thinking", onSetThinking as any);
403
+ bus.onPipe("config:get-models", onListModels as any);
404
+ bus.onPipe("config:get-thinking", onGetThinking as any);
175
405
  listeners.push(
176
- { event: "agent:submit", fn: onSubmit },
177
- { event: "agent:cancel-request", fn: onCancel },
178
- { event: "agent:reset-session", fn: onReset },
406
+ { kind: "on", event: "agent:submit", fn: onSubmit },
407
+ { kind: "on", event: "agent:cancel-request", fn: onCancel },
408
+ { kind: "on", event: "agent:reset-session", fn: onReset },
409
+ { kind: "on", event: "config:switch-model", fn: onSwitchModel },
410
+ { kind: "on", event: "config:set-thinking", fn: onSetThinking },
411
+ { kind: "pipe", event: "config:get-models", fn: onListModels },
412
+ { kind: "pipe", event: "config:get-thinking", fn: onGetThinking },
179
413
  );
180
414
  };
181
415
 
182
416
  const unwireListeners = () => {
183
- for (const { event, fn } of listeners) bus.off(event as any, fn as any);
417
+ for (const { kind, event, fn } of listeners) {
418
+ if (kind === "pipe") bus.offPipe(event as any, fn as any);
419
+ else bus.off(event as any, fn as any);
420
+ }
184
421
  listeners.length = 0;
185
422
  };
186
423
 
187
- // ── Register as backend ───────────────────────────────────────
188
424
  bus.emit("agent:register-backend", {
189
425
  name: "pi",
190
426
  start: async () => {
191
427
  await boot();
192
428
  wireListeners();
429
+ bus.emit("command:register", {
430
+ name: "/compact",
431
+ description: "Compact pi's session context",
432
+ handler: async () => {
433
+ if (!session) return;
434
+ try {
435
+ await session.compact();
436
+ bus.emit("ui:info", { message: "(compacted)" });
437
+ } catch (err) {
438
+ bus.emit("ui:info", {
439
+ message: `(${err instanceof Error ? err.message : String(err)})`,
440
+ });
441
+ }
442
+ },
443
+ });
444
+ bus.emit("command:register", {
445
+ name: "/context",
446
+ description: "Show pi's context budget usage",
447
+ handler: () => {
448
+ if (!session) return;
449
+ const usage = session.getContextUsage() as { tokens: number; contextWindow: number } | undefined;
450
+ if (!usage) {
451
+ bus.emit("ui:info", { message: "Context: not available yet" });
452
+ return;
453
+ }
454
+ const pct = usage.contextWindow > 0
455
+ ? Math.round((usage.tokens / usage.contextWindow) * 100)
456
+ : 0;
457
+ bus.emit("ui:info", {
458
+ message: `Active context: ~${usage.tokens.toLocaleString()} tokens / ${usage.contextWindow.toLocaleString()} budget (${pct}%)`,
459
+ });
460
+ },
461
+ });
193
462
  },
194
463
  kill: () => {
464
+ bus.emit("command:unregister", { name: "/compact" });
465
+ bus.emit("command:unregister", { name: "/context" });
195
466
  unwireListeners();
196
467
  runtime?.dispose();
197
468
  session = null;
198
469
  runtime = null;
470
+ modelRegistry = null;
199
471
  booting = true;
200
472
  },
201
473
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.12.20",
3
+ "version": "0.12.22",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",
@@ -1,9 +0,0 @@
1
- /**
2
- * Built-in OpenAI-compatible provider. Two activation paths:
3
- * - OPENAI_API_KEY only → cloud OpenAI, ships a curated catalog.
4
- * - OPENAI_BASE_URL (any key) → local/3rd-party server (Ollama, LM Studio,
5
- * vLLM, llama.cpp); the catalog is fetched
6
- * from the server's /models endpoint.
7
- */
8
- import type { ExtensionContext } from "../types.js";
9
- export default function activate(ctx: ExtensionContext): void;