agent-sh 0.1.0 → 0.2.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.
@@ -32,10 +32,12 @@ export class AcpClient {
32
32
  }
33
33
  async start() {
34
34
  this.log(`Starting agent: ${this.config.agentCommand} ${this.config.agentArgs.join(" ")}`);
35
- // Spawn the agent subprocess
36
- // Spawn the agent wait briefly to catch ENOENT and other spawn errors
35
+ // Spawn the agent subprocess with the user's full shell environment
36
+ // (includes vars from .zshrc/.bashrc that process.env may not have)
37
+ const agentEnv = this.config.shellEnv ?? process.env;
37
38
  this.agentProcess = spawn(this.config.agentCommand, this.config.agentArgs, {
38
39
  stdio: ["pipe", "pipe", process.env.DEBUG ? "inherit" : "ignore"],
40
+ env: agentEnv,
39
41
  });
40
42
  // Catch spawn errors (ENOENT, EACCES, etc.) before proceeding
41
43
  await new Promise((resolve, reject) => {
@@ -87,13 +89,17 @@ export class AcpClient {
87
89
  };
88
90
  this.log(`Agent info: ${this.agentInfo.name} v${this.agentInfo.version}`);
89
91
  }
90
- // Create a session
92
+ // Create a session — let extensions add MCP servers via pipe
91
93
  const cwd = this.contextManager.getCwd();
92
94
  this.log(`Creating new session with cwd: ${cwd}`);
93
- const sessionResponse = await this.connection.newSession({
95
+ const sessionConfig = this.bus.emitPipe("session:configure", {
94
96
  cwd,
95
97
  mcpServers: [],
96
98
  });
99
+ const sessionResponse = await this.connection.newSession({
100
+ cwd: sessionConfig.cwd,
101
+ mcpServers: sessionConfig.mcpServers,
102
+ });
97
103
  this.sessionId = sessionResponse.sessionId;
98
104
  this.log(`Session created: ${this.sessionId}`);
99
105
  }
@@ -185,10 +191,14 @@ export class AcpClient {
185
191
  async resetSession() {
186
192
  if (!this.connection)
187
193
  return;
188
- const sessionResponse = await this.connection.newSession({
194
+ const sessionConfig = this.bus.emitPipe("session:configure", {
189
195
  cwd: this.contextManager.getCwd(),
190
196
  mcpServers: [],
191
197
  });
198
+ const sessionResponse = await this.connection.newSession({
199
+ cwd: sessionConfig.cwd,
200
+ mcpServers: sessionConfig.mcpServers,
201
+ });
192
202
  this.sessionId = sessionResponse.sessionId;
193
203
  this.lastResponseText = "";
194
204
  this.currentResponseText = "";
@@ -282,23 +292,39 @@ export class AcpClient {
282
292
  break;
283
293
  }
284
294
  case "tool_call": {
285
- // Use toolCallId if available, otherwise generate a simple ID
286
295
  const toolId = update.toolCallId || `tool-${this.pendingToolCounter++}`;
287
296
  this.pendingToolCalls.set(toolId, true);
288
- this.bus.emit("agent:tool-started", { title: update.title, toolCallId: toolId });
297
+ this.bus.emit("agent:tool-started", {
298
+ title: update.title,
299
+ toolCallId: toolId,
300
+ kind: update.kind ?? undefined,
301
+ locations: update.locations?.map((l) => ({ path: l.path, line: l.line })),
302
+ rawInput: update.rawInput,
303
+ });
289
304
  break;
290
305
  }
291
306
  case "tool_call_update": {
292
- // Only show result when the tool completes, don't show tool call again
307
+ // Stream tool output content (text from pi's internal tool results)
308
+ if (update.content && Array.isArray(update.content)) {
309
+ for (const block of update.content) {
310
+ if (block.type === "content" && block.content?.type === "text" && block.content.text) {
311
+ this.bus.emit("agent:tool-output-chunk", { chunk: block.content.text });
312
+ }
313
+ }
314
+ }
293
315
  if (update.status === "completed" || update.status === "failed") {
294
316
  const toolId = update.toolCallId;
295
317
  const exitCode = update.status === "completed" ? 0 : 1;
296
318
  if (toolId && this.pendingToolCalls.has(toolId)) {
297
319
  this.pendingToolCalls.delete(toolId);
298
- this.bus.emit("agent:tool-completed", { toolCallId: toolId, exitCode });
320
+ this.bus.emit("agent:tool-completed", {
321
+ toolCallId: toolId,
322
+ exitCode,
323
+ rawOutput: update.rawOutput,
324
+ });
299
325
  }
300
326
  else if (!toolId) {
301
- this.bus.emit("agent:tool-completed", { exitCode });
327
+ this.bus.emit("agent:tool-completed", { exitCode, rawOutput: update.rawOutput });
302
328
  }
303
329
  }
304
330
  break;
@@ -363,6 +389,7 @@ export class AcpClient {
363
389
  const { session, done } = executeCommand({
364
390
  command: fullCommand,
365
391
  cwd,
392
+ env: this.config.shellEnv,
366
393
  timeout: 60_000,
367
394
  maxOutputBytes: 256 * 1024,
368
395
  onOutput: (chunk) => {
@@ -25,7 +25,7 @@ export declare class ContextManager {
25
25
  */
26
26
  getRecentSummary(n?: number): string;
27
27
  /**
28
- * Parse and handle __shell_recall commands.
28
+ * Parse and handle shell_recall commands.
29
29
  */
30
30
  handleRecallCommand(command: string): string;
31
31
  /**
@@ -165,21 +165,21 @@ export class ContextManager {
165
165
  return recent.map((ex) => this.exchangeOneLiner(ex)).join("\n");
166
166
  }
167
167
  /**
168
- * Parse and handle __shell_recall commands.
168
+ * Parse and handle shell_recall commands.
169
169
  */
170
170
  handleRecallCommand(command) {
171
- const args = command.replace(/^__shell_recall\s*/, "").trim();
171
+ const args = command.replace(/^_*shell_recall\s*/, "").trim();
172
172
  if (!args || args === "--help") {
173
173
  return [
174
174
  "Usage:",
175
- " __shell_recall Browse recent exchanges",
176
- " __shell_recall --search <query> Search all exchanges",
177
- " __shell_recall --expand <id,...> Show full content of exchanges",
175
+ " shell_recall Browse recent exchanges",
176
+ " shell_recall --search <query> Search all exchanges",
177
+ " shell_recall --expand <id,...> Show full content of exchanges",
178
178
  "",
179
179
  "Examples:",
180
- ' __shell_recall --search "test fail"',
181
- " __shell_recall --expand 41",
182
- " __shell_recall --expand 41,42,43",
180
+ ' shell_recall --search "test fail"',
181
+ " shell_recall --expand 41",
182
+ " shell_recall --expand 41,42,43",
183
183
  ].join("\n");
184
184
  }
185
185
  const searchMatch = args.match(/^--search\s+(?:"([^"]+)"|(\S+))/);
@@ -232,13 +232,13 @@ export class ContextManager {
232
232
  const ex = result[i];
233
233
  const before = this.exchangeSize(ex);
234
234
  if (ex.type === "shell_command") {
235
- ex.output = `[output omitted, use __shell_recall --expand ${ex.id}]`;
235
+ ex.output = `[output omitted, use shell_recall tool to expand id ${ex.id}]`;
236
236
  }
237
237
  else if (ex.type === "tool_execution") {
238
- ex.output = `[output omitted, use __shell_recall --expand ${ex.id}]`;
238
+ ex.output = `[output omitted, use shell_recall tool to expand id ${ex.id}]`;
239
239
  }
240
240
  else if (ex.type === "agent_response") {
241
- ex.response = `[response omitted, use __shell_recall --expand ${ex.id}]`;
241
+ ex.response = `[response omitted, use shell_recall tool to expand id ${ex.id}]`;
242
242
  }
243
243
  totalSize -= before - this.exchangeSize(ex);
244
244
  }
@@ -250,7 +250,7 @@ export class ContextManager {
250
250
  let out = "<shell_context>\n";
251
251
  out += `cwd: ${this.currentCwd}\n`;
252
252
  out += `session: ${totalCount} exchanges, ${elapsed}m elapsed\n`;
253
- out += `[hint: run \`__shell_recall --search "query"\` or \`__shell_recall --expand ID\` to retrieve truncated content]\n`;
253
+ out += `[hint: use the shell_recall tool to retrieve truncated content search(query) or expand(ids)]\n`;
254
254
  for (const ex of exchanges) {
255
255
  out += "\n" + this.formatExchangeTruncated(ex);
256
256
  }
@@ -381,7 +381,7 @@ function truncateOutput(text, threshold, headLines, tailLines, id) {
381
381
  const omitted = lines.length - headLines - tailLines;
382
382
  return [
383
383
  ...lines.slice(0, headLines),
384
- `[... ${omitted} lines truncated, use __shell_recall --expand ${id} to see full output ...]`,
384
+ `[... ${omitted} lines truncated, use shell_recall tool with expand and id ${id} to see full output ...]`,
385
385
  ...lines.slice(-tailLines),
386
386
  ].join("\n");
387
387
  }
@@ -391,7 +391,7 @@ function truncateHead(text, threshold, headLines, id) {
391
391
  return text;
392
392
  return [
393
393
  ...lines.slice(0, headLines),
394
- `[... truncated, use __shell_recall --expand ${id} for full response ...]`,
394
+ `[... truncated, use shell_recall tool with expand and id ${id} for full response ...]`,
395
395
  ].join("\n");
396
396
  }
397
397
  function indent(text, prefix) {
@@ -52,10 +52,17 @@ export interface ShellEvents {
52
52
  "agent:tool-started": {
53
53
  title: string;
54
54
  toolCallId?: string;
55
+ kind?: string;
56
+ locations?: {
57
+ path: string;
58
+ line?: number | null;
59
+ }[];
60
+ rawInput?: unknown;
55
61
  };
56
62
  "agent:tool-completed": {
57
63
  toolCallId?: string;
58
64
  exitCode: number | null;
65
+ rawOutput?: unknown;
59
66
  };
60
67
  "agent:tool-output-chunk": {
61
68
  chunk: string;
@@ -89,6 +96,24 @@ export interface ShellEvents {
89
96
  cwd: string;
90
97
  handled: boolean;
91
98
  };
99
+ "shell:exec-request": {
100
+ command: string;
101
+ output: string;
102
+ cwd: string;
103
+ done: boolean;
104
+ };
105
+ "session:configure": {
106
+ cwd: string;
107
+ mcpServers: {
108
+ name: string;
109
+ command: string;
110
+ args: string[];
111
+ env: {
112
+ name: string;
113
+ value: string;
114
+ }[];
115
+ }[];
116
+ };
92
117
  "autocomplete:request": {
93
118
  buffer: string;
94
119
  items: {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Shell exec extension.
3
+ *
4
+ * Runs a Unix domain socket server speaking JSON-RPC 2.0 that external
5
+ * tools (MCP server, pi extensions, etc.) connect to for interacting
6
+ * with the user's live PTY shell.
7
+ *
8
+ * Also registers the MCP server via the `session:configure` pipe so
9
+ * ACP agents discover the `user_shell` tool automatically.
10
+ *
11
+ * This extension has no direct PTY or Shell knowledge — it communicates
12
+ * exclusively through the bus, following the headless-core philosophy.
13
+ *
14
+ * ## Socket protocol (JSON-RPC 2.0, newline-delimited)
15
+ *
16
+ * shell/exec { command: string } → { output, cwd }
17
+ * shell/cwd {} → { cwd }
18
+ * shell/info {} → { busy, shell }
19
+ * shell/recall { operation, ... } → { result }
20
+ */
21
+ import type { ExtensionContext } from "../types.js";
22
+ export default function activate({ bus, contextManager }: ExtensionContext, opts: {
23
+ socketPath: string;
24
+ }): void;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Shell exec extension.
3
+ *
4
+ * Runs a Unix domain socket server speaking JSON-RPC 2.0 that external
5
+ * tools (MCP server, pi extensions, etc.) connect to for interacting
6
+ * with the user's live PTY shell.
7
+ *
8
+ * Also registers the MCP server via the `session:configure` pipe so
9
+ * ACP agents discover the `user_shell` tool automatically.
10
+ *
11
+ * This extension has no direct PTY or Shell knowledge — it communicates
12
+ * exclusively through the bus, following the headless-core philosophy.
13
+ *
14
+ * ## Socket protocol (JSON-RPC 2.0, newline-delimited)
15
+ *
16
+ * shell/exec { command: string } → { output, cwd }
17
+ * shell/cwd {} → { cwd }
18
+ * shell/info {} → { busy, shell }
19
+ * shell/recall { operation, ... } → { result }
20
+ */
21
+ import * as net from "node:net";
22
+ import * as fs from "node:fs";
23
+ import * as path from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
+ export default function activate({ bus, contextManager }, opts) {
27
+ const { socketPath } = opts;
28
+ // Register MCP server so ACP agents discover the user_shell tool
29
+ bus.onPipe("session:configure", (payload) => {
30
+ return {
31
+ ...payload,
32
+ mcpServers: [
33
+ ...payload.mcpServers,
34
+ {
35
+ name: "agent-sh",
36
+ command: process.execPath,
37
+ args: [path.join(__dirname, "..", "mcp-server.js")],
38
+ env: [{ name: "AGENT_SH_SOCKET", value: socketPath }],
39
+ },
40
+ ],
41
+ };
42
+ });
43
+ // Also set AGENT_SH_SOCKET for pi extensions that connect directly
44
+ process.env.AGENT_SH_SOCKET = socketPath;
45
+ // Serialize shell/exec requests — only one PTY command at a time
46
+ let execPending = Promise.resolve();
47
+ // ── JSON-RPC handler ────────────────────────────────────────────
48
+ async function handleRequest(method, params) {
49
+ switch (method) {
50
+ case "shell/exec": {
51
+ const command = params?.command;
52
+ if (typeof command !== "string" || !command) {
53
+ throw rpcError(-32602, "Missing required parameter: command");
54
+ }
55
+ // Serialize — one PTY command at a time
56
+ return new Promise((resolve, reject) => {
57
+ execPending = execPending.then(async () => {
58
+ try {
59
+ const result = await bus.emitPipeAsync("shell:exec-request", {
60
+ command,
61
+ output: "",
62
+ cwd: "",
63
+ done: false,
64
+ });
65
+ // Show the command output in the TUI
66
+ if (result.output) {
67
+ bus.emit("agent:tool-output-chunk", { chunk: result.output });
68
+ }
69
+ resolve({ output: result.output, cwd: result.cwd });
70
+ }
71
+ catch (err) {
72
+ const message = err instanceof Error ? err.message : String(err);
73
+ bus.emit("agent:tool-output-chunk", { chunk: `Error: ${message}` });
74
+ reject(rpcError(-32000, message));
75
+ }
76
+ });
77
+ });
78
+ }
79
+ case "shell/cwd":
80
+ return { cwd: contextManager.getCwd() };
81
+ case "shell/info":
82
+ return {
83
+ shell: process.env.SHELL || "unknown",
84
+ agentSh: true,
85
+ };
86
+ case "shell/recall": {
87
+ const operation = params?.operation || "browse";
88
+ switch (operation) {
89
+ case "search": {
90
+ const query = params?.query;
91
+ if (typeof query !== "string" || !query) {
92
+ throw rpcError(-32602, "Missing required parameter: query");
93
+ }
94
+ return { result: contextManager.search(query) };
95
+ }
96
+ case "expand": {
97
+ const ids = params?.ids;
98
+ if (!Array.isArray(ids) || ids.length === 0) {
99
+ throw rpcError(-32602, "Missing required parameter: ids (array of numbers)");
100
+ }
101
+ return { result: contextManager.expand(ids.map(Number)) };
102
+ }
103
+ case "browse":
104
+ return { result: contextManager.getRecentSummary() };
105
+ default:
106
+ throw rpcError(-32602, `Unknown recall operation: ${operation}`);
107
+ }
108
+ }
109
+ default:
110
+ throw rpcError(-32601, `Method not found: ${method}`);
111
+ }
112
+ }
113
+ // ── Socket server ───────────────────────────────────────────────
114
+ const server = net.createServer((conn) => {
115
+ let buffer = "";
116
+ conn.on("data", (chunk) => {
117
+ buffer += chunk.toString();
118
+ // Process complete lines (newline-delimited JSON-RPC)
119
+ let newlineIdx;
120
+ while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
121
+ const line = buffer.slice(0, newlineIdx).trim();
122
+ buffer = buffer.slice(newlineIdx + 1);
123
+ if (!line)
124
+ continue;
125
+ processMessage(conn, line);
126
+ }
127
+ });
128
+ });
129
+ function processMessage(conn, line) {
130
+ let id = null;
131
+ try {
132
+ const msg = JSON.parse(line);
133
+ id = msg.id ?? null;
134
+ const method = msg.method;
135
+ if (!method) {
136
+ sendError(conn, id, -32600, "Invalid request: missing method");
137
+ return;
138
+ }
139
+ handleRequest(method, msg.params)
140
+ .then((result) => sendResult(conn, id, result))
141
+ .catch((err) => {
142
+ if (err && typeof err === "object" && "rpcCode" in err) {
143
+ sendError(conn, id, err.rpcCode, err.message);
144
+ }
145
+ else {
146
+ sendError(conn, id, -32603, String(err));
147
+ }
148
+ });
149
+ }
150
+ catch {
151
+ sendError(conn, id, -32700, "Parse error");
152
+ }
153
+ }
154
+ // Clean up stale socket file
155
+ try {
156
+ fs.unlinkSync(socketPath);
157
+ }
158
+ catch {
159
+ // Doesn't exist — fine
160
+ }
161
+ server.listen(socketPath);
162
+ // Cleanup on exit
163
+ const cleanup = () => {
164
+ server.close();
165
+ try {
166
+ fs.unlinkSync(socketPath);
167
+ }
168
+ catch { }
169
+ };
170
+ process.on("exit", cleanup);
171
+ }
172
+ // ── JSON-RPC helpers ──────────────────────────────────────────────
173
+ function sendResult(conn, id, result) {
174
+ conn.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
175
+ }
176
+ function sendError(conn, id, code, message) {
177
+ conn.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\n");
178
+ }
179
+ function rpcError(code, message) {
180
+ const err = new Error(message);
181
+ err.rpcCode = code;
182
+ return err;
183
+ }
@@ -62,7 +62,7 @@ export default function activate({ bus }) {
62
62
  });
63
63
  bus.on("agent:tool-started", (e) => {
64
64
  stopCurrentSpinner();
65
- showToolCall(e.title, lastCommand);
65
+ showToolCall(e.title, lastCommand, e);
66
66
  lastCommand = "";
67
67
  });
68
68
  bus.on("agent:tool-completed", (e) => showToolComplete(e.exitCode));
@@ -169,13 +169,19 @@ export default function activate({ bus }) {
169
169
  renderer.push(text);
170
170
  flushOutput();
171
171
  }
172
- function showToolCall(title, command) {
172
+ function showToolCall(title, command, extra) {
173
173
  stopCurrentSpinner();
174
174
  if (!renderer)
175
175
  startAgentResponse();
176
176
  renderer.flush();
177
177
  const termW = process.stdout.columns || 80;
178
- const lines = renderToolCall({ title, command: command || undefined }, termW);
178
+ const lines = renderToolCall({
179
+ title,
180
+ command: command || undefined,
181
+ kind: extra?.kind,
182
+ locations: extra?.locations,
183
+ rawInput: extra?.rawInput,
184
+ }, termW);
179
185
  for (const line of lines) {
180
186
  renderer.writeLine(line);
181
187
  }
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
2
3
  import { Shell } from "./shell.js";
3
4
  import { createCore } from "./core.js";
4
5
  import { palette as p } from "./utils/palette.js";
@@ -6,7 +7,39 @@ import tuiRenderer from "./extensions/tui-renderer.js";
6
7
  import slashCommands from "./extensions/slash-commands.js";
7
8
  import fileAutocomplete from "./extensions/file-autocomplete.js";
8
9
  import shellRecall from "./extensions/shell-recall.js";
10
+ import shellExec from "./extensions/shell-exec.js";
9
11
  import { loadExtensions } from "./extension-loader.js";
12
+ /**
13
+ * Capture the user's full shell environment by running a quick interactive
14
+ * subshell. This picks up env vars exported in .zshrc/.bashrc that the
15
+ * Node.js process (which was spawned before the PTY sources rc files)
16
+ * doesn't have.
17
+ */
18
+ function captureShellEnv(shell) {
19
+ try {
20
+ const output = execFileSync(shell, ["-i", "-c", "env -0"], {
21
+ encoding: "utf-8",
22
+ timeout: 5000,
23
+ stdio: ["pipe", "pipe", "pipe"], // suppress interactive noise on stderr
24
+ });
25
+ const env = {};
26
+ for (const entry of output.split("\0")) {
27
+ const eq = entry.indexOf("=");
28
+ if (eq > 0)
29
+ env[entry.slice(0, eq)] = entry.slice(eq + 1);
30
+ }
31
+ return env;
32
+ }
33
+ catch {
34
+ // Fallback: use Node's own environment
35
+ const env = {};
36
+ for (const [k, v] of Object.entries(process.env)) {
37
+ if (v !== undefined)
38
+ env[k] = v;
39
+ }
40
+ return env;
41
+ }
42
+ }
10
43
  function parseArgs(argv) {
11
44
  // Priority: CLI args > Environment variables > Config file > Defaults
12
45
  const defaultAgent = process.env.AGENT_SH_AGENT || "pi-acp";
@@ -92,6 +125,9 @@ function formatAgentInfo(agentInfo, model) {
92
125
  }
93
126
  async function main() {
94
127
  const config = parseArgs(process.argv.slice(2));
128
+ // Capture the user's shell environment (picks up vars from .zshrc/.bashrc
129
+ // that the Node process doesn't have)
130
+ config.shellEnv = captureShellEnv(config.shell || process.env.SHELL || "/bin/bash");
95
131
  // ── Core (frontend-agnostic) ──────────────────────────────────
96
132
  const core = createCore(config);
97
133
  const { bus, client } = core;
@@ -130,6 +166,12 @@ async function main() {
130
166
  slashCommands(extCtx);
131
167
  fileAutocomplete(extCtx);
132
168
  shellRecall(extCtx);
169
+ // Shell-exec: start the Unix socket bridge so the MCP server can
170
+ // route user_shell tool calls to the PTY via the EventBus.
171
+ const tmpDir = shell.getTmpDir();
172
+ if (tmpDir) {
173
+ shellExec(extCtx, { socketPath: `${tmpDir}/shell.sock` });
174
+ }
133
175
  await loadExtensions(extCtx, config.extensions);
134
176
  // ── Agent connection (async — don't block shell startup) ──────
135
177
  core.start().catch((err) => {
@@ -17,7 +17,7 @@ export declare class InputHandler {
17
17
  private ctx;
18
18
  private lineBuffer;
19
19
  private agentInputMode;
20
- private agentInputBuffer;
20
+ private editor;
21
21
  private autocompleteActive;
22
22
  private autocompleteIndex;
23
23
  private autocompleteItems;
@@ -32,7 +32,7 @@ export declare class InputHandler {
32
32
  model?: string;
33
33
  };
34
34
  });
35
- /** Write the agent prompt line (clear + info prefix + ❯ + buffer text). */
35
+ /** Write the agent prompt line with cursor at the correct position. */
36
36
  private writeAgentPromptLine;
37
37
  handleInput(data: string): void;
38
38
  private enterAgentInputMode;
@@ -41,8 +41,8 @@ export declare class InputHandler {
41
41
  private renderAgentInput;
42
42
  private updateAutocomplete;
43
43
  private renderAutocomplete;
44
- private clearAutocompleteLines;
45
44
  private applyAutocomplete;
46
45
  private dismissAutocomplete;
46
+ private clearAutocompleteLines;
47
47
  private handleAgentInput;
48
48
  }