agent-sh 0.15.0 → 0.15.2
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/dist/agent/agent-loop.js +11 -8
- package/dist/agent/events.d.ts +4 -0
- package/docs/README.md +14 -0
- package/docs/agent.md +398 -0
- package/docs/architecture.md +196 -0
- package/docs/context-management.md +200 -0
- package/docs/extensions.md +951 -0
- package/docs/library.md +84 -0
- package/docs/troubleshooting.md +65 -0
- package/docs/tui-composition.md +294 -0
- package/docs/usage.md +306 -0
- package/examples/extensions/ash-scheme/package.json +1 -1
- package/examples/extensions/ashi/EXTENDING.md +2 -2
- package/examples/extensions/ashi/README.md +2 -2
- package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
- package/examples/extensions/ashi/package.json +5 -3
- package/examples/extensions/ashi/src/chat/tool-group.ts +3 -2
- package/examples/extensions/ashi/src/cli.ts +9 -8
- package/examples/extensions/ashi/src/dialogs.ts +16 -1
- package/examples/extensions/ashi/src/events.ts +1 -0
- package/examples/extensions/ashi/src/frontend.ts +26 -6
- package/examples/extensions/ashi/src/renderer.ts +24 -4
- package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +4 -3
- package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
- package/examples/extensions/ashi/src/ui.ts +11 -0
- package/examples/extensions/ashi-ink/package.json +2 -2
- package/examples/extensions/claude-code-bridge/package.json +1 -1
- package/examples/extensions/opencode-bridge/package.json +1 -1
- package/package.json +3 -1
- package/src/agent/agent-loop.ts +1566 -0
- package/src/agent/entry-format.ts +19 -0
- package/src/agent/events.ts +153 -0
- package/src/agent/extensions/rolling-history/constants.ts +1 -0
- package/src/agent/extensions/rolling-history/index.ts +202 -0
- package/src/agent/extensions/rolling-history/recall.ts +131 -0
- package/src/agent/extensions/rolling-history/strategy.ts +404 -0
- package/src/agent/host-types.ts +192 -0
- package/src/agent/index.ts +591 -0
- package/src/agent/live-view.ts +279 -0
- package/src/agent/llm-client.ts +111 -0
- package/src/agent/llm-facade.ts +43 -0
- package/src/agent/normalize-args.ts +61 -0
- package/src/agent/nuclear-form.ts +382 -0
- package/src/agent/providers/deepseek.ts +39 -0
- package/src/agent/providers/ollama.ts +92 -0
- package/src/agent/providers/openai-compatible.ts +36 -0
- package/src/agent/providers/openai.ts +52 -0
- package/src/agent/providers/opencode.ts +142 -0
- package/src/agent/providers/openrouter.ts +105 -0
- package/src/agent/providers/zai-coding-plan.ts +33 -0
- package/src/agent/session-store.ts +336 -0
- package/src/agent/skills.ts +228 -0
- package/src/agent/store.ts +310 -0
- package/src/agent/subagent.ts +305 -0
- package/src/agent/system-prompt.ts +151 -0
- package/src/agent/token-budget.ts +12 -0
- package/src/agent/tool-protocol.ts +722 -0
- package/src/agent/tool-registry.ts +66 -0
- package/src/agent/tools/bash.ts +95 -0
- package/src/agent/tools/edit-file.ts +154 -0
- package/src/agent/tools/expand-home.ts +7 -0
- package/src/agent/tools/glob.ts +108 -0
- package/src/agent/tools/grep.ts +228 -0
- package/src/agent/tools/list-skills.ts +37 -0
- package/src/agent/tools/ls.ts +81 -0
- package/src/agent/tools/pwsh.ts +140 -0
- package/src/agent/tools/read-file.ts +164 -0
- package/src/agent/tools/write-file.ts +72 -0
- package/src/agent/types.ts +149 -0
- package/src/cli/args.ts +91 -0
- package/src/cli/auth/cli.ts +244 -0
- package/src/cli/auth/discover.ts +52 -0
- package/src/cli/auth/keys.ts +143 -0
- package/src/cli/index.ts +295 -0
- package/src/cli/init.ts +74 -0
- package/src/cli/install.ts +439 -0
- package/src/cli/shell-env.ts +68 -0
- package/src/cli/subcommands.ts +24 -0
- package/src/core/event-bus.ts +252 -0
- package/src/core/extension-loader.ts +347 -0
- package/src/core/index.ts +152 -0
- package/src/core/settings.ts +398 -0
- package/src/core/types.ts +61 -0
- package/src/extensions/file-autocomplete.ts +71 -0
- package/src/extensions/index.ts +38 -0
- package/src/extensions/slash-commands/events.ts +14 -0
- package/src/extensions/slash-commands/index.ts +269 -0
- package/src/shell/events.ts +73 -0
- package/src/shell/host-types.ts +150 -0
- package/src/shell/index.ts +159 -0
- package/src/shell/input-handler.ts +505 -0
- package/src/shell/output-parser.ts +156 -0
- package/src/shell/shell-context.ts +193 -0
- package/src/shell/shell.ts +414 -0
- package/src/shell/strategies/bash.ts +83 -0
- package/src/shell/strategies/fish.ts +77 -0
- package/src/shell/strategies/index.ts +24 -0
- package/src/shell/strategies/types.ts +64 -0
- package/src/shell/strategies/zsh.ts +92 -0
- package/src/shell/terminal.ts +124 -0
- package/src/shell/tui-input-view.ts +222 -0
- package/src/shell/tui-renderer.ts +1126 -0
- package/src/utils/ansi.ts +140 -0
- package/src/utils/box-frame.ts +138 -0
- package/src/utils/compositor.ts +157 -0
- package/src/utils/diff-renderer.ts +829 -0
- package/src/utils/diff.ts +244 -0
- package/src/utils/executor.ts +305 -0
- package/src/utils/file-watcher.ts +110 -0
- package/src/utils/floating-panel.ts +1160 -0
- package/src/utils/handler-registry.ts +110 -0
- package/src/utils/line-editor.ts +636 -0
- package/src/utils/markdown.ts +437 -0
- package/src/utils/message-utils.ts +113 -0
- package/src/utils/package-version.ts +12 -0
- package/src/utils/palette.ts +64 -0
- package/src/utils/ref-counter.ts +9 -0
- package/src/utils/ripgrep-path.ts +17 -0
- package/src/utils/shell-output-spill.ts +76 -0
- package/src/utils/stream-transform.ts +292 -0
- package/src/utils/terminal-buffer.ts +213 -0
- package/src/utils/tool-display.ts +315 -0
- package/src/utils/tool-interactive.ts +71 -0
- package/src/utils/tty.ts +14 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as pty from "node-pty";
|
|
4
|
+
import type { EventBus } from "../core/event-bus.js";
|
|
5
|
+
import { InputHandler, type InputContext } from "./input-handler.js";
|
|
6
|
+
import { OutputParser } from "./output-parser.js";
|
|
7
|
+
import { getSettings } from "../core/settings.js";
|
|
8
|
+
import { clearOpost } from "../utils/tty.js";
|
|
9
|
+
import { processTerminal, surfaceFromTerminal, type Terminal } from "./terminal.js";
|
|
10
|
+
import { TuiInputView } from "./tui-input-view.js";
|
|
11
|
+
import {
|
|
12
|
+
pickStrategy,
|
|
13
|
+
FALLBACK_STRATEGY,
|
|
14
|
+
SUPPORTED_SHELL_NAMES,
|
|
15
|
+
type ShellStrategy,
|
|
16
|
+
} from "./strategies/index.js";
|
|
17
|
+
|
|
18
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
19
|
+
export interface ShellHandlers {
|
|
20
|
+
define: (name: string, fn: (...args: any[]) => any) => void;
|
|
21
|
+
call: (name: string, ...args: any[]) => any;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A claim on the shell's stdout-mute state. Acquire from shell.acquire*,
|
|
26
|
+
* pair with release() in a try/finally. Token-shape forces symmetry —
|
|
27
|
+
* the only way to influence the gate is to hold and release a scope.
|
|
28
|
+
*/
|
|
29
|
+
export interface ShellScope {
|
|
30
|
+
readonly reason: string;
|
|
31
|
+
release(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class Shell implements InputContext {
|
|
35
|
+
private ptyProcess: pty.IPty;
|
|
36
|
+
private bus: EventBus;
|
|
37
|
+
private handlers: ShellHandlers;
|
|
38
|
+
private inputHandler: InputHandler;
|
|
39
|
+
private outputParser: OutputParser;
|
|
40
|
+
private terminal: Terminal;
|
|
41
|
+
private inputDispose: (() => void) | null = null;
|
|
42
|
+
// hardMute is unconditional (overlay compositing); softMute is overridable
|
|
43
|
+
// by unmute (terminal_keys, permission UI). Gate: hard wins; otherwise
|
|
44
|
+
// muted iff softMute held without an unmute.
|
|
45
|
+
private hardMuteScopes = new Set<ShellScope>();
|
|
46
|
+
private softMuteScopes = new Set<ShellScope>();
|
|
47
|
+
private unmuteScopes = new Set<ShellScope>();
|
|
48
|
+
private pendingEchoSkips = 0;
|
|
49
|
+
private agentActive = false;
|
|
50
|
+
private strategy: ShellStrategy;
|
|
51
|
+
private tmpDir?: string;
|
|
52
|
+
|
|
53
|
+
constructor(opts: {
|
|
54
|
+
bus: EventBus;
|
|
55
|
+
handlers: ShellHandlers;
|
|
56
|
+
onShowAgentInfo?: () => { info: string; model?: string };
|
|
57
|
+
cols: number;
|
|
58
|
+
rows: number;
|
|
59
|
+
shell: string;
|
|
60
|
+
cwd: string;
|
|
61
|
+
instanceId: string;
|
|
62
|
+
terminal?: Terminal;
|
|
63
|
+
}) {
|
|
64
|
+
this.terminal = opts.terminal ?? processTerminal();
|
|
65
|
+
|
|
66
|
+
// Build environment — filter out undefined values (node-pty's native
|
|
67
|
+
// posix_spawnp fails if any env value is undefined)
|
|
68
|
+
const env: Record<string, string> = {};
|
|
69
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
70
|
+
if (v !== undefined) env[k] = v;
|
|
71
|
+
}
|
|
72
|
+
env.AGENT_SH = "1";
|
|
73
|
+
|
|
74
|
+
// Spawn the user's shell with their full config (aliases, plugins, PATH,
|
|
75
|
+
// completions, etc.). The core injects three invisible OSC hooks:
|
|
76
|
+
// - OSC 7: cwd tracking (required by OutputParser)
|
|
77
|
+
// - OSC 9999: prompt start marker (command boundary detection)
|
|
78
|
+
// - OSC 9998: prompt end marker (bracketed prompt capture)
|
|
79
|
+
// Prompt theming is left entirely to the user's shell config. Per-shell
|
|
80
|
+
// rc-file generation lives in src/shell/strategies/.
|
|
81
|
+
const matched = pickStrategy(opts.shell);
|
|
82
|
+
if (!matched) {
|
|
83
|
+
console.warn(
|
|
84
|
+
`Warning: agent-sh only supports ${SUPPORTED_SHELL_NAMES.join(", ")}. ` +
|
|
85
|
+
`"${opts.shell}" may not work correctly — falling back to /bin/bash.`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
this.strategy = matched ?? FALLBACK_STRATEGY;
|
|
89
|
+
const shellBin = matched ? opts.shell : "/bin/bash";
|
|
90
|
+
|
|
91
|
+
// Per-instance tag so nested agent-sh hooks don't cross-trigger.
|
|
92
|
+
const instanceTag = `id=${opts.instanceId}`;
|
|
93
|
+
const settings = getSettings();
|
|
94
|
+
const spawnConfig = this.strategy.prepareSpawn({
|
|
95
|
+
tmpDirRoot: os.tmpdir(),
|
|
96
|
+
instanceTag,
|
|
97
|
+
showIndicator: settings.promptIndicator !== false,
|
|
98
|
+
userHome: env.HOME || os.homedir(),
|
|
99
|
+
env,
|
|
100
|
+
});
|
|
101
|
+
this.tmpDir = spawnConfig.tmpDir;
|
|
102
|
+
Object.assign(env, spawnConfig.envOverrides);
|
|
103
|
+
const shellArgs = spawnConfig.args;
|
|
104
|
+
|
|
105
|
+
// The PTY will become the controlling terminal for the child shell;
|
|
106
|
+
// suspend the host terminal's input around spawn to avoid TTY contention
|
|
107
|
+
// on macOS. Headless terminals make this a no-op.
|
|
108
|
+
const suspended = this.terminal.suspendInput?.();
|
|
109
|
+
|
|
110
|
+
this.ptyProcess = pty.spawn(shellBin, shellArgs, {
|
|
111
|
+
name: "xterm-256color",
|
|
112
|
+
cols: opts.cols,
|
|
113
|
+
rows: opts.rows,
|
|
114
|
+
cwd: opts.cwd,
|
|
115
|
+
env,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
suspended?.resume();
|
|
119
|
+
|
|
120
|
+
clearOpost();
|
|
121
|
+
|
|
122
|
+
this.bus = opts.bus;
|
|
123
|
+
this.handlers = opts.handlers;
|
|
124
|
+
this.outputParser = new OutputParser(opts.bus, opts.cwd, instanceTag, {
|
|
125
|
+
cleanOutput: this.strategy.cleanOutput?.bind(this.strategy),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Ensure temp dir cleanup on abnormal exit (SIGKILL won't fire this,
|
|
129
|
+
// but it covers uncaught exceptions and normal process.exit paths)
|
|
130
|
+
if (this.tmpDir) {
|
|
131
|
+
const dir = this.tmpDir;
|
|
132
|
+
process.on("exit", () => {
|
|
133
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this.inputHandler = new InputHandler({
|
|
138
|
+
ctx: this,
|
|
139
|
+
bus: opts.bus,
|
|
140
|
+
handlers: opts.handlers,
|
|
141
|
+
onShowAgentInfo: opts.onShowAgentInfo ?? (() => ({ info: "" })),
|
|
142
|
+
view: new TuiInputView(surfaceFromTerminal(this.terminal)),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
this.setupOutput();
|
|
146
|
+
this.setupInput();
|
|
147
|
+
this.setupAgentLifecycle();
|
|
148
|
+
|
|
149
|
+
// Allow extensions to inject raw keystrokes into the PTY
|
|
150
|
+
this.bus.on("shell:pty-write", ({ data }) => {
|
|
151
|
+
this.ptyProcess.write(data);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Allow extensions to resize the PTY (sends SIGWINCH to child)
|
|
155
|
+
this.bus.on("shell:pty-resize", ({ cols, rows }) => {
|
|
156
|
+
this.ptyProcess.resize(cols, rows);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
this.bus.on("shell:host-write", ({ data }) => {
|
|
160
|
+
this.terminal.write(data);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Compat shims for the bus-event API. shell:stdout-hold maps to hard
|
|
164
|
+
// mute so terminal_keys' stdout-show can't paint through the overlay.
|
|
165
|
+
let holdRefcount = 0;
|
|
166
|
+
let holdScope: ShellScope | null = null;
|
|
167
|
+
this.bus.on("shell:stdout-hold", () => {
|
|
168
|
+
if (holdRefcount === 0) holdScope = this.acquireHardMute("bus:stdout-hold");
|
|
169
|
+
holdRefcount++;
|
|
170
|
+
});
|
|
171
|
+
this.bus.on("shell:stdout-release", () => {
|
|
172
|
+
if (holdRefcount === 0) return;
|
|
173
|
+
holdRefcount--;
|
|
174
|
+
if (holdRefcount === 0) { holdScope?.release(); holdScope = null; }
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
let showRefcount = 0;
|
|
178
|
+
let showScope: ShellScope | null = null;
|
|
179
|
+
this.bus.on("shell:stdout-show", () => {
|
|
180
|
+
if (showRefcount === 0) showScope = this.acquireUnmute("bus:stdout-show");
|
|
181
|
+
showRefcount++;
|
|
182
|
+
});
|
|
183
|
+
this.bus.on("shell:stdout-hide", () => {
|
|
184
|
+
if (showRefcount === 0) return;
|
|
185
|
+
showRefcount--;
|
|
186
|
+
if (showRefcount === 0) { showScope?.release(); showScope = null; }
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Scope-based gating ─────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/** Compositing-layer claim — overrides any unmute. */
|
|
193
|
+
acquireHardMute(reason: string): ShellScope {
|
|
194
|
+
const scope: ShellScope = {
|
|
195
|
+
reason,
|
|
196
|
+
release: () => { this.hardMuteScopes.delete(scope); },
|
|
197
|
+
};
|
|
198
|
+
this.hardMuteScopes.add(scope);
|
|
199
|
+
return scope;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Agent-turn / exec-style mute — overridable by unmute. */
|
|
203
|
+
acquireMute(reason: string): ShellScope {
|
|
204
|
+
const scope: ShellScope = {
|
|
205
|
+
reason,
|
|
206
|
+
release: () => { this.softMuteScopes.delete(scope); },
|
|
207
|
+
};
|
|
208
|
+
this.softMuteScopes.add(scope);
|
|
209
|
+
return scope;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Force visible while held; overrides soft mutes only. */
|
|
213
|
+
acquireUnmute(reason: string): ShellScope {
|
|
214
|
+
const scope: ShellScope = {
|
|
215
|
+
reason,
|
|
216
|
+
release: () => { this.unmuteScopes.delete(scope); },
|
|
217
|
+
};
|
|
218
|
+
this.unmuteScopes.add(scope);
|
|
219
|
+
return scope;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Swallow the next \n-terminated chunk from PTY (one per call). */
|
|
223
|
+
skipNextLine(): void { this.pendingEchoSkips++; }
|
|
224
|
+
|
|
225
|
+
private isHostMuted(): boolean {
|
|
226
|
+
if (this.hardMuteScopes.size > 0) return true;
|
|
227
|
+
return this.softMuteScopes.size > 0 && this.unmuteScopes.size === 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── InputContext implementation (delegates to OutputParser) ──
|
|
231
|
+
|
|
232
|
+
isForegroundBusy(): boolean {
|
|
233
|
+
return this.outputParser.isForegroundBusy();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
getCwd(): string {
|
|
237
|
+
return this.outputParser.getCwd();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
isAgentActive(): boolean {
|
|
241
|
+
return this.agentActive;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
writeToPty(data: string): void {
|
|
245
|
+
this.ptyProcess.write(data);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Ask the shell to redraw its own prompt in place. The escape sequence is
|
|
250
|
+
* defined per-strategy and bound in the generated rc file (zsh: ZLE widget,
|
|
251
|
+
* bash: readline redraw-current-line). When the strategy returns null we
|
|
252
|
+
* skip the in-place redraw and let freshPrompt do a heavy redraw instead.
|
|
253
|
+
*/
|
|
254
|
+
redrawPrompt(): void {
|
|
255
|
+
const result = this.bus.emitPipe("shell:redraw-prompt", {
|
|
256
|
+
cwd: this.outputParser.getCwd(),
|
|
257
|
+
kind: "redraw",
|
|
258
|
+
handled: false,
|
|
259
|
+
});
|
|
260
|
+
if (result.handled) return;
|
|
261
|
+
const escape = this.strategy.redrawEscape();
|
|
262
|
+
if (escape) this.ptyProcess.write(escape);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Heavy redraw: send \n to PTY to trigger a full precmd → prompt cycle.
|
|
267
|
+
* Use this after agent responses where stdout has moved far from where
|
|
268
|
+
* zle expects the cursor. The blank line is acceptable as a separator.
|
|
269
|
+
*
|
|
270
|
+
* Routed through shell:redraw-prompt pipe so extensions (e.g. overlay)
|
|
271
|
+
* can suppress it by setting `handled: true`.
|
|
272
|
+
*/
|
|
273
|
+
freshPrompt(): boolean {
|
|
274
|
+
const result = this.bus.emitPipe("shell:redraw-prompt", {
|
|
275
|
+
cwd: this.outputParser.getCwd(),
|
|
276
|
+
kind: "fresh",
|
|
277
|
+
handled: false,
|
|
278
|
+
});
|
|
279
|
+
if (!result.handled) {
|
|
280
|
+
this.ptyProcess.write("\n");
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
onCommandEntered(command: string, cwd: string): void {
|
|
287
|
+
this.outputParser.onCommandEntered(command, cwd);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── PTY I/O wiring ─────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
private setupOutput(): void {
|
|
293
|
+
this.ptyProcess.onData((data: string) => {
|
|
294
|
+
this.bus.emit("shell:pty-data", { raw: data });
|
|
295
|
+
this.outputParser.processData(data);
|
|
296
|
+
|
|
297
|
+
if (this.isHostMuted()) return;
|
|
298
|
+
|
|
299
|
+
if (this.pendingEchoSkips > 0) {
|
|
300
|
+
const nlIdx = data.indexOf("\n");
|
|
301
|
+
if (nlIdx === -1) return;
|
|
302
|
+
this.pendingEchoSkips--;
|
|
303
|
+
const rest = data.slice(nlIdx + 1);
|
|
304
|
+
if (rest) this.terminal.write(rest);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
this.terminal.write(data);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private setupInput(): void {
|
|
313
|
+
this.inputDispose = this.terminal.onInput((str) => {
|
|
314
|
+
this.inputHandler.handleInput(str);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* shell:on-processing-done splits into unconditional state cleanup
|
|
320
|
+
* (release agent-turn scope) and an advisable redraw (freshPrompt).
|
|
321
|
+
* RemoteSession suppresses the redraw, never the cleanup, so soft-mute
|
|
322
|
+
* can't leak past the end of a turn even when overlays are involved.
|
|
323
|
+
*/
|
|
324
|
+
private setupAgentLifecycle(): void {
|
|
325
|
+
let agentTurnScope: ShellScope | null = null;
|
|
326
|
+
|
|
327
|
+
this.handlers.define("shell:on-processing-start", () => {
|
|
328
|
+
this.agentActive = true;
|
|
329
|
+
agentTurnScope = this.acquireMute("agent-turn");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
this.handlers.define("shell:on-processing-redraw", () => {
|
|
333
|
+
if (!this.inputHandler.handleProcessingDone()) {
|
|
334
|
+
if (this.freshPrompt()) this.skipNextLine();
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
this.handlers.define("shell:on-processing-done", () => {
|
|
339
|
+
this.agentActive = false;
|
|
340
|
+
agentTurnScope?.release();
|
|
341
|
+
agentTurnScope = null;
|
|
342
|
+
this.handlers.call("shell:on-processing-redraw");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
this.bus.on("agent:processing-start", () => {
|
|
346
|
+
this.handlers.call("shell:on-processing-start");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
this.bus.on("agent:processing-done", () => {
|
|
350
|
+
this.handlers.call("shell:on-processing-done");
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
this.bus.onPipeAsync("shell:exec-request", async (payload) => {
|
|
354
|
+
const visible = this.acquireUnmute("exec-request");
|
|
355
|
+
this.skipNextLine();
|
|
356
|
+
this.terminal.write("\r\n");
|
|
357
|
+
this.bus.emit("shell:agent-exec-start", {});
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const output = await new Promise<{ output: string; cwd: string; exitCode: number | null }>((resolve, reject) => {
|
|
361
|
+
const timeout = setTimeout(() => {
|
|
362
|
+
this.bus.off("shell:command-done", handler);
|
|
363
|
+
this.ptyProcess.write("\x03");
|
|
364
|
+
reject(new Error("Shell exec timed out after 30s"));
|
|
365
|
+
}, 30_000);
|
|
366
|
+
|
|
367
|
+
const handler = (e: { command: string; output: string; cwd: string; exitCode: number | null }) => {
|
|
368
|
+
clearTimeout(timeout);
|
|
369
|
+
this.bus.off("shell:command-done", handler);
|
|
370
|
+
resolve({ output: e.output, cwd: e.cwd, exitCode: e.exitCode });
|
|
371
|
+
};
|
|
372
|
+
this.bus.on("shell:command-done", handler);
|
|
373
|
+
|
|
374
|
+
this.outputParser.onCommandEntered(payload.command, this.outputParser.getCwd());
|
|
375
|
+
// Collapse literal newlines to spaces so the PTY receives a single-line
|
|
376
|
+
// command. Multi-line commands (e.g. git commit -m "...\n...") would
|
|
377
|
+
// cause the shell to execute prematurely, producing garbled output from
|
|
378
|
+
// syntax highlighting plugins (zsh syntax highlighting, etc).
|
|
379
|
+
const oneLine = payload.command.replace(/\n/g, " ");
|
|
380
|
+
this.ptyProcess.write(oneLine + "\r");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
return { ...payload, output: output.output, cwd: output.cwd, exitCode: output.exitCode, done: true };
|
|
384
|
+
} finally {
|
|
385
|
+
visible.release();
|
|
386
|
+
this.bus.emit("shell:agent-exec-done", {});
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ── Public API (used by index.ts) ──
|
|
392
|
+
|
|
393
|
+
/** Temp directory used for shell config and sockets. */
|
|
394
|
+
getTmpDir(): string | undefined {
|
|
395
|
+
return this.tmpDir;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
resize(cols: number, rows: number): void {
|
|
399
|
+
this.ptyProcess.resize(cols, rows);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
onExit(callback: (e: { exitCode: number; signal?: number }) => void): void {
|
|
403
|
+
this.ptyProcess.onExit(callback);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
kill(): void {
|
|
407
|
+
this.inputDispose?.();
|
|
408
|
+
this.inputDispose = null;
|
|
409
|
+
this.ptyProcess.kill();
|
|
410
|
+
if (this.tmpDir) {
|
|
411
|
+
fs.rmSync(this.tmpDir, { recursive: true, force: true });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import type { ShellStrategy, PrepareSpawnOpts, ShellSpawnConfig } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const OSC7_CMD = 'printf "\\e]7;file://%s%s\\a" "$(hostname)" "$PWD"';
|
|
6
|
+
const TITLE_CMD = 'printf "\\e]0;⚡ agent-sh: %s\\a" "${PWD/#$HOME/~}"';
|
|
7
|
+
|
|
8
|
+
export const bashStrategy: ShellStrategy = {
|
|
9
|
+
name: "bash",
|
|
10
|
+
|
|
11
|
+
matches(shellPath: string): boolean {
|
|
12
|
+
return path.basename(shellPath).includes("bash");
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
prepareSpawn(opts: PrepareSpawnOpts): ShellSpawnConfig {
|
|
16
|
+
const { tmpDirRoot, instanceTag, showIndicator, env, userHome } = opts;
|
|
17
|
+
|
|
18
|
+
// Use --rcfile to source our wrapper, which sources the user's real
|
|
19
|
+
// bashrc then appends our hooks. No HOME override needed.
|
|
20
|
+
const tmpDir = fs.mkdtempSync(path.join(tmpDirRoot, "agent-sh-"));
|
|
21
|
+
const home = env.HOME || userHome;
|
|
22
|
+
const promptMarker = `printf "\\e]9999;${instanceTag};PROMPT\\a"`;
|
|
23
|
+
|
|
24
|
+
const lines = [
|
|
25
|
+
`[ -f "${home}/.bashrc" ] && source "${home}/.bashrc"`,
|
|
26
|
+
"",
|
|
27
|
+
"# agent-sh hooks (invisible OSC sequences for cwd + prompt detection)",
|
|
28
|
+
"# Wrapped in a function because inlining printf \"...\" into",
|
|
29
|
+
"# PROMPT_COMMAND=\"...\" breaks the outer quoting.",
|
|
30
|
+
"__agent_sh_precmd() {",
|
|
31
|
+
` ${OSC7_CMD}`,
|
|
32
|
+
` ${promptMarker}`,
|
|
33
|
+
...(showIndicator ? [` ${TITLE_CMD}`] : []),
|
|
34
|
+
" __agent_sh_preexec_ran=0",
|
|
35
|
+
"}",
|
|
36
|
+
`PROMPT_COMMAND="\${PROMPT_COMMAND%;}"`,
|
|
37
|
+
`PROMPT_COMMAND="\${PROMPT_COMMAND:+\$PROMPT_COMMAND;}__agent_sh_precmd"`,
|
|
38
|
+
"",
|
|
39
|
+
"# Preexec hook via DEBUG trap: emit actual command text so agent-sh",
|
|
40
|
+
"# can track history-recalled and tab-completed commands accurately.",
|
|
41
|
+
"# Start latched (=1) so the trap stays inert through the rest of",
|
|
42
|
+
"# rcfile sourcing — readline/history aren't loaded yet, and the case",
|
|
43
|
+
"# + bind statements below would otherwise fire a phantom preexec with",
|
|
44
|
+
"# an empty body. __agent_sh_precmd resets it to 0 before user input.",
|
|
45
|
+
"__agent_sh_preexec_ran=1",
|
|
46
|
+
"__agent_sh_emit_preexec() {",
|
|
47
|
+
' [[ $__agent_sh_preexec_ran == 1 ]] && return',
|
|
48
|
+
' [[ -n $COMP_LINE ]] && return',
|
|
49
|
+
" __agent_sh_preexec_ran=1",
|
|
50
|
+
" local this_cmd",
|
|
51
|
+
` this_cmd=$(HISTTIMEFORMAT='' builtin history 1 | command sed 's/^ *[0-9]* *//')`,
|
|
52
|
+
` printf '\\e]9997;${instanceTag};%s\\a' "$this_cmd"`,
|
|
53
|
+
"}",
|
|
54
|
+
"trap '__agent_sh_emit_preexec' DEBUG",
|
|
55
|
+
"",
|
|
56
|
+
"# End-of-prompt marker: append to PS1 (\\[...\\] marks it zero-width)",
|
|
57
|
+
`case "$PS1" in *9998*) ;; *) PS1="\${PS1}\\[\\e]9998;${instanceTag};READY\\a\\]";; esac`,
|
|
58
|
+
"",
|
|
59
|
+
"# Mirrors the zsh \\e[9999~ reset-prompt widget — used by agent-sh",
|
|
60
|
+
"# to repaint the prompt in place. All keymaps so `set -o vi` works.",
|
|
61
|
+
`bind -m emacs '"\\e[9999~":redraw-current-line' 2>/dev/null`,
|
|
62
|
+
`bind -m vi-insert '"\\e[9999~":redraw-current-line' 2>/dev/null`,
|
|
63
|
+
`bind -m vi-command '"\\e[9999~":redraw-current-line' 2>/dev/null`,
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const rcPath = path.join(tmpDir, ".bashrc");
|
|
67
|
+
fs.writeFileSync(rcPath, lines.join("\n") + "\n");
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
args: ["--rcfile", rcPath],
|
|
71
|
+
envOverrides: {},
|
|
72
|
+
tmpDir,
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
envCaptureCommand(): string {
|
|
77
|
+
return "[ -f ~/.bashrc ] && source ~/.bashrc 2>/dev/null; env -0";
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
redrawEscape(): string {
|
|
81
|
+
return "\x1b[9999~";
|
|
82
|
+
},
|
|
83
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import type { ShellStrategy, PrepareSpawnOpts, ShellSpawnConfig } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const OSC7_CMD = 'printf "\\e]7;file://%s%s\\a" (hostname) "$PWD"';
|
|
6
|
+
const TITLE_CMD =
|
|
7
|
+
'printf "\\e]0;⚡ agent-sh: %s\\a" (string replace -- "$HOME" "~" "$PWD")';
|
|
8
|
+
|
|
9
|
+
export const fishStrategy: ShellStrategy = {
|
|
10
|
+
name: "fish",
|
|
11
|
+
|
|
12
|
+
matches(shellPath: string): boolean {
|
|
13
|
+
return path.basename(shellPath).includes("fish");
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
prepareSpawn(opts: PrepareSpawnOpts): ShellSpawnConfig {
|
|
17
|
+
const { tmpDirRoot, instanceTag, showIndicator } = opts;
|
|
18
|
+
|
|
19
|
+
// Layer hooks via `-C` so they run after the user's config — our wrapper
|
|
20
|
+
// around fish_prompt needs to see the user's final definition.
|
|
21
|
+
const tmpDir = fs.mkdtempSync(path.join(tmpDirRoot, "agent-sh-"));
|
|
22
|
+
const initPath = path.join(tmpDir, "init.fish");
|
|
23
|
+
const promptMarker = `printf "\\e]9999;${instanceTag};PROMPT\\a"`;
|
|
24
|
+
|
|
25
|
+
const lines = [
|
|
26
|
+
"# agent-sh hooks (invisible OSC sequences for cwd + prompt detection)",
|
|
27
|
+
"function __agent_sh_precmd --on-event fish_prompt",
|
|
28
|
+
` ${OSC7_CMD}`,
|
|
29
|
+
` ${promptMarker}`,
|
|
30
|
+
...(showIndicator ? [` ${TITLE_CMD}`] : []),
|
|
31
|
+
"end",
|
|
32
|
+
"",
|
|
33
|
+
"# Preexec hook: emit actual command text so agent-sh can track",
|
|
34
|
+
"# history-recalled and tab-completed commands accurately",
|
|
35
|
+
"function __agent_sh_preexec --on-event fish_preexec",
|
|
36
|
+
` printf "\\e]9997;${instanceTag};%s\\a" "$argv"`,
|
|
37
|
+
"end",
|
|
38
|
+
"",
|
|
39
|
+
"# End-of-prompt marker: wrap fish_prompt so READY fires after render",
|
|
40
|
+
"if functions -q fish_prompt",
|
|
41
|
+
" functions --copy fish_prompt __agent_sh_orig_fish_prompt",
|
|
42
|
+
" function fish_prompt",
|
|
43
|
+
" __agent_sh_orig_fish_prompt",
|
|
44
|
+
` printf "\\e]9998;${instanceTag};READY\\a"`,
|
|
45
|
+
" end",
|
|
46
|
+
"else",
|
|
47
|
+
" function fish_prompt",
|
|
48
|
+
" printf '%s> ' (prompt_pwd)",
|
|
49
|
+
` printf "\\e]9998;${instanceTag};READY\\a"`,
|
|
50
|
+
" end",
|
|
51
|
+
"end",
|
|
52
|
+
"",
|
|
53
|
+
"# Redraw binding. fish 4 silently drops \\e[N~ outside the F-key table,",
|
|
54
|
+
"# so we use CSI-u with a private-use codepoint (U+E028) instead.",
|
|
55
|
+
"bind \\e\\[57400u 'commandline -f repaint' 2>/dev/null",
|
|
56
|
+
"bind -M insert \\e\\[57400u 'commandline -f repaint' 2>/dev/null",
|
|
57
|
+
"bind -M default \\e\\[57400u 'commandline -f repaint' 2>/dev/null",
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
fs.writeFileSync(initPath, lines.join("\n") + "\n");
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
args: ["-l", "-i", "-C", `source ${initPath}`],
|
|
64
|
+
envOverrides: {},
|
|
65
|
+
tmpDir,
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
envCaptureCommand(): string {
|
|
70
|
+
// `fish -l` already sources config.fish + conf.d, so no explicit source.
|
|
71
|
+
return "env -0";
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
redrawEscape(): string {
|
|
75
|
+
return "\x1b[57400u";
|
|
76
|
+
},
|
|
77
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { zshStrategy } from "./zsh.js";
|
|
2
|
+
import { bashStrategy } from "./bash.js";
|
|
3
|
+
import { fishStrategy } from "./fish.js";
|
|
4
|
+
import type { ShellStrategy } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export type { ShellStrategy, PrepareSpawnOpts, ShellSpawnConfig } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const STRATEGIES: ShellStrategy[] = [zshStrategy, bashStrategy, fishStrategy];
|
|
9
|
+
|
|
10
|
+
/** Strategy used when the requested shell isn't recognized. */
|
|
11
|
+
export const FALLBACK_STRATEGY: ShellStrategy = bashStrategy;
|
|
12
|
+
|
|
13
|
+
/** Names of supported shells, used for warning messages. */
|
|
14
|
+
export const SUPPORTED_SHELL_NAMES: readonly string[] = STRATEGIES.map((s) => s.name);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Pick the strategy that matches the given shell binary path. Returns null
|
|
18
|
+
* when no strategy claims the path — caller decides whether to warn and how
|
|
19
|
+
* to fall back (e.g. shell.ts swaps the binary to /bin/bash; env capture
|
|
20
|
+
* just runs the fallback strategy's syntax against the original binary).
|
|
21
|
+
*/
|
|
22
|
+
export function pickStrategy(shellPath: string): ShellStrategy | null {
|
|
23
|
+
return STRATEGIES.find((s) => s.matches(shellPath)) ?? null;
|
|
24
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-shell adapter for the bits of agent-sh that are inherently shell-syntax
|
|
3
|
+
* specific: rc-file generation, spawn args/env, env-capture command, and the
|
|
4
|
+
* escape sequence used to repaint the prompt in place.
|
|
5
|
+
*
|
|
6
|
+
* Everything else (PTY I/O, OSC parsing, mute scopes, prompt boundary
|
|
7
|
+
* detection) is shell-agnostic and lives in shell.ts / output-parser.ts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface PrepareSpawnOpts {
|
|
11
|
+
/** Root for mkdtemp — typically os.tmpdir(). */
|
|
12
|
+
tmpDirRoot: string;
|
|
13
|
+
/** Per-instance tag (e.g. "id=abc123") so nested agent-sh hooks don't cross-trigger. */
|
|
14
|
+
instanceTag: string;
|
|
15
|
+
/** Whether to emit the terminal title indicator from the prompt hook. */
|
|
16
|
+
showIndicator: boolean;
|
|
17
|
+
/** Resolved user home (env.HOME ?? os.homedir()). */
|
|
18
|
+
userHome: string;
|
|
19
|
+
/** Inherited env at spawn time — strategies may read ZDOTDIR etc. */
|
|
20
|
+
env: Record<string, string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ShellSpawnConfig {
|
|
24
|
+
/** Args to pass to pty.spawn after the shell binary. */
|
|
25
|
+
args: string[];
|
|
26
|
+
/** Env vars the strategy needs to set on the child (e.g. ZDOTDIR, XDG_CONFIG_HOME). */
|
|
27
|
+
envOverrides: Record<string, string>;
|
|
28
|
+
/** Temp directory the strategy created, if any — caller cleans up on exit. */
|
|
29
|
+
tmpDir?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ShellStrategy {
|
|
33
|
+
/** Short name used for fallback warnings ("zsh", "bash", "fish"). */
|
|
34
|
+
readonly name: string;
|
|
35
|
+
|
|
36
|
+
/** Does this strategy claim the binary at `shellPath`? */
|
|
37
|
+
matches(shellPath: string): boolean;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Generate any rc files and return spawn args + env overrides. May create
|
|
41
|
+
* a tmp directory; caller is responsible for cleanup via the returned path.
|
|
42
|
+
*/
|
|
43
|
+
prepareSpawn(opts: PrepareSpawnOpts): ShellSpawnConfig;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Shell-syntax command run via `<shell> -l -c "<cmd>"` to source the user's
|
|
47
|
+
* config and dump env. Used at startup to inherit shell-only env vars.
|
|
48
|
+
*/
|
|
49
|
+
envCaptureCommand(): string;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Escape sequence to write to the PTY to ask the shell to repaint its
|
|
53
|
+
* prompt in place. The corresponding binding is set up in prepareSpawn.
|
|
54
|
+
* Returns null if the shell can't redraw — caller falls back to freshPrompt.
|
|
55
|
+
*/
|
|
56
|
+
redrawEscape(): string | null;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Strip shell-specific artifacts from raw PTY output before stripAnsi
|
|
60
|
+
* collapses SGR codes (e.g. zsh's PROMPT_SP inverse-video `%`). Default
|
|
61
|
+
* is identity — most shells need no cleanup.
|
|
62
|
+
*/
|
|
63
|
+
cleanOutput?(raw: string): string;
|
|
64
|
+
}
|