agent-sh 0.12.21 → 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.
package/README.md CHANGED
@@ -19,7 +19,7 @@ So I built agent-sh. Under the hood it's a normal shell on top of node-pty — y
19
19
  ~ $ > draft a commit message # agent reads your diff and shell history
20
20
  ```
21
21
 
22
- I still use Claude Code and pi for serious coding work — this doesn't replace them. But for the quick stuff in the terminal, I reach for agent-sh almost every day now. The built-in agent is lightweight and good enough for most of what I throw at it, and when it isn't, bridge extensions let you plug [Claude Code](examples/extensions/claude-code-bridge/) or [pi](examples/extensions/pi-bridge/) in as the backend.
22
+ I still use a proper coding harness for serious work — this doesn't replace that. But for the quick stuff in the terminal, I reach for agent-sh almost every day now. The built-in agent is lightweight and good enough for most of what I throw at it, and when it isn't, you can swap in [pi](examples/extensions/pi-bridge/) as the backend via a bridge extension.
23
23
 
24
24
  ## Quick Start
25
25
 
@@ -95,7 +95,7 @@ Requires Node.js 18+. Currently supports **bash** and **zsh**; other shells (fis
95
95
 
96
96
  **Context that just works.** Every query includes your cwd, recent commands, and their output. Run a failing test, type `> fix this`, and agent-sh knows exactly what happened. Context management works like shell history — continuous, persistent across restarts, no sessions to manage. See [Context Management](docs/context-management.md).
97
97
 
98
- **Any LLM, any backend.** agent-sh works with any OpenAI-compatible API out of the box. Define multiple providers in settings and switch models at runtime with `/model <name>`. Or swap in a completely different agent — [Claude Code](examples/extensions/claude-code-bridge/) and [pi](examples/extensions/pi-bridge/) run as drop-in backend extensions.
98
+ **Any LLM, any backend.** agent-sh works with any OpenAI-compatible API out of the box. Define multiple providers in settings and switch models at runtime with `/model <name>`. Or swap in a completely different agent — [pi](examples/extensions/pi-bridge/) runs as a drop-in backend extension.
99
99
 
100
100
  **Extensible by design.** The entire system is built on a typed event bus. Extensions can add custom input modes, content transforms (render LaTeX as images, Mermaid as diagrams), themes, slash commands, or replace the agent backend entirely. The built-in TUI renderer is itself just an extension.
101
101
 
@@ -38,6 +38,7 @@ export declare class AgentLoop implements AgentBackend {
38
38
  private modes;
39
39
  private currentModeIndex;
40
40
  private boundListeners;
41
+ private boundPipeListeners;
41
42
  private ctorListeners;
42
43
  private ctorPipeListeners;
43
44
  private lastProjectSkillNames;
@@ -59,6 +59,7 @@ export class AgentLoop {
59
59
  modes;
60
60
  currentModeIndex = 0;
61
61
  boundListeners = [];
62
+ boundPipeListeners = [];
62
63
  ctorListeners = [];
63
64
  ctorPipeListeners = [];
64
65
  lastProjectSkillNames = new Set();
@@ -216,12 +217,24 @@ export class AgentLoop {
216
217
  this.bus.on(event, fn);
217
218
  this.boundListeners.push({ event, fn });
218
219
  };
220
+ const onPipe = (event, fn) => {
221
+ this.bus.onPipe(event, fn);
222
+ this.boundPipeListeners.push({ event, fn, async: false });
223
+ };
224
+ const onPipeAsync = (event, fn) => {
225
+ this.bus.onPipeAsync(event, fn);
226
+ this.boundPipeListeners.push({ event, fn, async: true });
227
+ };
219
228
  on("agent:submit", ({ query }) => {
220
229
  this.handleQuery(query).catch(() => { });
221
230
  });
222
231
  on("agent:cancel-request", (e) => {
223
232
  this.abortController?.abort(e.silent ? "silent" : undefined);
224
233
  });
234
+ on("agent:append-user-message", ({ text }) => {
235
+ this.conversation.appendUserMessage(text);
236
+ this.bus.emit("conversation:message-appended", { role: "user", content: text });
237
+ });
225
238
  on("config:switch-model", ({ model: target }) => {
226
239
  const atIdx = target.lastIndexOf("@");
227
240
  const modelId = atIdx > 0 ? target.slice(0, atIdx) : target;
@@ -257,7 +270,7 @@ export class AgentLoop {
257
270
  }
258
271
  this.bus.emit("config:changed", {});
259
272
  });
260
- this.bus.onPipe("config:get-models", (payload) => {
273
+ onPipe("config:get-models", () => {
261
274
  const models = this.modes.map((m) => ({ model: m.model, provider: m.provider ?? "" }));
262
275
  const cur = this.modes[this.currentModeIndex];
263
276
  const active = cur ? { model: cur.model, provider: cur.provider ?? "" } : null;
@@ -281,7 +294,7 @@ export class AgentLoop {
281
294
  this.bus.emit("ui:info", { message: `Thinking: ${level}` });
282
295
  this.bus.emit("config:changed", {});
283
296
  });
284
- this.bus.onPipe("config:get-thinking", () => {
297
+ onPipe("config:get-thinking", () => {
285
298
  const mode = this.currentMode;
286
299
  const supported = mode.reasoning !== false && mode.supportsReasoningEffort !== false;
287
300
  return { level: this.thinkingLevel, levels: AgentLoop.THINKING_LEVELS, supported };
@@ -303,20 +316,20 @@ export class AgentLoop {
303
316
  this.bus.emit("ui:info", { message: "(nothing to compact)" });
304
317
  }
305
318
  });
306
- this.bus.onPipe("context:get-stats", () => ({
319
+ onPipe("context:get-stats", () => ({
307
320
  activeTokens: this.conversation.estimateTokens(),
308
321
  totalTokens: this.conversation.estimatePromptTokens(),
309
322
  nuclearEntries: this.conversation.getNuclearEntryCount(),
310
323
  recallArchiveSize: this.conversation.getRecallArchiveSize(),
311
324
  budgetTokens: this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
312
325
  }));
313
- this.bus.onPipe("context:snapshot", (payload) => {
326
+ onPipe("context:snapshot", (payload) => {
314
327
  payload.messages = this.conversation.getMessages();
315
328
  payload.contextWindow = this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
316
329
  payload.activeTokens = this.conversation.estimateTokens();
317
330
  return payload;
318
331
  });
319
- this.bus.onPipeAsync("context:compact", async (payload) => {
332
+ onPipeAsync("context:compact", async (payload) => {
320
333
  const stats = await this.compactWithHooks(0, undefined, false, payload.strategy);
321
334
  if (stats)
322
335
  payload.stats = { before: stats.before, after: stats.after, evictedCount: stats.evictedCount };
@@ -366,6 +379,13 @@ export class AgentLoop {
366
379
  this.bus.off(event, fn);
367
380
  }
368
381
  this.boundListeners = [];
382
+ for (const { event, fn, async } of this.boundPipeListeners) {
383
+ if (async)
384
+ this.bus.offPipeAsync(event, fn);
385
+ else
386
+ this.bus.offPipe(event, fn);
387
+ }
388
+ this.boundPipeListeners = [];
369
389
  }
370
390
  /** Register a tool (used by extensions via ctx.registerTool). */
371
391
  registerTool(tool) {
@@ -38,7 +38,7 @@ export declare class ConversationState {
38
38
  private nextSeq;
39
39
  private lastApiTokenCount;
40
40
  private lastApiMessageCount;
41
- private pendingNotes;
41
+ private pendingMessages;
42
42
  constructor(handlers?: HandlerFunctions, instanceId?: string);
43
43
  /** Get JSON.stringify of messages, cached until next mutation. */
44
44
  private getMessagesJson;
@@ -56,8 +56,9 @@ export declare class ConversationState {
56
56
  addToolResultInline(content: string): void;
57
57
  /** Safe from any context: queues if mid-tool-pair, appends otherwise. */
58
58
  addSystemNote(text: string): void;
59
+ appendUserMessage(text: string): void;
59
60
  private hasOpenToolCalls;
60
- private flushPendingNotes;
61
+ private flushPendingMessages;
61
62
  getMessages(): ChatCompletionMessageParam[];
62
63
  /** Drop tool messages with no matching preceding tool_call — strict
63
64
  * providers (DeepSeek) 400, and compaction can leave such orphans. */
@@ -56,10 +56,10 @@ export class ConversationState {
56
56
  nextSeq = 1;
57
57
  lastApiTokenCount = null;
58
58
  lastApiMessageCount = 0;
59
- // Notes queued when addSystemNote fires mid-tool-pair; flushed once
60
- // the trailing tool_result lands. Splicing into the gap breaks
61
- // reasoning_content pairing and is rejected by strict providers.
62
- pendingNotes = [];
59
+ // Buffered when addSystemNote/appendUserMessage fires mid-tool-pair;
60
+ // flushed once the trailing tool_result lands. Splicing into the gap
61
+ // breaks reasoning_content pairing and is rejected by strict providers.
62
+ pendingMessages = [];
63
63
  constructor(handlers, instanceId = "0000") {
64
64
  this.handlers = handlers ?? null;
65
65
  this.instanceId = instanceId;
@@ -114,23 +114,30 @@ export class ConversationState {
114
114
  if (isError)
115
115
  this.toolErrors.add(toolCallId);
116
116
  this.invalidateMessagesCache();
117
- this.flushPendingNotes();
117
+ this.flushPendingMessages();
118
118
  }
119
119
  /** Add tool results as a user message (for inline tool protocol). */
120
120
  addToolResultInline(content) {
121
121
  this.messages.push({ role: "user", content });
122
122
  this.invalidateMessagesCache();
123
- this.flushPendingNotes();
123
+ this.flushPendingMessages();
124
124
  }
125
125
  /** Safe from any context: queues if mid-tool-pair, appends otherwise. */
126
126
  addSystemNote(text) {
127
127
  if (this.hasOpenToolCalls()) {
128
- this.pendingNotes.push(text);
128
+ this.pendingMessages.push({ kind: "system", text });
129
129
  return;
130
130
  }
131
131
  this.messages.push({ role: "user", content: text });
132
132
  this.invalidateMessagesCache();
133
133
  }
134
+ appendUserMessage(text) {
135
+ if (this.hasOpenToolCalls()) {
136
+ this.pendingMessages.push({ kind: "user", text });
137
+ return;
138
+ }
139
+ this.addUserMessage(text);
140
+ }
134
141
  hasOpenToolCalls() {
135
142
  for (let i = this.messages.length - 1; i >= 0; i--) {
136
143
  const msg = this.messages[i];
@@ -151,15 +158,21 @@ export class ConversationState {
151
158
  }
152
159
  return false;
153
160
  }
154
- flushPendingNotes() {
155
- if (this.pendingNotes.length === 0)
161
+ flushPendingMessages() {
162
+ if (this.pendingMessages.length === 0)
156
163
  return;
157
164
  if (this.hasOpenToolCalls())
158
165
  return;
159
- for (const text of this.pendingNotes) {
160
- this.messages.push({ role: "user", content: text });
166
+ const pending = this.pendingMessages;
167
+ this.pendingMessages = [];
168
+ for (const m of pending) {
169
+ if (m.kind === "user") {
170
+ this.addUserMessage(m.text);
171
+ }
172
+ else {
173
+ this.messages.push({ role: "user", content: m.text });
174
+ }
161
175
  }
162
- this.pendingNotes = [];
163
176
  this.invalidateMessagesCache();
164
177
  }
165
178
  getMessages() {
@@ -244,7 +257,7 @@ export class ConversationState {
244
257
  this.invalidateMessagesCache();
245
258
  this.lastApiTokenCount = null;
246
259
  this.lastApiMessageCount = 0;
247
- this.flushPendingNotes();
260
+ this.flushPendingMessages();
248
261
  }
249
262
  pruneToolErrors() {
250
263
  if (this.toolErrors.size === 0)
@@ -544,7 +557,7 @@ export class ConversationState {
544
557
  this.nuclearEntries = [];
545
558
  this.nuclearBySeq.clear();
546
559
  this.recallArchive.clear();
547
- this.pendingNotes = [];
560
+ this.pendingMessages = [];
548
561
  this.invalidateMessagesCache();
549
562
  this.lastApiTokenCount = null;
550
563
  this.lastApiMessageCount = 0;
package/dist/core.d.ts CHANGED
@@ -37,11 +37,13 @@ export interface AgentShellCore {
37
37
  /** Unique id for this agent process; used for shell-marker tagging and lineage tracking. */
38
38
  instanceId: string;
39
39
  /** Activate the agent backend (call after extensions load). */
40
- activateBackend(): void;
40
+ activateBackend(): Promise<void>;
41
41
  /** Convenience: emit agent:submit and await the response. */
42
42
  query(text: string): Promise<string>;
43
43
  /** Convenience: emit agent:cancel-request. */
44
44
  cancel(): void;
45
+ /** Convenience: emit agent:append-user-message. */
46
+ appendUserMessage(text: string): void;
45
47
  /** Build an ExtensionContext for loading extensions against this core. */
46
48
  extensionContext(opts: {
47
49
  quit: () => void;
package/dist/core.js CHANGED
@@ -56,33 +56,30 @@ export function createCore(config) {
56
56
  handlers.define("query-context:build", () => "");
57
57
  const backends = new Map();
58
58
  let activeBackendName = null;
59
- const activateByName = async (name, silent = false) => {
59
+ const activateByName = async (name) => {
60
60
  const backend = backends.get(name);
61
61
  if (!backend) {
62
62
  bus.emit("ui:error", { message: `Unknown backend: ${name}` });
63
- return;
63
+ return false;
64
64
  }
65
- // Deactivate current backend
66
65
  if (activeBackendName) {
67
66
  backends.get(activeBackendName)?.kill();
68
67
  }
69
- // Activate new backend
70
68
  await backend.start?.();
71
69
  activeBackendName = name;
72
- if (!silent) {
73
- bus.emit("ui:info", { message: `Backend: ${name}` });
74
- }
75
- bus.emit("config:changed", {});
70
+ return true;
76
71
  };
77
72
  bus.on("agent:register-backend", (backend) => {
78
73
  backends.set(backend.name, backend);
79
74
  });
80
75
  bus.on("config:switch-backend", ({ name }) => {
81
- activateByName(name).then(() => {
82
- if (activeBackendName === name) {
83
- settingsMod.updateSettings({ defaultBackend: name });
84
- bus.emit("ui:info", { message: `Saved '${name}' as default backend.` });
85
- }
76
+ activateByName(name).then((ok) => {
77
+ if (!ok)
78
+ return;
79
+ settingsMod.updateSettings({ defaultBackend: name });
80
+ // Single ui:info; config:changed (which triggers prompt redraw) follows it.
81
+ bus.emit("ui:info", { message: `Backend: ${name} (saved as default)` });
82
+ bus.emit("config:changed", {});
86
83
  });
87
84
  });
88
85
  bus.on("config:list-backends", () => {
@@ -105,18 +102,12 @@ export function createCore(config) {
105
102
  bus,
106
103
  handlers,
107
104
  instanceId,
108
- activateBackend() {
109
- // Silent — backend info is shown in the startup banner.
110
- // Runtime switches (config:switch-backend) still emit ui:info.
105
+ async activateBackend() {
111
106
  if (backends.size === 0)
112
107
  return;
113
108
  const preferred = settings.defaultBackend;
114
- if (preferred && backends.has(preferred)) {
115
- activateByName(preferred, true);
116
- }
117
- else {
118
- activateByName(backends.keys().next().value, true);
119
- }
109
+ const name = preferred && backends.has(preferred) ? preferred : backends.keys().next().value;
110
+ await activateByName(name);
120
111
  },
121
112
  async query(text) {
122
113
  return new Promise((resolve, reject) => {
@@ -155,6 +146,9 @@ export function createCore(config) {
155
146
  cancel() {
156
147
  bus.emit("agent:cancel-request", {});
157
148
  },
149
+ appendUserMessage(text) {
150
+ bus.emit("agent:append-user-message", { text });
151
+ },
158
152
  extensionContext(opts) {
159
153
  const ctx = {
160
154
  bus,
@@ -47,6 +47,9 @@ export interface ShellEvents {
47
47
  "agent:cancel-request": {
48
48
  silent?: boolean;
49
49
  };
50
+ "agent:append-user-message": {
51
+ text: string;
52
+ };
50
53
  "input-mode:register": import("./types.js").InputModeConfig;
51
54
  "agent:query": {
52
55
  query: string;
@@ -307,7 +310,9 @@ export interface ShellEvents {
307
310
  "config:add-modes": {
308
311
  modes: AgentMode[];
309
312
  };
310
- "core:extensions-loaded": Record<string, never>;
313
+ "core:extensions-loaded": {
314
+ names: string[];
315
+ };
311
316
  "provider:register": {
312
317
  id: string;
313
318
  apiKey?: string;
@@ -445,6 +450,8 @@ export declare class EventBus {
445
450
  * If no listeners are registered, returns the original payload unchanged.
446
451
  */
447
452
  emitPipe<K extends keyof ShellEvents>(event: K, payload: ShellEvents[K]): ShellEvents[K];
453
+ /** Remove an async transform listener from a pipeline event. */
454
+ offPipeAsync<K extends keyof ShellEvents>(event: K, fn: AsyncPipeListener<ShellEvents[K]>): void;
448
455
  /** Register an async transform listener for a pipeline event. */
449
456
  onPipeAsync<K extends keyof ShellEvents>(event: K, fn: AsyncPipeListener<ShellEvents[K]>): void;
450
457
  /**
package/dist/event-bus.js CHANGED
@@ -131,6 +131,15 @@ export class EventBus {
131
131
  }
132
132
  return result;
133
133
  }
134
+ /** Remove an async transform listener from a pipeline event. */
135
+ offPipeAsync(event, fn) {
136
+ const listeners = this.asyncPipeListeners.get(event);
137
+ if (!listeners)
138
+ return;
139
+ const idx = listeners.indexOf(fn);
140
+ if (idx !== -1)
141
+ listeners.splice(idx, 1);
142
+ }
134
143
  /** Register an async transform listener for a pipeline event. */
135
144
  onPipeAsync(event, fn) {
136
145
  let listeners = this.asyncPipeListeners.get(event);
@@ -2,6 +2,7 @@ import { AgentLoop } from "../agent/agent-loop.js";
2
2
  import { LlmClient } from "../utils/llm-client.js";
3
3
  import { resolveProvider, getProviderNames, getSettings } from "../settings.js";
4
4
  import { PACKAGE_VERSION } from "../utils/package-version.js";
5
+ import { discoverSkills } from "../agent/skills.js";
5
6
  /** Read the user's persisted defaultModel for a provider, if any. */
6
7
  function persistedModelFor(providerName) {
7
8
  if (!providerName)
@@ -88,6 +89,8 @@ export default function agentBackend(ctx) {
88
89
  let modes = [];
89
90
  let initialModeIndex = 0;
90
91
  let resolved = false;
92
+ // Gates late-registration reconcile so its config:switch-model emit doesn't misroute under a non-ash backend.
93
+ let ashActive = false;
91
94
  bus.onPipe("config:get-initial-modes", () => ({ modes, initialModeIndex }));
92
95
  // AgentLoop must be constructed *before* user extensions activate,
93
96
  // because its ctor defines handlers (history:append, etc.) that
@@ -104,7 +107,9 @@ export default function agentBackend(ctx) {
104
107
  instanceId: ctx.instanceId,
105
108
  history: config.history,
106
109
  });
107
- bus.on("core:extensions-loaded", () => {
110
+ let loadedExtensionNames = [];
111
+ bus.on("core:extensions-loaded", ({ names }) => {
112
+ loadedExtensionNames = names;
108
113
  const settings = getSettings();
109
114
  // If the user didn't pick a default, fall back to the first registered
110
115
  // provider (built-in load order biases to openrouter → openai).
@@ -149,9 +154,37 @@ export default function agentBackend(ctx) {
149
154
  resolved = true;
150
155
  bus.emit("agent:register-backend", {
151
156
  name: "ash",
152
- kill: () => agentLoop.kill(),
157
+ kill: () => {
158
+ ashActive = false;
159
+ bus.emit("command:unregister", { name: "/compact" });
160
+ bus.emit("command:unregister", { name: "/context" });
161
+ agentLoop.kill();
162
+ },
153
163
  start: async () => {
154
164
  agentLoop.wire();
165
+ ashActive = true;
166
+ bus.emit("command:register", {
167
+ name: "/compact",
168
+ description: "Compact conversation via the active compaction strategy",
169
+ handler: () => bus.emit("agent:compact-request", {}),
170
+ });
171
+ bus.emit("command:register", {
172
+ name: "/context",
173
+ description: "Show context budget usage",
174
+ handler: () => {
175
+ const stats = bus.emitPipe("context:get-stats", {
176
+ activeTokens: 0,
177
+ totalTokens: 0,
178
+ budgetTokens: 0,
179
+ });
180
+ const pct = stats.budgetTokens > 0
181
+ ? Math.round((stats.activeTokens / stats.budgetTokens) * 100)
182
+ : 0;
183
+ bus.emit("ui:info", {
184
+ message: `Active context: ~${stats.activeTokens.toLocaleString()} tokens / ${stats.budgetTokens.toLocaleString()} budget (${pct}%)`,
185
+ });
186
+ },
187
+ });
155
188
  bus.emit("agent:info", {
156
189
  name: "ash",
157
190
  version: PACKAGE_VERSION,
@@ -215,7 +248,7 @@ export default function agentBackend(ctx) {
215
248
  // Late-registration reconcile: if this completes the user's persisted
216
249
  // default (openrouter's async fetch delivers the full catalog after
217
250
  // we've already fallen back to mode 0), quietly switch to it.
218
- if (!resolved)
251
+ if (!resolved || !ashActive)
219
252
  return;
220
253
  const pendingProvider = getSettings().defaultProvider;
221
254
  if (pendingProvider !== p.id)
@@ -259,4 +292,17 @@ export default function agentBackend(ctx) {
259
292
  bus.emit("ui:info", { message: `Switched to ${name} (${switchModel})` });
260
293
  bus.emit("config:changed", {});
261
294
  });
295
+ bus.onPipe("banner:collect", (e) => {
296
+ const settings = getSettings();
297
+ if (settings.defaultBackend && settings.defaultBackend !== "ash")
298
+ return e;
299
+ if (loadedExtensionNames.length > 0) {
300
+ e.sections.push({ label: "Extensions", items: [...loadedExtensionNames] });
301
+ }
302
+ const skills = discoverSkills(ctx.call("cwd") ?? process.cwd());
303
+ if (skills.length > 0) {
304
+ e.sections.push({ label: "Skills", items: skills.map((s) => s.name) });
305
+ }
306
+ return e;
307
+ });
262
308
  }
@@ -76,30 +76,6 @@ export default function activate(ctx) {
76
76
  }
77
77
  },
78
78
  });
79
- register({
80
- name: "/compact",
81
- description: "Compact conversation via the active compaction strategy",
82
- handler: () => {
83
- bus.emit("agent:compact-request", {});
84
- },
85
- });
86
- register({
87
- name: "/context",
88
- description: "Show context budget usage",
89
- handler: () => {
90
- const stats = bus.emitPipe("context:get-stats", {
91
- activeTokens: 0,
92
- totalTokens: 0,
93
- budgetTokens: 0,
94
- });
95
- const pct = stats.budgetTokens > 0
96
- ? Math.round((stats.activeTokens / stats.budgetTokens) * 100)
97
- : 0;
98
- bus.emit("ui:info", {
99
- message: `Active context: ~${stats.activeTokens.toLocaleString()} tokens / ${stats.budgetTokens.toLocaleString()} budget (${pct}%)`,
100
- });
101
- },
102
- });
103
79
  register({
104
80
  name: "/reload",
105
81
  description: "Reload user extensions from ~/.agent-sh/extensions/",
package/dist/index.js CHANGED
@@ -7,7 +7,6 @@ import { palette as p } from "./utils/palette.js";
7
7
  import { loadBuiltinExtensions } from "./extensions/index.js";
8
8
  import { loadExtensions } from "./extension-loader.js";
9
9
  import { getSettings } from "./settings.js";
10
- import { discoverSkills } from "./agent/skills.js";
11
10
  import { runInit } from "./init.js";
12
11
  import { PACKAGE_VERSION } from "./utils/package-version.js";
13
12
  /**
@@ -270,11 +269,8 @@ async function main() {
270
269
  if (process.env.DEBUG) {
271
270
  console.error('[agent-sh] Extensions loaded');
272
271
  }
273
- // Tell deferred-init listeners (agent-backend) that the provider
274
- // registry is now complete.
275
- core.bus.emit("core:extensions-loaded", {});
276
- // ── Discover skills ───────────────────────────────────────────
277
- const skills = discoverSkills(process.cwd());
272
+ // Names ride along so backend extensions can build banner sections.
273
+ core.bus.emit("core:extensions-loaded", { names: loadedExtensions });
278
274
  // ── Activate agent backend ────────────────────────────────────
279
275
  // Extensions had their chance to register via agent:register-backend.
280
276
  // If none did, the built-in AgentLoop gets wired to bus events.
@@ -288,6 +284,7 @@ async function main() {
288
284
  " Alternatively, install a bridge extension (claude-code-bridge, pi-bridge).\n");
289
285
  process.exit(1);
290
286
  }
287
+ // No await: banner must out-race the shell's PS1 arriving via PTY.
291
288
  core.activateBackend();
292
289
  // ── Startup banner ───────────────────────────────────────────
293
290
  const settings = getSettings();
@@ -295,31 +292,11 @@ async function main() {
295
292
  const termW = process.stdout.columns || 80;
296
293
  const bannerW = Math.min(termW, 60);
297
294
  const productName = `${p.accent}${p.bold}agent-sh${p.reset}`;
298
- const info = agentInfo;
299
- const backendReady = !!info?.model;
300
- const backendName = info?.name ?? "ash";
301
- const model = info?.model;
302
- const provider = info?.provider;
303
- const modelValue = model
304
- ? provider ? `${model} [${provider}]` : model
305
- : null;
295
+ const backendName = settings.defaultBackend && backendNames.includes(settings.defaultBackend)
296
+ ? settings.defaultBackend
297
+ : backendNames[0];
306
298
  let sections = "";
307
- sections += `\n\n ${p.muted}Backend:${p.reset} ${p.dim}${backendName}${backendReady ? "" : " (not configured)"}${p.reset}`;
308
- if (modelValue) {
309
- sections += `\n ${p.muted}Model:${p.reset} ${p.dim}${modelValue}${p.reset}`;
310
- }
311
- if (loadedExtensions.length > 0) {
312
- sections += `\n\n ${p.muted}Extensions:${p.reset}`;
313
- for (const name of loadedExtensions) {
314
- sections += `\n ${p.dim}${name}${p.reset}`;
315
- }
316
- }
317
- if (skills.length > 0) {
318
- sections += `\n\n ${p.muted}Skills:${p.reset}`;
319
- for (const s of skills) {
320
- sections += `\n ${p.dim}${s.name}${p.reset}`;
321
- }
322
- }
299
+ sections += `\n\n ${p.muted}Backend:${p.reset} ${p.dim}${backendName}${p.reset}`;
323
300
  const extSections = bus.emitPipe("banner:collect", { sections: [] }).sections;
324
301
  for (const sec of extSections) {
325
302
  sections += `\n\n ${p.muted}${sec.label}:${p.reset}`;
@@ -327,9 +304,7 @@ async function main() {
327
304
  sections += `\n ${p.dim}${item}${p.reset}`;
328
305
  }
329
306
  }
330
- const hint = backendReady
331
- ? `${p.muted}Type ${p.warning}>${p.muted} to ask AI · ${p.warning}>/help${p.muted} for commands${p.reset}`
332
- : `${p.muted}Set ${p.warning}OPENROUTER_API_KEY${p.muted} or ${p.warning}OPENAI_API_KEY${p.muted} and restart to enable AI${p.reset}`;
307
+ const hint = `${p.muted}Type ${p.warning}>${p.muted} to ask AI · ${p.warning}>/help${p.muted} for commands${p.reset}`;
333
308
  const borderLine = `${p.muted}${"─".repeat(bannerW)}${p.reset}`;
334
309
  process.stdout.write("\n" + borderLine + "\n" +
335
310
  " " + productName +
@@ -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.22",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",