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.
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,
@@ -210,9 +204,11 @@ export function createCore(config) {
210
204
  cleanups.push(compositor.redirect("agent", surface));
211
205
  cleanups.push(compositor.redirect("query", surface));
212
206
  cleanups.push(compositor.redirect("status", surface));
213
- // Keep shell interactive
207
+ // Suppress the host shell's mute lifecycle and post-turn
208
+ // redraw nudge. on-processing-done is intentionally not advised
209
+ // — its scope cleanup must always run.
214
210
  cleanups.push(handlers.advise("shell:on-processing-start", (next) => active ? undefined : next()));
215
- cleanups.push(handlers.advise("shell:on-processing-done", (next) => active ? undefined : next()));
211
+ cleanups.push(handlers.advise("shell:on-processing-redraw", (next) => active ? undefined : next()));
216
212
  // Suppress chrome
217
213
  if (opts.suppressBorders !== false) {
218
214
  cleanups.push(handlers.advise("tui:response-border", (next, ...a) => active ? null : next(...a)));
@@ -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;
@@ -211,6 +214,7 @@ export interface ShellEvents {
211
214
  };
212
215
  "shell:redraw-prompt": {
213
216
  cwd: string;
217
+ kind: "fresh" | "redraw";
214
218
  handled: boolean;
215
219
  };
216
220
  "shell:exec-request": {
@@ -307,7 +311,9 @@ export interface ShellEvents {
307
311
  "config:add-modes": {
308
312
  modes: AgentMode[];
309
313
  };
310
- "core:extensions-loaded": Record<string, never>;
314
+ "core:extensions-loaded": {
315
+ names: string[];
316
+ };
311
317
  "provider:register": {
312
318
  id: string;
313
319
  apiKey?: string;
@@ -445,6 +451,8 @@ export declare class EventBus {
445
451
  * If no listeners are registered, returns the original payload unchanged.
446
452
  */
447
453
  emitPipe<K extends keyof ShellEvents>(event: K, payload: ShellEvents[K]): ShellEvents[K];
454
+ /** Remove an async transform listener from a pipeline event. */
455
+ offPipeAsync<K extends keyof ShellEvents>(event: K, fn: AsyncPipeListener<ShellEvents[K]>): void;
448
456
  /** Register an async transform listener for a pipeline event. */
449
457
  onPipeAsync<K extends keyof ShellEvents>(event: K, fn: AsyncPipeListener<ShellEvents[K]>): void;
450
458
  /**
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);
package/dist/executor.js CHANGED
@@ -1,5 +1,13 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
2
3
  import { stripAnsi } from "./utils/ansi.js";
4
+ // Node reports a missing cwd as `spawn <binary> ENOENT` — disambiguate.
5
+ function explainSpawnError(err, cwd) {
6
+ if (err.code === "ENOENT" && !existsSync(cwd)) {
7
+ return `cwd no longer exists: ${cwd} (${err.message})`;
8
+ }
9
+ return err.message;
10
+ }
3
11
  let cachedBashPath;
4
12
  /** Resolve a usable bash binary, or null if none is on PATH.
5
13
  * Unix: `/bin/bash` (canonical, present on every Linux/macOS install).
@@ -60,7 +68,10 @@ export function executeCommand(opts) {
60
68
  catch (err) {
61
69
  session.exitCode = -1;
62
70
  session.spawnFailed = true;
63
- session.output = `Failed to spawn: ${err instanceof Error ? err.message : String(err)}`;
71
+ const msg = err instanceof Error
72
+ ? explainSpawnError(err, opts.cwd)
73
+ : String(err);
74
+ session.output = `Failed to spawn: ${msg}`;
64
75
  session.done = true;
65
76
  session.resolve?.();
66
77
  return { session, done };
@@ -103,7 +114,7 @@ export function executeCommand(opts) {
103
114
  const code = err.code;
104
115
  if (code === "ENOENT" || code === "EACCES")
105
116
  session.spawnFailed = true;
106
- session.output += `\nProcess error: ${err.message}`;
117
+ session.output += `\nProcess error: ${explainSpawnError(err, opts.cwd)}`;
107
118
  session.done = true;
108
119
  session.process = null;
109
120
  session.resolve?.();
@@ -149,7 +160,10 @@ export function executeArgv(opts) {
149
160
  catch (err) {
150
161
  session.exitCode = -1;
151
162
  session.spawnFailed = true;
152
- session.output = `Failed to spawn ${opts.file}: ${err instanceof Error ? err.message : String(err)}`;
163
+ const msg = err instanceof Error
164
+ ? explainSpawnError(err, opts.cwd)
165
+ : String(err);
166
+ session.output = `Failed to spawn ${opts.file}: ${msg}`;
153
167
  session.done = true;
154
168
  session.resolve?.();
155
169
  return { session, done };
@@ -197,7 +211,7 @@ export function executeArgv(opts) {
197
211
  const code = err.code;
198
212
  if (code === "ENOENT" || code === "EACCES")
199
213
  session.spawnFailed = true;
200
- session.output += `\nProcess error: ${err.message}`;
214
+ session.output += `\nProcess error: ${explainSpawnError(err, opts.cwd)}`;
201
215
  session.done = true;
202
216
  session.process = null;
203
217
  session.resolve?.();
@@ -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 +