agent-sh 0.12.21 → 0.12.23

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.
@@ -1,41 +1,49 @@
1
1
  /**
2
2
  * Overlay agent extension.
3
3
  *
4
- * Provides a hotkey (Ctrl+\) to summon the agent from anywhere even
5
- * inside vim, htop, or ssh. Composites a floating response box on top
6
- * of the current terminal content.
4
+ * Press Ctrl+\ from anywhere — a shell prompt, vim, ssh, htop, a REPLto
5
+ * summon the agent in a floating panel composited over the current terminal.
6
+ * The agent sees the live screen as `<terminal_buffer>` context (when a TUI
7
+ * is active) or `<shell_events>` (at a shell prompt), so screen-aware
8
+ * questions answer without a tool round-trip.
7
9
  *
8
- * Uses createRemoteSession() to route the full tui-renderer pipeline
9
- * (markdown, tool grouping, spinner, diffs) into the floating panel.
10
+ * Install (from an npm install of agent-sh):
11
+ * mkdir -p ~/.agent-sh/extensions
12
+ * cp "$(npm root -g)/agent-sh/examples/extensions/overlay-agent.ts" \
13
+ * ~/.agent-sh/extensions/
10
14
  *
11
- * Install:
12
- * cp examples/extensions/overlay-agent.ts ~/.agent-sh/extensions/
15
+ * Or load ad-hoc without copying:
16
+ * agent-sh -e "$(npm root -g)/agent-sh/examples/extensions/overlay-agent.ts"
13
17
  *
14
- * Or load directly:
15
- * agent-sh -e ./examples/extensions/overlay-agent.ts
16
- *
17
- * Requires: npm install @xterm/headless@5.5.0 @xterm/addon-serialize@0.13.0
18
+ * Optional companion extensions (copy the same way) — without them the
19
+ * overlay can read the screen but cannot interact with it:
20
+ * - terminal-buffer.ts → terminal_read / terminal_keys tools
21
+ * - user-shell.ts → user_shell tool (run new shell commands)
18
22
  */
19
23
  import type { ExtensionContext, RemoteSession } from "agent-sh/types";
20
24
  import type { RenderSurface } from "agent-sh/utils/compositor";
21
25
  import { FloatingPanel } from "agent-sh/utils/floating-panel";
26
+ import { formatScreenContext, type TerminalBuffer } from "agent-sh/utils/terminal-buffer";
22
27
 
23
28
  /** Adapt a FloatingPanel to the RenderSurface interface. */
