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 +2 -2
- package/dist/core.d.ts +1 -1
- package/dist/core.js +6 -4
- package/dist/event-bus.d.ts +3 -0
- package/dist/executor.js +18 -4
- package/dist/extensions/agent-backend.js +1 -2
- package/dist/index.js +45 -11
- package/dist/install.d.ts +10 -0
- package/dist/install.js +205 -0
- package/dist/shell/shell.d.ts +26 -7
- package/dist/shell/shell.js +133 -74
- package/dist/types.d.ts +2 -0
- package/dist/utils/floating-panel.d.ts +1 -2
- package/dist/utils/floating-panel.js +35 -36
- package/dist/utils/markdown.js +2 -2
- package/examples/extensions/overlay-agent.ts +51 -43
- package/package.json +1 -1
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
|
|
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/)
|
|
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
|
-
//
|
|
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-
|
|
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)));
|
package/dist/event-bus.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
|
284
|
-
"
|
|
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 =
|
|
296
|
-
?
|
|
297
|
-
: backendNames
|
|
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 {};
|
package/dist/install.js
ADDED
|
@@ -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
|
+
}
|
package/dist/shell/shell.d.ts
CHANGED
|
@@ -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
|
|
14
|
-
private
|
|
15
|
-
private
|
|
16
|
-
private
|
|
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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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. */
|
package/dist/shell/shell.js
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
this.bus.on("shell:stdout-
|
|
194
|
-
|
|
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.
|
|
254
|
-
return;
|
|
255
|
-
if (this.paused && !this.stdoutShow.active)
|
|
316
|
+
if (this.isHostMuted())
|
|
256
317
|
return;
|
|
257
|
-
|
|
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.
|
|
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
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
347
|
+
agentTurnScope = this.acquireMute("agent-turn");
|
|
289
348
|
});
|
|
290
|
-
this.handlers.define("shell:on-processing-
|
|
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.
|
|
298
|
-
|
|
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
|
|
310
|
-
//
|
|
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
|
-
|
|
372
|
+
permissionVisible?.release();
|
|
373
|
+
permissionVisible = this.acquireUnmute("permission-ui");
|
|
313
374
|
});
|
|
314
375
|
this.bus.onPipeAsync("permission:request", async (payload) => {
|
|
315
|
-
|
|
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
|
-
|
|
323
|
-
this.
|
|
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
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
//
|
|
302
|
-
//
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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) {
|
package/dist/utils/markdown.js
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Press Ctrl+\ from anywhere — a shell prompt, vim, ssh, htop, a REPL — to
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
15
|
+
* Or load ad-hoc without copying:
|
|
16
|
+
* agent-sh -e "$(npm root -g)/agent-sh/examples/extensions/overlay-agent.ts"
|
|
13
17
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
72
|
-
"
|
|
73
|
-
"
|
|
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
|
-
//
|
|
105
|
-
//
|
|
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 (
|
|
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
|
}
|