agent-sh 0.12.22 → 0.12.24

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 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.
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 `agent-sh install pi-bridge` followed by `agent-sh --backend pi`.
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 — [pi](examples/extensions/pi-bridge/) runs as a drop-in backend extension.
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 — `agent-sh install pi-bridge && agent-sh --backend pi` runs [pi](examples/extensions/pi-bridge/) as a drop-in backend.
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
 
package/dist/core.d.ts CHANGED
@@ -37,7 +37,7 @@ 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(): Promise<void>;
40
+ activateBackend(override?: string): 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. */
package/dist/core.js CHANGED
@@ -102,10 +102,10 @@ export function createCore(config) {
102
102
  bus,
103
103
  handlers,
104
104
  instanceId,
105
- async activateBackend() {
105
+ async activateBackend(override) {
106
106
  if (backends.size === 0)
107
107
  return;
108
- const preferred = settings.defaultBackend;
108
+ const preferred = override ?? settings.defaultBackend;
109
109
  const name = preferred && backends.has(preferred) ? preferred : backends.keys().next().value;
110
110
  await activateByName(name);
111
111
  },
@@ -204,9 +204,11 @@ export function createCore(config) {
204
204
  cleanups.push(compositor.redirect("agent", surface));
205
205
  cleanups.push(compositor.redirect("query", surface));
206
206
  cleanups.push(compositor.redirect("status", surface));
207
- // 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.
208
210
  cleanups.push(handlers.advise("shell:on-processing-start", (next) => active ? undefined : next()));
209
- 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()));
210
212
  // Suppress chrome
211
213
  if (opts.suppressBorders !== false) {
212
214
  cleanups.push(handlers.advise("tui:response-border", (next, ...a) => active ? null : next(...a)));
@@ -214,6 +214,7 @@ export interface ShellEvents {
214
214
  };
215
215
  "shell:redraw-prompt": {
216
216
  cwd: string;
217
+ kind: "fresh" | "redraw";
217
218
  handled: boolean;
218
219
  };
219
220
  "shell:exec-request": {
@@ -366,6 +367,8 @@ export interface ShellEvents {
366
367
  label: string;
367
368
  items: string[];
368
369
  }>;
370
+ /** Name of the backend being launched. Extensions should gate per-backend sections on this rather than settings.defaultBackend. */
371
+ activeBackend?: string;
369
372
  };
370
373
  "autocomplete:request": {
371
374
  buffer: string;
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?.();
@@ -293,8 +293,7 @@ export default function agentBackend(ctx) {
293
293
  bus.emit("config:changed", {});
294
294
  });
295
295
  bus.onPipe("banner:collect", (e) => {
296
- const settings = getSettings();
297
- if (settings.defaultBackend && settings.defaultBackend !== "ash")
296
+ if (e.activeBackend && e.activeBackend !== "ash")
298
297
  return e;
299
298
  if (loadedExtensionNames.length > 0) {
300
299
  e.sections.push({ label: "Extensions", items: [...loadedExtensionNames] });
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { loadBuiltinExtensions } from "./extensions/index.js";
8
8
  import { loadExtensions } from "./extension-loader.js";
9
9
  import { getSettings } from "./settings.js";
10
10
  import { runInit } from "./init.js";
11
+ import { runInstall, runUninstall, runList, suggestBridgeFor } from "./install.js";
11
12
  import { PACKAGE_VERSION } from "./utils/package-version.js";
12
13
  /**
13
14
  * Capture the user's full shell environment.
@@ -78,7 +79,8 @@ function parseArgs(argv) {
78
79
  let model;
79
80
  let extensions;
80
81
  let provider;
81
- const shell = process.env.SHELL || "/bin/bash";
82
+ let backend;
83
+ let shell = process.env.SHELL || "/bin/bash";
82
84
  let apiKey = process.env.OPENAI_API_KEY;
83
85
  let baseURL = process.env.OPENAI_BASE_URL;
84
86
  for (let i = 0; i < argv.length; i++) {
@@ -95,8 +97,11 @@ function parseArgs(argv) {
95
97
  else if (arg === "--provider" && argv[i + 1]) {
96
98
  provider = argv[++i];
97
99
  }
100
+ else if (arg === "--backend" && argv[i + 1]) {
101
+ backend = argv[++i];
102
+ }
98
103
  else if (arg === "--shell" && argv[i + 1]) {
99
- return { shell: argv[++i], model, extensions, apiKey, baseURL, provider };
104
+ shell = argv[++i];
100
105
  }
101
106
  else if ((arg === "--extensions" || arg === "-e") && argv[i + 1]) {
102
107
  const exts = argv[++i].split(",").map(s => s.trim());
@@ -110,7 +115,10 @@ function parseArgs(argv) {
110
115
  console.log(`agent-sh — a shell-first terminal where AI is one keystroke away
111
116
 
112
117
  Usage: agent-sh [options]
113
- agent-sh init [--force] Scaffold ~/.agent-sh/ (settings, examples, AGENTS.md)
118
+ agent-sh init [--force] Scaffold ~/.agent-sh/ (settings, examples, AGENTS.md)
119
+ agent-sh install <spec> [--force] Install an extension (bundled name, file:, npm:, github:)
120
+ agent-sh uninstall <name> Remove an installed extension
121
+ agent-sh list List installed extensions
114
122
 
115
123
  Provider Profiles:
116
124
  --provider <name> Use a provider from ~/.agent-sh/settings.json
@@ -121,6 +129,7 @@ Direct LLM API:
121
129
  --base-url <url> Base URL for API (or set OPENAI_BASE_URL)
122
130
 
123
131
  General Options:
132
+ --backend <name> Agent backend to launch (e.g. ash, pi); overrides settings.defaultBackend for this session
124
133
  --shell <path> Shell to use (default: $SHELL or /bin/bash)
125
134
  -e, --extensions Extensions to load (comma-separated, repeatable)
126
135
  -h, --help Show this help
@@ -149,7 +158,7 @@ Inside the shell:
149
158
  process.exit(0);
150
159
  }
151
160
  }
152
- return { shell, model, extensions, apiKey, baseURL, provider };
161
+ return { shell, model, extensions, apiKey, baseURL, provider, backend };
153
162
  }
154
163
  async function main() {
155
164
  // Subcommands — handled before the shell-launch path.
@@ -158,6 +167,18 @@ async function main() {
158
167
  runInit({ force: rawArgs.includes("--force") });
159
168
  return;
160
169
  }
170
+ if (rawArgs[0] === "install") {
171
+ await runInstall(rawArgs[1] ?? "", { force: rawArgs.includes("--force") });
172
+ return;
173
+ }
174
+ if (rawArgs[0] === "uninstall") {
175
+ await runUninstall(rawArgs[1] ?? "");
176
+ return;
177
+ }
178
+ if (rawArgs[0] === "list") {
179
+ runList();
180
+ return;
181
+ }
161
182
  if (process.env.AGENT_SH) {
162
183
  console.error("agent-sh: already running inside an agent-sh session (nested sessions are not supported).");
163
184
  process.exit(1);
@@ -280,24 +301,37 @@ async function main() {
280
301
  console.error("\nagent-sh: no agent backend available.\n\n" +
281
302
  " Export OPENROUTER_API_KEY or OPENAI_API_KEY for zero-config launch, or\n" +
282
303
  " pass --api-key on the command line, or\n" +
283
- " run `agent-sh init` for a settings.json template.\n" +
284
- " Alternatively, install a bridge extension (claude-code-bridge, pi-bridge).\n");
304
+ " run `agent-sh init` for a settings.json template, or\n" +
305
+ " run `agent-sh install <bridge>` (e.g. pi-bridge, claude-code-bridge) to use a non-ash backend.\n");
306
+ process.exit(1);
307
+ }
308
+ if (config.backend && !backendNames.includes(config.backend)) {
309
+ shell?.kill();
310
+ const bridge = suggestBridgeFor(config.backend);
311
+ const hint = bridge
312
+ ? ` Try: agent-sh install ${bridge}\n`
313
+ : ` Run \`agent-sh install\` to see bundled bridge extensions.\n`;
314
+ console.error(`\nagent-sh: backend "${config.backend}" is not available.\n\n` +
315
+ ` Available backends: ${backendNames.join(", ")}\n` +
316
+ hint);
285
317
  process.exit(1);
286
318
  }
287
319
  // No await: banner must out-race the shell's PS1 arriving via PTY.
288
- core.activateBackend();
320
+ core.activateBackend(config.backend);
289
321
  // ── Startup banner ───────────────────────────────────────────
290
322
  const settings = getSettings();
291
323
  if (settings.startupBanner !== false) {
292
324
  const termW = process.stdout.columns || 80;
293
325
  const bannerW = Math.min(termW, 60);
294
326
  const productName = `${p.accent}${p.bold}agent-sh${p.reset}`;
295
- const backendName = settings.defaultBackend && backendNames.includes(settings.defaultBackend)
296
- ? settings.defaultBackend
297
- : backendNames[0];
327
+ const backendName = config.backend && backendNames.includes(config.backend)
328
+ ? config.backend
329
+ : settings.defaultBackend && backendNames.includes(settings.defaultBackend)
330
+ ? settings.defaultBackend
331
+ : backendNames[0];
298
332
  let sections = "";
299
333
  sections += `\n\n ${p.muted}Backend:${p.reset} ${p.dim}${backendName}${p.reset}`;
300
- const extSections = bus.emitPipe("banner:collect", { sections: [] }).sections;
334
+ const extSections = bus.emitPipe("banner:collect", { sections: [], activeBackend: backendName }).sections;
301
335
  for (const sec of extSections) {
302
336
  sections += `\n\n ${p.muted}${sec.label}:${p.reset}`;
303
337
  for (const item of sec.items) {
@@ -0,0 +1,10 @@
1
+ interface InstallOpts {
2
+ force?: boolean;
3
+ }
4
+ export declare function listBundled(): string[];
5
+ /** Heuristic: a backend named "pi" is typically provided by an extension called "pi-bridge". */
6
+ export declare function suggestBridgeFor(backend: string): string | null;
7
+ export declare function runInstall(spec: string, opts?: InstallOpts): Promise<void>;
8
+ export declare function runUninstall(name: string): Promise<void>;
9
+ export declare function runList(): void;
10
+ export {};
@@ -0,0 +1,205 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { spawnSync } from "node:child_process";
5
+ import { CONFIG_DIR, getSettings } from "./settings.js";
6
+ // Kept in sync with extension-loader.ts SCRIPT_EXTS.
7
+ const SCRIPT_EXTS = [".js", ".mjs", ".ts", ".tsx", ".mts"];
8
+ function hasIndexFile(dir) {
9
+ return SCRIPT_EXTS.some((ext) => fs.existsSync(path.join(dir, `index${ext}`)));
10
+ }
11
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../");
12
+ const BUNDLED_DIR = path.join(PACKAGE_ROOT, "examples/extensions");
13
+ const EXT_DIR = path.join(CONFIG_DIR, "extensions");
14
+ export function listBundled() {
15
+ if (!fs.existsSync(BUNDLED_DIR))
16
+ return [];
17
+ return fs.readdirSync(BUNDLED_DIR).map((n) => n.replace(/\.(ts|js|mjs)$/, ""));
18
+ }
19
+ /** Heuristic: a backend named "pi" is typically provided by an extension called "pi-bridge". */
20
+ export function suggestBridgeFor(backend) {
21
+ const candidate = `${backend}-bridge`;
22
+ return listBundled().includes(candidate) ? candidate : null;
23
+ }
24
+ const bundledResolver = {
25
+ resolve: async (spec) => {
26
+ const candidates = [
27
+ { p: path.join(BUNDLED_DIR, spec), name: spec },
28
+ { p: path.join(BUNDLED_DIR, `${spec}.ts`), name: `${spec}.ts` },
29
+ { p: path.join(BUNDLED_DIR, `${spec}.js`), name: `${spec}.js` },
30
+ ];
31
+ for (const c of candidates) {
32
+ if (fs.existsSync(c.p)) {
33
+ const isDirectory = fs.statSync(c.p).isDirectory();
34
+ return { sourcePath: c.p, name: c.name, isDirectory };
35
+ }
36
+ }
37
+ const available = listBundled();
38
+ throw new Error(`No bundled extension named "${spec}".\n\n` +
39
+ `Available:\n${available.map((n) => ` ${n}`).join("\n")}`);
40
+ },
41
+ };
42
+ const npmResolver = {
43
+ canHandle: (spec) => spec.startsWith("npm:"),
44
+ resolve: async () => {
45
+ throw new Error("npm: source is not yet implemented");
46
+ },
47
+ };
48
+ const githubResolver = {
49
+ canHandle: (spec) => spec.startsWith("github:") || spec.startsWith("https://github.com/"),
50
+ resolve: async () => {
51
+ throw new Error("github: source is not yet implemented");
52
+ },
53
+ };
54
+ const fileResolver = {
55
+ canHandle: (spec) => spec.startsWith("file:") || spec.startsWith("/") || spec.startsWith("./") || spec.startsWith("../"),
56
+ resolve: async (spec) => {
57
+ const raw = spec.startsWith("file:") ? spec.slice("file:".length) : spec;
58
+ const abs = path.resolve(raw);
59
+ if (!fs.existsSync(abs))
60
+ throw new Error(`Path does not exist: ${abs}`);
61
+ const isDirectory = fs.statSync(abs).isDirectory();
62
+ return { sourcePath: abs, name: path.basename(abs), isDirectory };
63
+ },
64
+ };
65
+ const PREFIX_RESOLVERS = [npmResolver, githubResolver, fileResolver];
66
+ function pickResolver(spec) {
67
+ for (const r of PREFIX_RESOLVERS)
68
+ if (r.canHandle?.(spec))
69
+ return r;
70
+ return bundledResolver;
71
+ }
72
+ function maybeNpmInstall(target) {
73
+ const pkgJson = path.join(target, "package.json");
74
+ if (!fs.existsSync(pkgJson))
75
+ return;
76
+ const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf-8"));
77
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.peerDependencies ?? {}) };
78
+ if (Object.keys(deps).length === 0)
79
+ return;
80
+ if (fs.existsSync(path.join(target, "node_modules")))
81
+ return;
82
+ console.log(`Running npm install in ${target}...`);
83
+ const result = spawnSync("npm", ["install", "--no-audit", "--no-fund"], {
84
+ cwd: target,
85
+ stdio: "inherit",
86
+ });
87
+ if (result.status !== 0) {
88
+ throw new Error(`npm install failed in ${target}; run it manually.`);
89
+ }
90
+ }
91
+ export async function runInstall(spec, opts = {}) {
92
+ if (!spec) {
93
+ console.error("Usage: agent-sh install <name|file:|npm:|github:> [--force]\n\n" +
94
+ "Bundled extensions:\n" +
95
+ listBundled()
96
+ .map((n) => ` ${n}`)
97
+ .join("\n"));
98
+ process.exit(1);
99
+ }
100
+ fs.mkdirSync(EXT_DIR, { recursive: true });
101
+ let resolved;
102
+ try {
103
+ resolved = await pickResolver(spec).resolve(spec);
104
+ }
105
+ catch (err) {
106
+ console.error(`agent-sh: ${err instanceof Error ? err.message : String(err)}`);
107
+ process.exit(1);
108
+ }
109
+ const target = path.join(EXT_DIR, resolved.name);
110
+ if (fs.lstatSync(target, { throwIfNoEntry: false })) {
111
+ if (!opts.force) {
112
+ console.error(`agent-sh: ${target} already exists (pass --force to overwrite)`);
113
+ process.exit(1);
114
+ }
115
+ fs.rmSync(target, { recursive: true, force: true });
116
+ }
117
+ if (resolved.isDirectory) {
118
+ fs.cpSync(resolved.sourcePath, target, { recursive: true });
119
+ try {
120
+ maybeNpmInstall(target);
121
+ }
122
+ catch (err) {
123
+ console.error(`agent-sh: ${err instanceof Error ? err.message : String(err)}`);
124
+ process.exit(1);
125
+ }
126
+ }
127
+ else {
128
+ fs.copyFileSync(resolved.sourcePath, target);
129
+ }
130
+ console.log(`Installed: ${resolved.name} -> ${target}`);
131
+ }
132
+ export async function runUninstall(name) {
133
+ if (!name) {
134
+ console.error("Usage: agent-sh uninstall <name>");
135
+ process.exit(1);
136
+ }
137
+ const target = path.join(EXT_DIR, name);
138
+ // Refuse path-traversal: target must sit directly under EXT_DIR.
139
+ const resolvedTarget = path.resolve(target);
140
+ const resolvedExtDir = path.resolve(EXT_DIR);
141
+ if (!resolvedTarget.startsWith(resolvedExtDir + path.sep)) {
142
+ console.error(`agent-sh: refusing to uninstall outside ${EXT_DIR}`);
143
+ process.exit(1);
144
+ }
145
+ if (!fs.lstatSync(target, { throwIfNoEntry: false })) {
146
+ console.error(`agent-sh: not installed: ${name}`);
147
+ process.exit(1);
148
+ }
149
+ fs.rmSync(target, { recursive: true, force: true });
150
+ console.log(`Uninstalled: ${name}`);
151
+ }
152
+ function listFromExtDir(disabled) {
153
+ if (!fs.existsSync(EXT_DIR))
154
+ return [];
155
+ const dirents = fs.readdirSync(EXT_DIR, { withFileTypes: true });
156
+ const out = [];
157
+ for (const d of dirents) {
158
+ if (d.name.startsWith("."))
159
+ continue;
160
+ const nameForDisable = d.name.replace(/\.[^.]+$/, "");
161
+ if (disabled.has(nameForDisable))
162
+ continue;
163
+ const full = path.join(EXT_DIR, d.name);
164
+ let isDir = d.isDirectory();
165
+ if (d.isSymbolicLink()) {
166
+ try {
167
+ isDir = fs.statSync(full).isDirectory();
168
+ }
169
+ catch {
170
+ continue;
171
+ }
172
+ }
173
+ if (isDir) {
174
+ if (!hasIndexFile(full))
175
+ continue;
176
+ }
177
+ else if (!SCRIPT_EXTS.some((ext) => d.name.endsWith(ext))) {
178
+ continue;
179
+ }
180
+ const detail = d.isSymbolicLink() ? `-> ${fs.readlinkSync(full)}` : undefined;
181
+ out.push({ name: d.name, source: "extensions dir", detail });
182
+ }
183
+ return out;
184
+ }
185
+ function listFromSettings(disabled) {
186
+ const specs = getSettings().extensions ?? [];
187
+ return specs
188
+ .filter((s) => !disabled.has(s.replace(/\.[^.]+$/, "")))
189
+ .map((s) => ({ name: s, source: "settings.json" }));
190
+ }
191
+ export function runList() {
192
+ const disabled = new Set(getSettings().disabledExtensions ?? []);
193
+ const items = [...listFromExtDir(disabled), ...listFromSettings(disabled)];
194
+ if (items.length === 0) {
195
+ console.log("No extensions installed.");
196
+ return;
197
+ }
198
+ const nameWidth = Math.max(...items.map((i) => i.name.length));
199
+ console.log("Installed extensions:");
200
+ for (const item of items) {
201
+ const padded = item.name.padEnd(nameWidth);
202
+ const detail = item.detail ? ` ${item.detail}` : "";
203
+ console.log(` ${padded} (${item.source})${detail}`);
204
+ }
205
+ }
@@ -4,16 +4,25 @@ export interface ShellHandlers {
4
4
  define: (name: string, fn: (...args: any[]) => any) => void;
5
5
  call: (name: string, ...args: any[]) => any;
6
6
  }
7
+ /**
8
+ * A claim on the shell's stdout-mute state. Acquire from shell.acquire*,
9
+ * pair with release() in a try/finally. Token-shape forces symmetry —
10
+ * the only way to influence the gate is to hold and release a scope.
11
+ */
12
+ export interface ShellScope {
13
+ readonly reason: string;
14
+ release(): void;
15
+ }
7
16
  export declare class Shell implements InputContext {
8
17
  private ptyProcess;
9
18
  private bus;
10
19
  private handlers;
11
20
  private inputHandler;
12
21
  private outputParser;
13
- private paused;
14
- private stdoutHold;
15
- private stdoutShow;
16
- private echoSkip;
22
+ private hardMuteScopes;
23
+ private softMuteScopes;
24
+ private unmuteScopes;
25
+ private pendingEchoSkips;
17
26
  private agentActive;
18
27
  private isZsh;
19
28
  private tmpDir?;
@@ -30,6 +39,15 @@ export declare class Shell implements InputContext {
30
39
  cwd: string;
31
40
  instanceId: string;
32
41
  });
42
+ /** Compositing-layer claim — overrides any unmute. */
43
+ acquireHardMute(reason: string): ShellScope;
44
+ /** Agent-turn / exec-style mute — overridable by unmute. */
45
+ acquireMute(reason: string): ShellScope;
46
+ /** Force visible while held; overrides soft mutes only. */
47
+ acquireUnmute(reason: string): ShellScope;
48
+ /** Swallow the next \n-terminated chunk from PTY (one per call). */
49
+ skipNextLine(): void;
50
+ private isHostMuted;
33
51
  isForegroundBusy(): boolean;
34
52
  getCwd(): string;
35
53
  isAgentActive(): boolean;
@@ -52,9 +70,10 @@ export declare class Shell implements InputContext {
52
70
  private setupOutput;
53
71
  private setupInput;
54
72
  /**
55
- * React to agent lifecycle events — Shell manages its own state
56
- * rather than being driven by AcpClient. This means AcpClient has
57
- * zero frontend knowledge; any frontend can subscribe to the same events.
73
+ * shell:on-processing-done splits into unconditional state cleanup
74
+ * (release agent-turn scope) and an advisable redraw (freshPrompt).
75
+ * RemoteSession suppresses the redraw, never the cleanup, so soft-mute
76
+ * can't leak past the end of a turn even when overlays are involved.
58
77
  */
59
78
  private setupAgentLifecycle;
60
79
  /** Temp directory used for shell config and sockets. */
@@ -5,17 +5,19 @@ import * as pty from "node-pty";
5
5
  import { InputHandler } from "./input-handler.js";
6
6
  import { OutputParser } from "./output-parser.js";
7
7
  import { getSettings } from "../settings.js";
8
- import { RefCounter } from "../utils/ref-counter.js";
9
8
  export class Shell {
10
9
  ptyProcess;
11
10
  bus;
12
11
  handlers;
13
12
  inputHandler;
14
13
  outputParser;
15
- paused = false;
16
- stdoutHold = new RefCounter();
17
- stdoutShow = new RefCounter();
18
- echoSkip = false;
14
+ // hardMute is unconditional (overlay compositing); softMute is overridable
15
+ // by unmute (terminal_keys, permission UI). Gate: hard wins; otherwise
16
+ // muted iff softMute held without an unmute.
17
+ hardMuteScopes = new Set();
18
+ softMuteScopes = new Set();
19
+ unmuteScopes = new Set();
20
+ pendingEchoSkips = 0;
19
21
  agentActive = false;
20
22
  isZsh = false;
21
23
  tmpDir;
@@ -186,12 +188,75 @@ export class Shell {
186
188
  this.bus.on("shell:pty-resize", ({ cols, rows }) => {
187
189
  this.ptyProcess.resize(cols, rows);
188
190
  });
189
- // Ref-counted stdout hold overlay extensions suppress PTY output
190
- this.bus.on("shell:stdout-hold", () => { this.stdoutHold.increment(); });
191
- this.bus.on("shell:stdout-release", () => { this.stdoutHold.decrement(); });
192
- // Ref-counted stdout show — tools temporarily force output visible during agent processing
193
- this.bus.on("shell:stdout-show", () => { this.stdoutShow.increment(); });
194
- this.bus.on("shell:stdout-hide", () => { this.stdoutShow.decrement(); });
191
+ // Compat shims for the bus-event API. shell:stdout-hold maps to hard
192
+ // mute so terminal_keys' stdout-show can't paint through the overlay.
193
+ let holdRefcount = 0;
194
+ let holdScope = null;
195
+ this.bus.on("shell:stdout-hold", () => {
196
+ if (holdRefcount === 0)
197
+ holdScope = this.acquireHardMute("bus:stdout-hold");
198
+ holdRefcount++;
199
+ });
200
+ this.bus.on("shell:stdout-release", () => {
201
+ if (holdRefcount === 0)
202
+ return;
203
+ holdRefcount--;
204
+ if (holdRefcount === 0) {
205
+ holdScope?.release();
206
+ holdScope = null;
207
+ }
208
+ });
209
+ let showRefcount = 0;
210
+ let showScope = null;
211
+ this.bus.on("shell:stdout-show", () => {
212
+ if (showRefcount === 0)
213
+ showScope = this.acquireUnmute("bus:stdout-show");
214
+ showRefcount++;
215
+ });
216
+ this.bus.on("shell:stdout-hide", () => {
217
+ if (showRefcount === 0)
218
+ return;
219
+ showRefcount--;
220
+ if (showRefcount === 0) {
221
+ showScope?.release();
222
+ showScope = null;
223
+ }
224
+ });
225
+ }
226
+ // ── Scope-based gating ─────────────────────────────────────
227
+ /** Compositing-layer claim — overrides any unmute. */
228
+ acquireHardMute(reason) {
229
+ const scope = {
230
+ reason,
231
+ release: () => { this.hardMuteScopes.delete(scope); },
232
+ };
233
+ this.hardMuteScopes.add(scope);
234
+ return scope;
235
+ }
236
+ /** Agent-turn / exec-style mute — overridable by unmute. */
237
+ acquireMute(reason) {
238
+ const scope = {
239
+ reason,
240
+ release: () => { this.softMuteScopes.delete(scope); },
241
+ };
242
+ this.softMuteScopes.add(scope);
243
+ return scope;
244
+ }
245
+ /** Force visible while held; overrides soft mutes only. */
246
+ acquireUnmute(reason) {
247
+ const scope = {
248
+ reason,
249
+ release: () => { this.unmuteScopes.delete(scope); },
250
+ };
251
+ this.unmuteScopes.add(scope);
252
+ return scope;
253
+ }
254
+ /** Swallow the next \n-terminated chunk from PTY (one per call). */
255
+ skipNextLine() { this.pendingEchoSkips++; }
256
+ isHostMuted() {
257
+ if (this.hardMuteScopes.size > 0)
258
+ return true;
259
+ return this.softMuteScopes.size > 0 && this.unmuteScopes.size === 0;
195
260
  }
196
261
  // ── InputContext implementation (delegates to OutputParser) ──
197
262
  isForegroundBusy() {
@@ -211,12 +276,9 @@ export class Shell {
211
276
  * zsh (ZLE widget) and bash (readline redraw-current-line) bind to repaint.
212
277
  */
213
278
  redrawPrompt() {
214
- // Stale echoSkip/paused from handleProcessingDone re-entering a mode
215
- // would swallow the redraw and freeze the terminal visually.
216
- this.echoSkip = false;
217
- this.paused = false;
218
279
  const result = this.bus.emitPipe("shell:redraw-prompt", {
219
280
  cwd: this.outputParser.getCwd(),
281
+ kind: "redraw",
220
282
  handled: false,
221
283
  });
222
284
  if (!result.handled) {
@@ -234,6 +296,7 @@ export class Shell {
234
296
  freshPrompt() {
235
297
  const result = this.bus.emitPipe("shell:redraw-prompt", {
236
298
  cwd: this.outputParser.getCwd(),
299
+ kind: "fresh",
237
300
  handled: false,
238
301
  });
239
302
  if (!result.handled) {
@@ -250,16 +313,13 @@ export class Shell {
250
313
  this.ptyProcess.onData((data) => {
251
314
  this.bus.emit("shell:pty-data", { raw: data });
252
315
  this.outputParser.processData(data);
253
- if (this.stdoutHold.active)
254
- return;
255
- if (this.paused && !this.stdoutShow.active)
316
+ if (this.isHostMuted())
256
317
  return;
257
- // During user_shell exec, skip the command echo (first line)
258
- if (this.echoSkip) {
318
+ if (this.pendingEchoSkips > 0) {
259
319
  const nlIdx = data.indexOf("\n");
260
320
  if (nlIdx === -1)
261
321
  return;
262
- this.echoSkip = false;
322
+ this.pendingEchoSkips--;
263
323
  const rest = data.slice(nlIdx + 1);
264
324
  if (rest)
265
325
  process.stdout.write(rest);
@@ -275,81 +335,80 @@ export class Shell {
275
335
  });
276
336
  }
277
337
  /**
278
- * React to agent lifecycle events — Shell manages its own state
279
- * rather than being driven by AcpClient. This means AcpClient has
280
- * zero frontend knowledge; any frontend can subscribe to the same events.
338
+ * shell:on-processing-done splits into unconditional state cleanup
339
+ * (release agent-turn scope) and an advisable redraw (freshPrompt).
340
+ * RemoteSession suppresses the redraw, never the cleanup, so soft-mute
341
+ * can't leak past the end of a turn even when overlays are involved.
281
342
  */
282
343
  setupAgentLifecycle() {
283
- // Default agent lifecycle: pause the shell while the agent works,
284
- // then redraw the prompt when done. Extensions advise these handlers
285
- // to change behavior (e.g. tmux split keeps the shell interactive).
344
+ let agentTurnScope = null;
286
345
  this.handlers.define("shell:on-processing-start", () => {
287
346
  this.agentActive = true;
288
- this.paused = true;
347
+ agentTurnScope = this.acquireMute("agent-turn");
289
348
  });
290
- this.handlers.define("shell:on-processing-done", () => {
291
- this.agentActive = false;
292
- // If handleProcessingDone re-entered a mode, leave stdout paused so
293
- // stale PTY output doesn't overwrite the mode prompt (exitMode →
294
- // redrawPrompt will unpause). Setting echoSkip here would swallow
295
- // that PTY output since no \n was sent.
349
+ this.handlers.define("shell:on-processing-redraw", () => {
296
350
  if (!this.inputHandler.handleProcessingDone()) {
297
- this.paused = false;
298
- if (this.freshPrompt()) {
299
- this.echoSkip = true;
300
- }
351
+ if (this.freshPrompt())
352
+ this.skipNextLine();
301
353
  }
302
354
  });
355
+ this.handlers.define("shell:on-processing-done", () => {
356
+ this.agentActive = false;
357
+ agentTurnScope?.release();
358
+ agentTurnScope = null;
359
+ this.handlers.call("shell:on-processing-redraw");
360
+ });
303
361
  this.bus.on("agent:processing-start", () => {
304
362
  this.handlers.call("shell:on-processing-start");
305
363
  });
306
364
  this.bus.on("agent:processing-done", () => {
307
365
  this.handlers.call("shell:on-processing-done");
308
366
  });
309
- // Permission prompts need stdout unpaused so the interactive UI renders,
310
- // then re-paused after the decision.
367
+ // Permission UI is briefly visible during the prompt; an unmute scope
368
+ // overrides whatever mute is currently held, then releases cleanly.
369
+ // Doesn't touch agent-turn state, so suppressed handlers can't leak.
370
+ let permissionVisible = null;
311
371
  this.bus.on("permission:request", () => {
312
- this.paused = false;
372
+ permissionVisible?.release();
373
+ permissionVisible = this.acquireUnmute("permission-ui");
313
374
  });
314
375
  this.bus.onPipeAsync("permission:request", async (payload) => {
315
- this.paused = true;
376
+ permissionVisible?.release();
377
+ permissionVisible = null;
316
378
  return payload;
317
379
  });
318
- // Shell exec: write a command to the live PTY and capture its output.
319
- // stdout is paused during agent processing, so PTY output flows through
320
- // OutputParser (for OSC detection) but never reaches the terminal.
321
380
  this.bus.onPipeAsync("shell:exec-request", async (payload) => {
322
- this.echoSkip = true;
323
- this.paused = false;
381
+ const visible = this.acquireUnmute("exec-request");
382
+ this.skipNextLine();
324
383
  process.stdout.write("\n");
325
384
  this.bus.emit("shell:agent-exec-start", {});
326
- const output = await new Promise((resolve, reject) => {
327
- const timeout = setTimeout(() => {
328
- this.bus.off("shell:command-done", handler);
329
- this.ptyProcess.write("\x03");
330
- reject(new Error("Shell exec timed out after 30s"));
331
- }, 30_000);
332
- const handler = (e) => {
333
- clearTimeout(timeout);
334
- this.bus.off("shell:command-done", handler);
335
- // Re-pause stdout so the prompt text following the marker doesn't
336
- // leak to the terminal while the agent is still processing.
337
- this.paused = true;
338
- resolve({ output: e.output, cwd: e.cwd, exitCode: e.exitCode });
339
- };
340
- this.bus.on("shell:command-done", handler);
341
- this.outputParser.onCommandEntered(payload.command, this.outputParser.getCwd());
342
- // Collapse literal newlines to spaces so the PTY receives a single-line
343
- // command. Multi-line commands (e.g. git commit -m "...\n...") would
344
- // cause the shell to execute prematurely, producing garbled output from
345
- // syntax highlighting plugins (zsh syntax highlighting, etc).
346
- const oneLine = payload.command.replace(/\n/g, " ");
347
- this.ptyProcess.write(oneLine + "\r");
348
- });
349
- this.paused = true;
350
- this.echoSkip = false;
351
- this.bus.emit("shell:agent-exec-done", {});
352
- return { ...payload, output: output.output, cwd: output.cwd, exitCode: output.exitCode, done: true };
385
+ try {
386
+ const output = await new Promise((resolve, reject) => {
387
+ const timeout = setTimeout(() => {
388
+ this.bus.off("shell:command-done", handler);
389
+ this.ptyProcess.write("\x03");
390
+ reject(new Error("Shell exec timed out after 30s"));
391
+ }, 30_000);
392
+ const handler = (e) => {
393
+ clearTimeout(timeout);
394
+ this.bus.off("shell:command-done", handler);
395
+ resolve({ output: e.output, cwd: e.cwd, exitCode: e.exitCode });
396
+ };
397
+ this.bus.on("shell:command-done", handler);
398
+ this.outputParser.onCommandEntered(payload.command, this.outputParser.getCwd());
399
+ // Collapse literal newlines to spaces so the PTY receives a single-line
400
+ // command. Multi-line commands (e.g. git commit -m "...\n...") would
401
+ // cause the shell to execute prematurely, producing garbled output from
402
+ // syntax highlighting plugins (zsh syntax highlighting, etc).
403
+ const oneLine = payload.command.replace(/\n/g, " ");
404
+ this.ptyProcess.write(oneLine + "\r");
405
+ });
406
+ return { ...payload, output: output.output, cwd: output.cwd, exitCode: output.exitCode, done: true };
407
+ }
408
+ finally {
409
+ visible.release();
410
+ this.bus.emit("shell:agent-exec-done", {});
411
+ }
353
412
  });
354
413
  }
355
414
  // ── Public API (used by index.ts) ──
package/dist/types.d.ts CHANGED
@@ -100,6 +100,8 @@ export interface AgentShellConfig {
100
100
  baseURL?: string;
101
101
  /** Named provider to use from settings.json. */
102
102
  provider?: string;
103
+ /** Override settings.defaultBackend for this session only (does not persist). */
104
+ backend?: string;
103
105
  /** Conversation history backend. Defaults to the on-disk HistoryFile. */
104
106
  history?: HistoryAdapter;
105
107
  }
@@ -172,7 +172,6 @@ export declare class FloatingPanel {
172
172
  private prevFrame;
173
173
  private suppressNextRedraw;
174
174
  private autoDismissTimer;
175
- private ptyBuffer;
176
175
  private usedAltScreen;
177
176
  private wrapCache;
178
177
  private wrapCacheWidth;
@@ -204,6 +203,7 @@ export declare class FloatingPanel {
204
203
  appendText(text: string): void;
205
204
  appendLine(line: string): void;
206
205
  updateLastLine(fn: (line: string) => string): void;
206
+ popLastLine(): void;
207
207
  clearContent(): void;
208
208
  setTitle(title: string): void;
209
209
  setFooter(footer: string): void;
@@ -222,7 +222,6 @@ export declare class FloatingPanel {
222
222
  private buildFrame;
223
223
  private scheduleRender;
224
224
  private render;
225
- /** Full screen teardown: exit alt screen, release stdout, force redraw. */
226
225
  private teardownScreen;
227
226
  /** Start rendering TerminalBuffer directly (no overlay box). */
228
227
  private startPassthrough;
@@ -118,7 +118,6 @@ export class FloatingPanel {
118
118
  prevFrame = [];
119
119
  suppressNextRedraw = false;
120
120
  autoDismissTimer = null;
121
- ptyBuffer = ""; // PTY output accumulated while overlay is open
122
121
  usedAltScreen = false; // whether we entered our own alt screen
123
122
  wrapCache = new Map(); // line → wrapped lines (invalidated on width change)
124
123
  wrapCacheWidth = 0;
@@ -287,20 +286,14 @@ export class FloatingPanel {
287
286
  }
288
287
  // ── Bus event wiring ───────────────────────────────────────
289
288
  wireEvents() {
290
- // Buffer PTY output while overlay is visible (alt screen discards it).
291
- // Don't buffer when hidden — PTY flows to terminal directly via stdout-show.
292
- this.bus.on("shell:pty-data", ({ raw }) => {
293
- if (this._visible)
294
- this.ptyBuffer += raw;
295
- });
296
289
  this.bus.onPipe("input:intercept", (payload) => this.handleIntercept(payload));
297
290
  this.bus.onPipe("shell:redraw-prompt", (payload) => {
298
291
  if (this._visible || this._passthrough) {
299
292
  return { ...payload, handled: true };
300
293
  }
301
- // After dismiss, suppress one redraw restoreScreen already
302
- // restored the terminal content, so freshPrompt's \n is unwanted.
303
- if (this.suppressNextRedraw) {
294
+ // Suppress only freshPrompt's \n — an in-place redraw must not
295
+ // consume the slot, or unrelated mode-exit redraws go missing.
296
+ if (this.suppressNextRedraw && payload.kind === "fresh") {
304
297
  this.suppressNextRedraw = false;
305
298
  return { ...payload, handled: true };
306
299
  }
@@ -374,7 +367,6 @@ export class FloatingPanel {
374
367
  // so the background program's screen stays correct without
375
368
  // handing rendering control back to ncurses.
376
369
  this._passthrough = true;
377
- this.ptyBuffer = "";
378
370
  this.startPassthrough();
379
371
  }
380
372
  else {
@@ -435,7 +427,6 @@ export class FloatingPanel {
435
427
  /** Common screen enter logic shared by open() and show(). */
436
428
  enterScreen() {
437
429
  this._visible = true;
438
- this.ptyBuffer = "";
439
430
  this.bus.emit("shell:stdout-hold", {});
440
431
  this.usedAltScreen = !(this.buffer?.altScreen);
441
432
  if (this.usedAltScreen) {
@@ -471,6 +462,15 @@ export class FloatingPanel {
471
462
  }
472
463
  this.scheduleRender();
473
464
  }
465
+ popLastLine() {
466
+ if (this.currentPartialLine) {
467
+ this.currentPartialLine = "";
468
+ }
469
+ else if (this.contentLines.length > 0) {
470
+ this.contentLines.pop();
471
+ }
472
+ this.scheduleRender();
473
+ }
474
474
  clearContent() {
475
475
  this.contentLines = [];
476
476
  this.currentPartialLine = "";
@@ -751,39 +751,38 @@ export class FloatingPanel {
751
751
  this.prevFrame = frame;
752
752
  }
753
753
  // ── Screen helpers ────────────────────────────────────────
754
- /** Full screen teardown: exit alt screen, release stdout, force redraw. */
755
754
  teardownScreen() {
756
755
  this.resizeUnsub?.();
757
756
  this.resizeUnsub = null;
758
757
  this.suppressNextRedraw = true;
759
- // Re-check alt screen state: the program we overlaid may have exited
760
- // (e.g. agent quit vim via terminal_keys) while the panel was active.
761
- const stillInAltScreen = !this.usedAltScreen && !!this.buffer?.altScreen;
762
- const programExited = !this.usedAltScreen && !stillInAltScreen;
763
- if (this.usedAltScreen) {
764
- this.surface.write("\x1b[?1049l");
765
- }
766
- // Replay PTY output that arrived while the overlay was active.
767
- // Without this, commands run by the agent (e.g. user_shell ls)
768
- // would vanish — the alt screen exit restores the saved screen
769
- // from before the overlay opened, losing any shell output produced
770
- // during the session.
771
- if (this.ptyBuffer) {
772
- this.surface.write(this.ptyBuffer);
773
- }
774
- this.ptyBuffer = "";
775
- this.bus.emit("shell:stdout-release", {});
776
- if (stillInAltScreen || programExited) {
777
- // Either a TUI app is still running and needs SIGWINCH to repaint,
778
- // or the overlaid program exited (e.g. agent quit vim) and we
779
- // discarded its stale buffer — SIGWINCH makes the shell redraw
780
- // its prompt cleanly.
758
+ this.buffer?.flush();
759
+ const programInAlt = !!this.buffer?.altScreen;
760
+ if (!this.usedAltScreen && programInAlt) {
761
+ // Program still in its own alt-screen — SIGWINCH so it redraws
762
+ // and re-asserts its modes; replaying from the mirror would
763
+ // freeze modes serialize() doesn't track (modifyOtherKeys, kitty
764
+ // kbd) and leave ctrl-c arriving as \x1b[27;5;99~.
765
+ this.bus.emit("shell:stdout-release", {});
781
766
  const cols = this.surface.columns;
782
767
  const rows = this.surface.rows;
783
768
  this.bus.emit("shell:pty-resize", { cols, rows: rows - 1 });
784
769
  setTimeout(() => {
785
770
  this.bus.emit("shell:pty-resize", { cols, rows });
786
771
  }, 50);
772
+ return;
773
+ }
774
+ this.surface.write("\x1b[?1049l");
775
+ if (!this.usedAltScreen) {
776
+ // Program exited mid-overlay; its reset bytes were eaten by
777
+ // stdout-hold. Reset modes serialize() doesn't track or the
778
+ // host stays in vim's modifyOtherKeys mode.
779
+ this.surface.write("\x1b[>4;0m\x1b[<u\x1b[?2004l\x1b[?1004l" +
780
+ "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l");
781
+ }
782
+ this.bus.emit("shell:stdout-release", {});
783
+ const serialized = this.buffer?.serialize();
784
+ if (serialized) {
785
+ this.surface.write(`${SYNC_START}\x1b[2J\x1b[H${serialized}${SYNC_END}`);
787
786
  }
788
787
  }
789
788
  // ── Passthrough rendering ─────────────────────────────────
@@ -808,7 +807,7 @@ export class FloatingPanel {
808
807
  const serialized = this.buffer.serialize();
809
808
  if (serialized && serialized !== this.prevSerialized) {
810
809
  this.prevSerialized = serialized;
811
- this.surface.write(`${SYNC_START}\x1b[H${serialized}${SYNC_END}`);
810
+ this.surface.write(`${SYNC_START}\x1b[2J\x1b[H${serialized}${SYNC_END}`);
812
811
  }
813
812
  }
814
813
  resolveSize(spec, available) {
@@ -364,6 +364,8 @@ export class MarkdownRenderer {
364
364
  return this.renderInline(line);
365
365
  }
366
366
  renderInline(text) {
367
+ // Links first — later subs inject `\x1b[…m` whose `[` would be eaten here.
368
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, `$1 ${p.muted}${p.underline}($2)${p.reset}`);
367
369
  // Inline code
368
370
  text = text.replace(/`([^`]+)`/g, `${p.accent}$1${p.reset}`);
369
371
  // Bold + italic
@@ -376,8 +378,6 @@ export class MarkdownRenderer {
376
378
  text = text.replace(/(?<!\w)_(.+?)_(?!\w)/g, `${p.italic}$1${p.reset}`);
377
379
  // Strikethrough
378
380
  text = text.replace(/~~(.+?)~~/g, `${p.dim}$1${p.reset}`);
379
- // Links
380
- text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, `$1 ${p.muted}${p.underline}($2)${p.reset}`);
381
381
  return text;
382
382
  }
383
383
  /**
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.12.22",
3
+ "version": "0.12.24",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",