agent-sh 0.1.0 → 0.3.0

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.
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Minimal MCP server exposing a `user_shell` tool.
4
+ *
5
+ * Spawned by the ACP agent (pi-acp, claude-agent-acp, etc.) as an MCP
6
+ * stdio server. When the LLM calls `user_shell`, this process connects
7
+ * to agent-sh's Unix socket to execute the command in the user's live
8
+ * PTY shell.
9
+ *
10
+ * Protocol: MCP over stdio (newline-delimited JSON-RPC 2.0).
11
+ * No SDK dependency — the protocol surface is tiny.
12
+ */
13
+ import { createConnection } from "node:net";
14
+ import { createInterface } from "node:readline";
15
+ const SOCKET_PATH = process.env.AGENT_SH_SOCKET;
16
+ // ── MCP protocol helpers ────────────────────────────────────────
17
+ function send(msg) {
18
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...msg }) + "\n");
19
+ }
20
+ function sendResult(id, result) {
21
+ send({ id, result });
22
+ }
23
+ function sendError(id, code, message) {
24
+ send({ id, error: { code, message } });
25
+ }
26
+ // ── Tool definition ─────────────────────────────────────────────
27
+ const SHELL_CWD_TOOL = {
28
+ name: "shell_cwd",
29
+ description: "Get the user's current working directory in their live shell. " +
30
+ "IMPORTANT: Your internal working directory may differ from the user's actual shell cwd — " +
31
+ "the user may have cd'd after your session started. Call this tool to get the real cwd " +
32
+ "before file operations if you're unsure.",
33
+ inputSchema: {
34
+ type: "object",
35
+ properties: {},
36
+ },
37
+ };
38
+ const USER_SHELL_TOOL = {
39
+ name: "user_shell",
40
+ description: "Execute a command in the user's live terminal session. " +
41
+ "Use this for commands that should affect the user's shell state: " +
42
+ "cd, export, source, pushd/popd, alias, etc. " +
43
+ "The command runs in the user's actual shell with their full environment " +
44
+ "(aliases, functions, PATH), not an isolated subprocess. " +
45
+ "NOTE: Your internal cwd may be stale — the user may have cd'd. " +
46
+ "Check the shell context for [shell cwd:...] labels or call shell_cwd " +
47
+ "to determine the real working directory. Use absolute paths when possible.",
48
+ inputSchema: {
49
+ type: "object",
50
+ properties: {
51
+ command: {
52
+ type: "string",
53
+ description: "Shell command to execute in the user's live terminal",
54
+ },
55
+ },
56
+ required: ["command"],
57
+ },
58
+ };
59
+ const SHELL_RECALL_TOOL = {
60
+ name: "shell_recall",
61
+ description: "Retrieve past shell commands, agent responses, and tool executions from the session history. " +
62
+ "Use this to look up truncated output, search for previous commands or errors, " +
63
+ "or browse recent exchanges. Each entry shows [shell cwd:...] so you can see " +
64
+ "which directory commands were run in. Operations: " +
65
+ '"browse" lists recent exchange summaries with line counts, ' +
66
+ '"search" finds exchanges matching a regex query, ' +
67
+ '"expand" retrieves content by exchange ID (use start/end for specific line ranges).',
68
+ inputSchema: {
69
+ type: "object",
70
+ properties: {
71
+ operation: {
72
+ type: "string",
73
+ enum: ["search", "expand", "browse"],
74
+ description: 'Operation to perform (default: "browse")',
75
+ },
76
+ query: {
77
+ type: "string",
78
+ description: 'Search query — supports regex (required for "search" operation)',
79
+ },
80
+ ids: {
81
+ type: "array",
82
+ items: { type: "number" },
83
+ description: 'Exchange IDs to expand (required for "expand" operation)',
84
+ },
85
+ start: {
86
+ type: "number",
87
+ description: "Start line number, 1-indexed (optional, for expand)",
88
+ },
89
+ end: {
90
+ type: "number",
91
+ description: "End line number, inclusive (optional, for expand)",
92
+ },
93
+ },
94
+ },
95
+ };
96
+ // ── agent-sh socket client (JSON-RPC 2.0) ──────────────────────
97
+ let rpcId = 0;
98
+ function callSocket(method, params) {
99
+ return new Promise((resolve, reject) => {
100
+ if (!SOCKET_PATH) {
101
+ reject(new Error("AGENT_SH_SOCKET not set — not running inside agent-sh"));
102
+ return;
103
+ }
104
+ const conn = createConnection(SOCKET_PATH);
105
+ let buffer = "";
106
+ conn.on("connect", () => {
107
+ const msg = { jsonrpc: "2.0", id: ++rpcId, method, params: params ?? {} };
108
+ conn.write(JSON.stringify(msg) + "\n");
109
+ });
110
+ conn.on("data", (chunk) => {
111
+ buffer += chunk.toString();
112
+ const newlineIdx = buffer.indexOf("\n");
113
+ if (newlineIdx === -1)
114
+ return;
115
+ const line = buffer.slice(0, newlineIdx).trim();
116
+ conn.destroy();
117
+ try {
118
+ const response = JSON.parse(line);
119
+ if (response.error) {
120
+ reject(new Error(response.error.message || "RPC error"));
121
+ }
122
+ else {
123
+ resolve(response.result);
124
+ }
125
+ }
126
+ catch {
127
+ reject(new Error(`Invalid response from agent-sh: ${line}`));
128
+ }
129
+ });
130
+ conn.on("error", (err) => {
131
+ reject(new Error(`Failed to connect to agent-sh: ${err.message}`));
132
+ });
133
+ conn.setTimeout(35_000, () => {
134
+ conn.destroy();
135
+ reject(new Error("Connection to agent-sh timed out"));
136
+ });
137
+ });
138
+ }
139
+ // ── Request handler ─────────────────────────────────────────────
140
+ async function handleRequest(id, method, params) {
141
+ switch (method) {
142
+ case "initialize":
143
+ sendResult(id, {
144
+ protocolVersion: params?.protocolVersion ?? "2024-11-05",
145
+ capabilities: { tools: {} },
146
+ serverInfo: { name: "agent-sh-shell", version: "0.1.0" },
147
+ });
148
+ break;
149
+ case "notifications/initialized":
150
+ // Client acknowledgement — nothing to do
151
+ break;
152
+ case "tools/list":
153
+ sendResult(id, { tools: [SHELL_CWD_TOOL, USER_SHELL_TOOL, SHELL_RECALL_TOOL] });
154
+ break;
155
+ case "tools/call": {
156
+ const toolName = params?.name;
157
+ const args = params?.arguments ?? {};
158
+ try {
159
+ let text;
160
+ if (toolName === "shell_cwd") {
161
+ const result = await callSocket("shell/cwd", {});
162
+ text = `User's current working directory: ${result.cwd}`;
163
+ }
164
+ else if (toolName === "user_shell") {
165
+ const command = args.command;
166
+ if (!command || typeof command !== "string") {
167
+ sendError(id, -32602, "Missing required parameter: command");
168
+ return;
169
+ }
170
+ const result = await callSocket("shell/exec", { command });
171
+ text = result.output || "(no output)";
172
+ }
173
+ else if (toolName === "shell_recall") {
174
+ const result = await callSocket("shell/recall", {
175
+ operation: args.operation || "browse",
176
+ query: args.query,
177
+ ids: args.ids,
178
+ start: args.start,
179
+ end: args.end,
180
+ });
181
+ text = result.result || "(no results)";
182
+ }
183
+ else {
184
+ sendError(id, -32602, `Unknown tool: ${toolName}`);
185
+ return;
186
+ }
187
+ sendResult(id, {
188
+ content: [{ type: "text", text }],
189
+ });
190
+ }
191
+ catch (err) {
192
+ sendResult(id, {
193
+ content: [
194
+ {
195
+ type: "text",
196
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`,
197
+ },
198
+ ],
199
+ isError: true,
200
+ });
201
+ }
202
+ break;
203
+ }
204
+ default:
205
+ // Unknown methods: return method-not-found for requests (those with id)
206
+ if (id !== undefined && id !== null) {
207
+ sendError(id, -32601, `Method not found: ${method}`);
208
+ }
209
+ break;
210
+ }
211
+ }
212
+ // ── Main loop ───────────────────────────────────────────────────
213
+ const rl = createInterface({ input: process.stdin });
214
+ rl.on("line", (line) => {
215
+ if (!line.trim())
216
+ return;
217
+ try {
218
+ const msg = JSON.parse(line);
219
+ const { id, method, params } = msg;
220
+ if (method) {
221
+ handleRequest(id, method, params).catch((err) => {
222
+ if (id !== undefined && id !== null) {
223
+ sendError(id, -32603, String(err));
224
+ }
225
+ });
226
+ }
227
+ }
228
+ catch {
229
+ // Malformed JSON — ignore
230
+ }
231
+ });
232
+ rl.on("close", () => {
233
+ process.exit(0);
234
+ });
@@ -9,24 +9,14 @@ export declare class OutputParser {
9
9
  private currentOutputCapture;
10
10
  private lastCommand;
11
11
  private foregroundBusy;
12
- private capturingPrompt;
13
- private promptCaptureComplete;
14
- private promptBuffer;
15
- private lastPrompt;
12
+ private promptReady;
16
13
  constructor(bus: EventBus, initialCwd: string);
17
14
  /** Process a chunk of PTY output data. */
18
15
  processData(data: string): void;
19
16
  /** Called when user presses Enter on a non-empty line. */
20
17
  onCommandEntered(command: string, cwd: string): void;
21
- /** Returns the full captured prompt bytes, or empty if incomplete. */
22
- getLastPrompt(): string;
23
- /**
24
- * Returns just the last line of the captured prompt (e.g. p10k's "❯ " line).
25
- * This is safe to replay with \r because it's linear text (colors + chars),
26
- * not relative cursor positioning. Returns empty if no complete capture.
27
- */
28
- getLastPromptLine(): string;
29
- isPromptCaptureComplete(): boolean;
18
+ /** Whether the shell's prompt is fully rendered and ready for input. */
19
+ isPromptReady(): boolean;
30
20
  isForegroundBusy(): boolean;
31
21
  getCwd(): string;
32
22
  private parseOSC7;
@@ -36,20 +26,9 @@ export declare class OutputParser {
36
26
  */
37
27
  private parsePromptMarker;
38
28
  /**
39
- * Detect end-of-prompt marker (OSC 9998). Finalizes the bracketed capture.
40
- *
41
- * By the time this runs, the current chunk has already been appended to
42
- * promptBuffer (either by parsePromptMarker for the first chunk, or by
43
- * the wasCapturing guard in processData for subsequent chunks). So we
44
- * just need to trim everything from the end marker onward.
29
+ * Detect end-of-prompt marker (OSC 9998). The prompt is fully rendered
30
+ * and the shell is ready for input.
45
31
  */
46
32
  private parsePromptEnd;
47
- /**
48
- * Strip internal OSC markers from captured prompt so replay is clean.
49
- * We intentionally strip all OSC 7 sequences — they're used for cwd
50
- * reporting and have no visual effect, so replaying them would just
51
- * cause duplicate cwd-change events.
52
- */
53
- private sanitizePromptForReplay;
54
33
  private removeEchoedCommand;
55
34
  }
@@ -9,10 +9,7 @@ export class OutputParser {
9
9
  currentOutputCapture = "";
10
10
  lastCommand = "";
11
11
  foregroundBusy = false;
12
- capturingPrompt = false;
13
- promptCaptureComplete = false;
14
- promptBuffer = "";
15
- lastPrompt = "";
12
+ promptReady = false;
16
13
  constructor(bus, initialCwd) {
17
14
  this.bus = bus;
18
15
  this.cwd = initialCwd;
@@ -20,19 +17,7 @@ export class OutputParser {
20
17
  /** Process a chunk of PTY output data. */
21
18
  processData(data) {
22
19
  this.parseOSC7(data);
23
- // Bracketed prompt capture: accumulate bytes between OSC 9999 and 9998.
24
- // parsePromptMarker may start capture (setting promptBuffer to the tail
25
- // of the current chunk), so we only append subsequent chunks here.
26
- const wasCapturing = this.capturingPrompt;
27
20
  this.parsePromptMarker(data);
28
- // If we were already capturing before this chunk (and still are), append
29
- // the full chunk. If capture just started in parsePromptMarker above, the
30
- // tail after the start marker is already in promptBuffer — don't double-add.
31
- if (wasCapturing && this.capturingPrompt) {
32
- this.promptBuffer += data;
33
- }
34
- // Check for end marker. Must run after the append above so that
35
- // multi-chunk captures include this chunk's data before we finalize.
36
21
  this.parsePromptEnd(data);
37
22
  }
38
23
  /** Called when user presses Enter on a non-empty line. */
@@ -45,28 +30,9 @@ export class OutputParser {
45
30
  this.bus.emit("shell:foreground-busy", { busy: true });
46
31
  }
47
32
  }
48
- /** Returns the full captured prompt bytes, or empty if incomplete. */
49
- getLastPrompt() {
50
- if (!this.promptCaptureComplete)
51
- return "";
52
- return this.lastPrompt;
53
- }
54
- /**
55
- * Returns just the last line of the captured prompt (e.g. p10k's "❯ " line).
56
- * This is safe to replay with \r because it's linear text (colors + chars),
57
- * not relative cursor positioning. Returns empty if no complete capture.
58
- */
59
- getLastPromptLine() {
60
- if (!this.promptCaptureComplete)
61
- return "";
62
- // Find the last \r\n or \n — everything after it is the final prompt line
63
- const lastNewline = this.lastPrompt.lastIndexOf("\n");
64
- if (lastNewline < 0)
65
- return this.lastPrompt;
66
- return this.lastPrompt.slice(lastNewline + 1);
67
- }
68
- isPromptCaptureComplete() {
69
- return this.promptCaptureComplete;
33
+ /** Whether the shell's prompt is fully rendered and ready for input. */
34
+ isPromptReady() {
35
+ return this.promptReady;
70
36
  }
71
37
  isForegroundBusy() {
72
38
  return this.foregroundBusy;
@@ -90,7 +56,14 @@ export class OutputParser {
90
56
  * Each time a prompt appears, we finalize the previous command's output.
91
57
  */
92
58
  parsePromptMarker(data) {
93
- if (data.includes("\x1b]9999;PROMPT\x07")) {
59
+ const marker = "\x1b]9999;PROMPT\x07";
60
+ const markerIdx = data.indexOf(marker);
61
+ if (markerIdx !== -1) {
62
+ // Capture any output that arrived in the same chunk before the marker
63
+ if (markerIdx > 0) {
64
+ this.currentOutputCapture += data.slice(0, markerIdx);
65
+ }
66
+ this.promptReady = false;
94
67
  if (this.foregroundBusy) {
95
68
  this.foregroundBusy = false;
96
69
  this.bus.emit("shell:foreground-busy", { busy: false });
@@ -107,54 +80,19 @@ export class OutputParser {
107
80
  }
108
81
  this.lastCommand = "";
109
82
  this.currentOutputCapture = "";
110
- // Start bracketed prompt capture: accumulate bytes until OSC 9998
111
- this.capturingPrompt = true;
112
- this.promptCaptureComplete = false;
113
- this.promptBuffer = "";
114
- const markerEnd = data.indexOf("\x1b]9999;PROMPT\x07") + "\x1b]9999;PROMPT\x07".length;
115
- if (markerEnd < data.length) {
116
- this.promptBuffer = data.slice(markerEnd);
117
- }
118
83
  }
119
84
  else {
120
85
  this.currentOutputCapture += data;
121
86
  }
122
87
  }
123
88
  /**
124
- * Detect end-of-prompt marker (OSC 9998). Finalizes the bracketed capture.
125
- *
126
- * By the time this runs, the current chunk has already been appended to
127
- * promptBuffer (either by parsePromptMarker for the first chunk, or by
128
- * the wasCapturing guard in processData for subsequent chunks). So we
129
- * just need to trim everything from the end marker onward.
89
+ * Detect end-of-prompt marker (OSC 9998). The prompt is fully rendered
90
+ * and the shell is ready for input.
130
91
  */
131
92
  parsePromptEnd(data) {
132
- if (!this.capturingPrompt)
133
- return;
134
- if (!data.includes("\x1b]9998;READY\x07"))
135
- return;
136
- // promptBuffer already contains this chunk's data. Find the end marker
137
- // within the buffer and trim everything from it onward.
138
- const endMarker = "\x1b]9998;READY\x07";
139
- const bufEndIdx = this.promptBuffer.indexOf(endMarker);
140
- if (bufEndIdx >= 0) {
141
- this.promptBuffer = this.promptBuffer.slice(0, bufEndIdx);
93
+ if (data.includes("\x1b]9998;READY\x07")) {
94
+ this.promptReady = true;
142
95
  }
143
- this.capturingPrompt = false;
144
- this.promptCaptureComplete = true;
145
- this.lastPrompt = this.sanitizePromptForReplay(this.promptBuffer);
146
- }
147
- /**
148
- * Strip internal OSC markers from captured prompt so replay is clean.
149
- * We intentionally strip all OSC 7 sequences — they're used for cwd
150
- * reporting and have no visual effect, so replaying them would just
151
- * cause duplicate cwd-change events.
152
- */
153
- sanitizePromptForReplay(raw) {
154
- return raw
155
- .replace(/\x1b\]7;[^\x07]*\x07/g, "") // OSC 7 (cwd reporting)
156
- .replace(/\x1b\]9999;PROMPT\x07/g, "") // start marker
157
- .replace(/\x1b\]9998;READY\x07/g, ""); // end marker
158
96
  }
159
97
  removeEchoedCommand(output, command) {
160
98
  const lines = output.split("\n");
@@ -0,0 +1,33 @@
1
+ export declare const CONFIG_DIR: string;
2
+ export interface Settings {
3
+ /** Extensions to load (npm packages or file paths). */
4
+ extensions?: string[];
5
+ /** Max agent query history entries to keep. */
6
+ historySize?: number;
7
+ /** Recent exchanges included in agent context window. */
8
+ contextWindowSize?: number;
9
+ /** Context budget in bytes (~4 chars per token). */
10
+ contextBudget?: number;
11
+ /** Shell output lines before truncation kicks in. */
12
+ shellTruncateThreshold?: number;
13
+ /** Lines kept from start of truncated shell output. */
14
+ shellHeadLines?: number;
15
+ /** Lines kept from end of truncated shell output. */
16
+ shellTailLines?: number;
17
+ /** Max lines for recall expand before requiring line ranges. */
18
+ recallExpandMaxLines?: number;
19
+ /** Max command output lines shown inline in TUI. */
20
+ maxCommandOutputLines?: number;
21
+ /** Max read tool output lines shown inline in TUI (0 = hide). */
22
+ readOutputMaxLines?: number;
23
+ /** Max diff lines shown before "ctrl+o to expand". */
24
+ diffMaxLines?: number;
25
+ /** Register MCP server for bridge tools (shell_cwd, user_shell, shell_recall). Default true. */
26
+ enableMcp?: boolean;
27
+ }
28
+ declare const DEFAULTS: Required<Settings>;
29
+ /** Load settings from disk (cached after first call). */
30
+ export declare function getSettings(): Settings & typeof DEFAULTS;
31
+ /** Reset cached settings (for testing or after external edit). */
32
+ export declare function reloadSettings(): void;
33
+ export {};
@@ -0,0 +1,43 @@
1
+ /**
2
+ * User settings loaded from ~/.agent-sh/settings.json.
3
+ *
4
+ * Settings are loaded once at startup and available synchronously
5
+ * throughout the app. Unknown keys are preserved on write.
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import * as os from "node:os";
10
+ export const CONFIG_DIR = path.join(os.homedir(), ".agent-sh");
11
+ const SETTINGS_PATH = path.join(CONFIG_DIR, "settings.json");
12
+ const DEFAULTS = {
13
+ extensions: [],
14
+ historySize: 500,
15
+ contextWindowSize: 20,
16
+ contextBudget: 16384,
17
+ shellTruncateThreshold: 10,
18
+ shellHeadLines: 5,
19
+ shellTailLines: 5,
20
+ recallExpandMaxLines: 100,
21
+ maxCommandOutputLines: 5,
22
+ readOutputMaxLines: 0,
23
+ diffMaxLines: 20,
24
+ enableMcp: true,
25
+ };
26
+ let cached = null;
27
+ /** Load settings from disk (cached after first call). */
28
+ export function getSettings() {
29
+ if (!cached) {
30
+ try {
31
+ const raw = fs.readFileSync(SETTINGS_PATH, "utf-8");
32
+ cached = JSON.parse(raw);
33
+ }
34
+ catch {
35
+ cached = {};
36
+ }
37
+ }
38
+ return { ...DEFAULTS, ...cached };
39
+ }
40
+ /** Reset cached settings (for testing or after external edit). */
41
+ export function reloadSettings() {
42
+ cached = null;
43
+ }
package/dist/shell.d.ts CHANGED
@@ -6,7 +6,9 @@ export declare class Shell implements InputContext {
6
6
  private inputHandler;
7
7
  private outputParser;
8
8
  private paused;
9
+ private echoSkip;
9
10
  private agentActive;
11
+ private isZsh;
10
12
  private tmpDir?;
11
13
  constructor(opts: {
12
14
  bus: EventBus;
@@ -24,10 +26,11 @@ export declare class Shell implements InputContext {
24
26
  isAgentActive(): boolean;
25
27
  writeToPty(data: string): void;
26
28
  /**
27
- * Lightweight redraw: replay just the last line of the shell's prompt
28
- * (e.g. p10k's "❯ "). This works because agent input mode only overwrites
29
- * the final prompt linethe path bar above is still intact. The last
30
- * line is linear text (colors + chars + clear-to-end), no cursor positioning.
29
+ * Lightweight redraw: ask the shell to redraw its own prompt via a hidden
30
+ * ZLE widget (zsh) bound to \e[9999~. The shell knows how to draw its
31
+ * prompt correctlywe don't try to replay captured bytes.
32
+ *
33
+ * For bash, falls back to sending \n for a fresh prompt cycle.
31
34
  */
32
35
  redrawPrompt(): void;
33
36
  /**
@@ -45,6 +48,8 @@ export declare class Shell implements InputContext {
45
48
  * zero frontend knowledge; any frontend can subscribe to the same events.
46
49
  */
47
50
  private setupAgentLifecycle;
51
+ /** Temp directory used for shell config and sockets. */
52
+ getTmpDir(): string | undefined;
48
53
  resize(cols: number, rows: number): void;
49
54
  onExit(callback: (e: {
50
55
  exitCode: number;