24
29
  function createPanelSurface(panel: FloatingPanel): RenderSurface {
30
+ // Track the spinner row so a stop-clear ("\r\x1b[2K") removes it
31
+ // instead of leaving an orphan blank line in the panel.
32
+ let spinnerLine = false;
25
33
  return {
26
34
  write(text: string): void {
27
- // Handle \r (carriage return) — overwrite the current line.
28
- // The spinner uses "\r <content>\x1b[K" to update in-place.
29
35
  if (text.startsWith("\r")) {
30
- // Strip \r and any erase-line sequences
31
36
  const cleaned = text.replace(/^\r/, "").replace(/\x1b\[\d*K/g, "");
32
37
  if (cleaned.trim()) {
33
- panel.updateLastLine(() => cleaned);
38
+ if (spinnerLine) panel.updateLastLine(() => cleaned);
39
+ else { panel.appendLine(cleaned); spinnerLine = true; }
40
+ } else if (spinnerLine) {
41
+ panel.popLastLine();
42
+ spinnerLine = false;
34
43
  }
35
44
  return;
36
45
  }
37
-
38
- // Regular text — may contain newlines
46
+ if (spinnerLine) { panel.popLastLine(); spinnerLine = false; }
39
47
  panel.appendText(text);
40
48
  },
41
49
  writeLine(line: string): void {
@@ -44,12 +52,23 @@ function createPanelSurface(panel: FloatingPanel): RenderSurface {
44
52
  get columns(): number {
45
53
  return panel.computeGeometry().contentW;
46
54
  },
55
+ get rows(): number {
56
+ return panel.computeGeometry().contentH;
57
+ },
58
+ onResize(cb: (cols: number, rows: number) => void): () => void {
59
+ const handler = () => {
60
+ const g = panel.computeGeometry();
61
+ cb(g.contentW, g.contentH);
62
+ };
63
+ process.stdout.on("resize", handler);
64
+ return () => { process.stdout.off("resize", handler); };
65
+ },
47
66
  };
48
67
  }
49
68
 
50
69
  export default function activate(ctx: ExtensionContext): void {
51
70
  const { bus, registerInstruction, createRemoteSession } = ctx;
52
- const terminalBuffer = ctx.call("terminal-buffer");
71
+ const terminalBuffer: TerminalBuffer | null = ctx.call("terminal-buffer");
53
72
 
54
73
  const panel = new FloatingPanel(bus, {
55
74
  trigger: "\x1c", // Ctrl+\
@@ -60,22 +79,21 @@ export default function activate(ctx: ExtensionContext): void {
60
79
  const panelSurface = createPanelSurface(panel);
61
80
  let session: RemoteSession | null = null;
62
81
 
63
- // Tell the LLM it's running inside an overlay session. The matching
64
- // system-prompt block (registered via registerInstruction below) describes
65
- // how to behave in this mode.
66
82
  ctx.registerContextProducer("interactive-session", () =>
67
83
  session?.active ? "interactive-session: true" : null,
68
84
  );
69
85
 
86
+ // Inject the live screen for TUI / REPL programs. At a plain shell prompt
87
+ // `<shell_events>` already covers the visible scrollback — skip to dedupe.
88
+ ctx.registerContextProducer("terminal-screen", () => {
89
+ if (!session?.active || !terminalBuffer?.altScreen) return null;
90
+ return formatScreenContext(terminalBuffer.readScreen(), 80);
91
+ });
92
+
70
93
  registerInstruction("Interactive Overlay Sessions", [
71
- "When the dynamic context includes `interactive-session: true`, the user has summoned you",
72
- "via a hotkey overlay from inside their live terminal. They may be in the middle of using",
73
- "a program (vim, ssh, a REPL, etc.) or at a shell prompt. In this mode:",
74
- "- Start with terminal_read if you need to understand what's on screen.",
75
- "- Prefer terminal_keys to interact with whatever is currently running.",
76
- "- Use user_shell only for running new, standalone commands — not for interacting with",
77
- " what's already on screen.",
78
- "- Keep responses concise — the user is in the middle of a workflow.",
94
+ "When dynamic context includes `interactive-session: true`, the user summoned you via a",
95
+ "hotkey overlay from their live terminal. They're mid-workflow (shell prompt, vim, ssh, a",
96
+ "REPL, etc.) keep responses concise and prefer reading what's on screen over asking.",
79
97
  ].join("\n"));
80
98
 
81
99
  // ── Panel lifecycle ────────────────────────────────────────────
@@ -84,7 +102,6 @@ export default function activate(ctx: ExtensionContext): void {
84
102
  if (!session) {
85
103
  session = createRemoteSession({
86
104
  surface: panelSurface,
87
- suppressQueryBox: true,
88
105
  });
89
106
  }
90
107
  panel.setActive();
@@ -92,18 +109,13 @@ export default function activate(ctx: ExtensionContext): void {
92
109
  });
93
110
 
94
111
  panel.handlers.advise("panel:show", (_next) => {
95
- // Re-establish session if panel is shown while agent is still working
96
112
  if (panel.active && !session) {
97
- session = createRemoteSession({
98
- surface: panelSurface,
99
- suppressQueryBox: true,
100
- });
113
+ session = createRemoteSession({ surface: panelSurface });
101
114
  }
102
115
  });
103
116
 
104
- // On dismiss: close session only if agent is not actively processing.
105
- // If agent is still working (phase="active"), keep session alive so
106
- // output buffers in the panel and agent can keep executing tools.
117
+ // Keep the session alive while the agent is still working, even after
118
+ // dismiss so output keeps buffering and tools keep executing.
107
119
  panel.handlers.advise("panel:dismiss", (next) => {
108
120
  next();
109
121
  if (session && !panel.processing) {
@@ -113,10 +125,6 @@ export default function activate(ctx: ExtensionContext): void {
113
125
  });
114
126
 
115
127
  bus.on("agent:processing-done", () => {
116
- if (!panel.active) return;
117
- panel.setDone();
118
- // If panel was hidden while processing (passthrough), setDone()
119
- // triggers dismiss() which closes the session above.
120
- // If panel is still visible, session stays for the follow-up prompt.
128
+ if (panel.active) panel.setDone();
121
129
  });
122
130
  }
@@ -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.21",
3
+ "version": "0.12.23",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",