agent-sh 0.10.0 → 0.10.1

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.
@@ -3,7 +3,6 @@ export const BUILTIN_EXTENSIONS = [
3
3
  { name: "tui-renderer", load: () => import("./tui-renderer.js").then(m => m.default) },
4
4
  { name: "slash-commands", load: () => import("./slash-commands.js").then(m => m.default) },
5
5
  { name: "file-autocomplete", load: () => import("./file-autocomplete.js").then(m => m.default) },
6
- { name: "shell-recall", load: () => import("./shell-recall.js").then(m => m.default) },
7
6
  { name: "command-suggest", load: () => import("./command-suggest.js").then(m => m.default) },
8
7
  ];
9
8
  /**
@@ -74,8 +74,11 @@ export default function activate(ctx) {
74
74
  bus.on("shell:cwd-change", (e) => { shellCwd = e.cwd; });
75
75
  /** Shorthand — get the current agent surface. */
76
76
  function out() { return compositor.surface("agent"); }
77
- /** Capped width for borders, tool lines, and content — keeps everything aligned. */
78
- function cappedW() { return Math.min(MAX_CONTENT_WIDTH + 2, out().columns); }
77
+ /** Capped width for borders, tool lines, and content — keeps everything aligned.
78
+ * MarkdownRenderer.writeLine prepends a 2-char indent (" ") to every line,
79
+ * so available width for actual content is columns - 2. Subtract an additional
80
+ * 1 to prevent terminal auto-wrap when a line lands exactly at the right edge. */
81
+ function cappedW() { return Math.min(MAX_CONTENT_WIDTH + 2, out().columns) - 2 - 1; }
79
82
  // Gate: other extensions (e.g. overlay) can advise this to suppress
80
83
  // TUI rendering of agent output while they own the display.
81
84
  define("tui:should-render-agent", () => true);
@@ -32,20 +32,12 @@ export interface Settings {
32
32
  defaultProvider?: string;
33
33
  /** Preferred agent backend (extension name, e.g. "pi", "claude-code"). */
34
34
  defaultBackend?: string;
35
- /** Recent exchanges included in agent context window. */
36
- contextWindowSize?: number;
37
- /** Context budget in bytes (~4 chars per token). */
38
- contextBudget?: number;
39
- /** Shell output lines before truncation kicks in. */
35
+ /** Shell output lines before spill-to-tempfile kicks in. */
40
36
  shellTruncateThreshold?: number;
41
- /** Lines kept from start of truncated shell output. */
37
+ /** Lines kept from start of spilled shell output. */
42
38
  shellHeadLines?: number;
43
- /** Lines kept from end of truncated shell output. */
39
+ /** Lines kept from end of spilled shell output. */
44
40
  shellTailLines?: number;
45
- /** Max lines for recall expand before requiring line ranges. */
46
- recallExpandMaxLines?: number;
47
- /** Fraction of content budget allocated to shell context (0-1, default 0.35). */
48
- shellContextRatio?: number;
49
41
  /** Max history file size in bytes (default: 102400 = 100KB). */
50
42
  historyMaxBytes?: number;
51
43
  /** Number of prior history entries to load on startup (default: 50). */
package/dist/settings.js CHANGED
@@ -16,13 +16,9 @@ const DEFAULTS = {
16
16
  defaultProvider: undefined,
17
17
  defaultBackend: "ash",
18
18
  toolMode: "api",
19
- contextWindowSize: 20,
20
- contextBudget: 32768,
21
19
  shellTruncateThreshold: 20,
22
20
  shellHeadLines: 10,
23
21
  shellTailLines: 10,
24
- recallExpandMaxLines: 500,
25
- shellContextRatio: 0.35,
26
22
  historyMaxBytes: 104857600, // 100MB — history is only accessed via search/expand, never loaded wholesale
27
23
  historyStartupEntries: 100,
28
24
  autoCompactThreshold: 0.5,
@@ -107,11 +107,11 @@ export class InputHandler {
107
107
  p.accent + after + p.reset +
108
108
  "\x1b8" // DECRC — restore cursor position
109
109
  );
110
- // Clearing on next redraw needs total rows, so measure the full
111
- // content width not just up to the cursor.
112
- const totalVisLen = promptVisLen + visibleLen(display);
113
- this.cursorRowsBelow = totalVisLen > 0 ? Math.ceil(totalVisLen / termW) - 1 : 0;
110
+ // cursorRowsBelow is distance from cursor (restored by DECRC, sitting at
111
+ // the cursor col) back up to the prompt's top row. Next redraw uses it
112
+ // with \x1b[${n}A then \x1b[J — moving past the top scrolls the screen.
114
113
  const cursorVisCol = promptVisLen + visibleLen(before);
114
+ this.cursorRowsBelow = cursorVisCol > 0 ? Math.ceil(cursorVisCol / termW) - 1 : 0;
115
115
  this.cursorTermCol = cursorVisCol === 0 ? 1 : (cursorVisCol % termW === 0 ? termW : (cursorVisCol % termW) + 1);
116
116
  }
117
117
  else {
@@ -157,8 +157,10 @@ export class InputHandler {
157
157
  rowsSoFar += lineTermRows;
158
158
  }
159
159
  process.stdout.write(output + "\x1b8"); // DECRC — restore cursor position
160
- // Total rows (not cursor row) so next redraw clears the whole area.
161
- this.cursorRowsBelow = rowsSoFar - 1 > 0 ? rowsSoFar - 1 : 0;
160
+ // Distance from cursor (where DECRC lands) back to the top row. Next
161
+ // redraw moves up by this and clears to end-of-screen \x1b[J handles
162
+ // everything below, including rows after the cursor's logical line.
163
+ this.cursorRowsBelow = cursorRowFromTop;
162
164
  }
163
165
  }
164
166
  handleInput(data) {
@@ -519,9 +521,6 @@ export class InputHandler {
519
521
  this.applyAutocomplete();
520
522
  }
521
523
  break;
522
- case "shift+tab":
523
- this.bus.emit("config:cycle", {});
524
- break;
525
524
  case "arrow-up":
526
525
  if (this.autocompleteActive) {
527
526
  this.autocompleteIndex =
package/dist/types.d.ts CHANGED
@@ -158,12 +158,15 @@ export type Exchange = {
158
158
  timestamp: number;
159
159
  cwd: string;
160
160
  command: string;
161
+ /** In-context representation: full text if short, head+tail+path stub if spilled. */
161
162
  output: string;
162
163
  exitCode: number | null;
163
164
  outputLines: number;
164
165
  outputBytes: number;
165
166
  /** Who initiated this command: "user" (typed) or "agent" (via user_shell). */
166
167
  source: "user" | "agent";
168
+ /** Path to the tempfile holding the full captured output, if spilled. */
169
+ spillPath?: string;
167
170
  } | {
168
171
  type: "agent_query";
169
172
  id: number;
@@ -8,7 +8,9 @@ export declare const BOLD = "\u001B[1m";
8
8
  export declare const RESET = "\u001B[0m";
9
9
  /**
10
10
  * Check if a Unicode code point is a wide character (CJK, fullwidth, emoji, etc.)
11
- * Returns 2 for wide chars, 1 for normal chars.
11
+ * Returns 2 for wide chars, 1 for normal chars, 0 for combining chars.
12
+ *
13
+ * Based on East Asian Width and Unicode categories.
12
14
  */
13
15
  export declare function charWidth(codePoint: number): number;
14
16
  /**
@@ -10,9 +10,54 @@ export const RESET = "\x1b[0m";
10
10
  // ── ANSI utility functions ───────────────────────────────────
11
11
  /**
12
12
  * Check if a Unicode code point is a wide character (CJK, fullwidth, emoji, etc.)
13
- * Returns 2 for wide chars, 1 for normal chars.
13
+ * Returns 2 for wide chars, 1 for normal chars, 0 for combining chars.
14
+ *
15
+ * Based on East Asian Width and Unicode categories.
14
16
  */
15
17
  export function charWidth(codePoint) {
18
+ // Combining characters (zero width)
19
+ if (codePoint >= 0x0300 && codePoint <= 0x036f)
20
+ return 0; // Combining Diacritical Marks
21
+ if (codePoint >= 0x1ab0 && codePoint <= 0x1aff)
22
+ return 0; // Combining Diacritical Marks Extended
23
+ if (codePoint >= 0x1dc0 && codePoint <= 0x1dff)
24
+ return 0; // Combining Diacritical Marks Supplement
25
+ if (codePoint >= 0x20d0 && codePoint <= 0x20ff)
26
+ return 0; // Combining Diacritical Marks for Symbols
27
+ if (codePoint >= 0xfe20 && codePoint <= 0xfe2f)
28
+ return 0; // Combining Half Marks
29
+ if (codePoint >= 0xfe00 && codePoint <= 0xfe0f)
30
+ return 0; // Variation Selectors
31
+ if (codePoint >= 0xe0100 && codePoint <= 0xe01ef)
32
+ return 0; // Variation Selectors Supplement
33
+ // Emoji and symbols that render as wide (2 columns)
34
+ // Emoji presentation sequences and keycap
35
+ if (codePoint === 0x20e3)
36
+ return 2; // Combining Enclosing Keycap
37
+ // Emoji blocks
38
+ if (codePoint >= 0x1f600 && codePoint <= 0x1f64f)
39
+ return 2; // Emoticons
40
+ if (codePoint >= 0x1f300 && codePoint <= 0x1f5ff)
41
+ return 2; // Misc Symbols and Pictographs
42
+ if (codePoint >= 0x1f680 && codePoint <= 0x1f6ff)
43
+ return 2; // Transport and Map
44
+ if (codePoint >= 0x1f700 && codePoint <= 0x1f77f)
45
+ return 2; // Alchemical Symbols
46
+ if (codePoint >= 0x1f780 && codePoint <= 0x1f7ff)
47
+ return 2; // Geometric Shapes Extended
48
+ if (codePoint >= 0x1f800 && codePoint <= 0x1f8ff)
49
+ return 2; // Supplemental Arrows-C
50
+ if (codePoint >= 0x1f900 && codePoint <= 0x1f9ff)
51
+ return 2; // Supplemental Symbols and Pictographs
52
+ if (codePoint >= 0x1fa00 && codePoint <= 0x1faff)
53
+ return 2; // Chess Symbols, Symbols and Pictographs Extended-A
54
+ // NOTE: 0x2300-0x23ff (Misc Technical), 0x2600-0x26ff (Misc Symbols),
55
+ // and 0x2700-0x27bf (Dingbats) are intentionally NOT width 2 — these ranges
56
+ // contain mostly "Ambiguous" width characters that render as 1 column in
57
+ // non-CJK terminal locales (e.g. ❯, ⌘, ★, ♦).
58
+ // Regional indicator symbols (flag emoji components)
59
+ if (codePoint >= 0x1f1e6 && codePoint <= 0x1f1ff)
60
+ return 2;
16
61
  // CJK Unified Ideographs
17
62
  if (codePoint >= 0x4e00 && codePoint <= 0x9fff)
18
63
  return 2;
@@ -28,7 +73,6 @@ export function charWidth(codePoint) {
28
73
  // Fullwidth ASCII variants
29
74
  if (codePoint >= 0xff01 && codePoint <= 0xff5e)
30
75
  return 2;
31
- // Halfwidth Katakana (actually narrow, skip)
32
76
  // Fullwidth bracket forms
33
77
  if (codePoint >= 0xff5f && codePoint <= 0xff60)
34
78
  return 2;
@@ -76,18 +120,35 @@ export function visibleLen(str) {
76
120
  */
77
121
  export function truncateToWidth(str, maxWidth) {
78
122
  const clean = str.replace(/\x1b\[[^m]*m/g, "");
123
+ if (maxWidth <= 0)
124
+ return "";
125
+ // First check if the entire string fits
126
+ let fullWidth = 0;
127
+ for (const char of clean) {
128
+ fullWidth += charWidth(char.codePointAt(0) ?? 0);
129
+ }
130
+ if (fullWidth <= maxWidth)
131
+ return clean;
132
+ // String doesn't fit — truncate with "…"
133
+ // At maxWidth=1 the ellipsis alone fills the budget.
134
+ if (maxWidth === 1)
135
+ return "…";
136
+ // Reserve 1 column for "…", so target content width is maxWidth - 1
137
+ const target = maxWidth - 1;
79
138
  let width = 0;
80
139
  let i = 0;
81
140
  for (const char of clean) {
82
141
  const cw = charWidth(char.codePointAt(0) ?? 0);
83
- if (width + cw > maxWidth - 1) {
84
- // Need room for the "…" (1 column wide)
85
- return clean.slice(0, i) + "…";
86
- }
142
+ if (width + cw > target)
143
+ break;
87
144
  width += cw;
88
145
  i += char.length;
89
146
  }
90
- return clean;
147
+ // If nothing fit (first char is wider than target), just show the ellipsis
148
+ // rather than emit a character that would overflow the budget.
149
+ if (i === 0)
150
+ return "…";
151
+ return clean.slice(0, i) + "…";
91
152
  }
92
153
  /**
93
154
  * Pad a string with spaces to fill `targetWidth` visible columns.
@@ -5,7 +5,7 @@
5
5
  * never writes to stdout. Supports multiple border styles and
6
6
  * optional title/footer sections with dividers.
7
7
  */
8
- import { visibleLen } from "./ansi.js";
8
+ import { visibleLen, truncateToWidth } from "./ansi.js";
9
9
  import { palette as p } from "./palette.js";
10
10
  const BORDERS = {
11
11
  rounded: { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│", ml: "├", mr: "┤" },
@@ -63,6 +63,12 @@ export function renderBoxFrame(content, opts) {
63
63
  }
64
64
  // ── Helpers ──────────────────────────────────────────────────────
65
65
  function boxLine(text, innerW, v, bc) {
66
- const pad = Math.max(0, innerW - visibleLen(text));
66
+ const textWidth = visibleLen(text);
67
+ if (textWidth > innerW) {
68
+ // Content is too wide — truncate to fit exactly
69
+ const truncated = truncateToWidth(text, innerW);
70
+ return `${bc}${v}${p.reset} ${truncated} ${bc}${v}${p.reset}`;
71
+ }
72
+ const pad = innerW - textWidth;
67
73
  return `${bc}${v}${p.reset} ${text}${" ".repeat(pad)} ${bc}${v}${p.reset}`;
68
74
  }
@@ -1,4 +1,4 @@
1
- import { visibleLen, truncateToWidth, padEndToWidth } from "./ansi.js";
1
+ import { visibleLen, truncateToWidth, padEndToWidth, charWidth } from "./ansi.js";
2
2
  import { palette as p } from "./palette.js";
3
3
  export const MAX_CONTENT_WIDTH = 90;
4
4
  /**
@@ -33,16 +33,31 @@ export function wrapLine(text, maxWidth) {
33
33
  for (const word of words) {
34
34
  if (word.length === 0)
35
35
  continue;
36
- if (currentWidth + word.length <= maxWidth) {
36
+ const wordWidth = visibleLen(word);
37
+ if (currentWidth + wordWidth <= maxWidth) {
37
38
  currentLine += word;
38
- currentWidth += word.length;
39
+ currentWidth += wordWidth;
39
40
  }
40
41
  else if (currentWidth === 0) {
41
- // Single word longer than maxWidth — hard break
42
+ // Single word longer than maxWidth — hard break by visible width
42
43
  let remaining = word;
43
44
  while (remaining.length > 0) {
44
- const chunk = remaining.slice(0, maxWidth - currentWidth || maxWidth);
45
- remaining = remaining.slice(chunk.length);
45
+ // Find the largest prefix that fits
46
+ let fitLen = 0;
47
+ let fitWidth = 0;
48
+ for (const ch of remaining) {
49
+ const cw = charWidth(ch.codePointAt(0) ?? 0);
50
+ if (fitWidth + cw > maxWidth)
51
+ break;
52
+ fitWidth += cw;
53
+ fitLen += ch.length;
54
+ }
55
+ if (fitLen === 0) {
56
+ // Even one char doesn't fit — force take one char to avoid infinite loop
57
+ fitLen = remaining[0]?.length ?? 1;
58
+ }
59
+ const chunk = remaining.slice(0, fitLen);
60
+ remaining = remaining.slice(fitLen);
46
61
  currentLine += chunk;
47
62
  if (remaining.length > 0) {
48
63
  result.push(currentLine + p.reset);
@@ -50,7 +65,7 @@ export function wrapLine(text, maxWidth) {
50
65
  currentWidth = 0;
51
66
  }
52
67
  else {
53
- currentWidth += chunk.length;
68
+ currentWidth += fitWidth;
54
69
  }
55
70
  }
56
71
  }
@@ -62,7 +77,7 @@ export function wrapLine(text, maxWidth) {
62
77
  // Skip leading spaces on new line
63
78
  const trimmed = word.replace(/^ +/, "");
64
79
  currentLine += trimmed;
65
- currentWidth = trimmed.length;
80
+ currentWidth = visibleLen(trimmed);
66
81
  }
67
82
  }
68
83
  }
@@ -0,0 +1 @@
1
+ export declare const PACKAGE_VERSION: string;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The agent-sh package version, read from package.json at load time.
3
+ * Emitted on `agent:info` so consumers (TUI, remote peers, logs) see a
4
+ * version that tracks releases instead of a hand-edited constant.
5
+ */
6
+ import { createRequire } from "module";
7
+ const require = createRequire(import.meta.url);
8
+ // dist/utils/package-version.js → ../../package.json (project root)
9
+ const pkg = require("../../package.json");
10
+ export const PACKAGE_VERSION = pkg.version ?? "0.0.0";
@@ -0,0 +1,2 @@
1
+ export declare function getSessionDir(): string;
2
+ export declare function spillOutput(id: number, text: string): string;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Spill long shell outputs to per-session tempfiles.
3
+ *
4
+ * Captured PTY output that exceeds the truncation threshold is written to
5
+ * `<tmpdir>/agent-sh-<pid>/<id>.out`. The in-memory exchange keeps only a
6
+ * head+tail stub pointing at that path, so the agent can fetch the full
7
+ * text via `read_file` on demand. The session dir is removed on process
8
+ * exit; stale dirs from dead processes are swept lazily on first use.
9
+ */
10
+ import { mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ const DIR_PREFIX = "agent-sh-";
14
+ let sessionDir = null;
15
+ let cleanupRegistered = false;
16
+ export function getSessionDir() {
17
+ if (sessionDir)
18
+ return sessionDir;
19
+ sessionDir = join(tmpdir(), `${DIR_PREFIX}${process.pid}`);
20
+ mkdirSync(sessionDir, { recursive: true });
21
+ sweepStaleDirs();
22
+ if (!cleanupRegistered) {
23
+ cleanupRegistered = true;
24
+ const cleanup = () => {
25
+ if (!sessionDir)
26
+ return;
27
+ try {
28
+ rmSync(sessionDir, { recursive: true, force: true });
29
+ }
30
+ catch { }
31
+ sessionDir = null;
32
+ };
33
+ process.on("exit", cleanup);
34
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
35
+ process.on(sig, () => { cleanup(); process.exit(128); });
36
+ }
37
+ }
38
+ return sessionDir;
39
+ }
40
+ export function spillOutput(id, text) {
41
+ const path = join(getSessionDir(), `${id}.out`);
42
+ writeFileSync(path, text);
43
+ return path;
44
+ }
45
+ function sweepStaleDirs() {
46
+ const base = tmpdir();
47
+ let entries;
48
+ try {
49
+ entries = readdirSync(base);
50
+ }
51
+ catch {
52
+ return;
53
+ }
54
+ for (const name of entries) {
55
+ if (!name.startsWith(DIR_PREFIX))
56
+ continue;
57
+ const pid = Number(name.slice(DIR_PREFIX.length));
58
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid)
59
+ continue;
60
+ if (isProcessAlive(pid))
61
+ continue;
62
+ const full = join(base, name);
63
+ try {
64
+ // Small safety check: only remove directories.
65
+ if (statSync(full).isDirectory()) {
66
+ rmSync(full, { recursive: true, force: true });
67
+ }
68
+ }
69
+ catch { }
70
+ }
71
+ }
72
+ function isProcessAlive(pid) {
73
+ try {
74
+ process.kill(pid, 0);
75
+ return true;
76
+ }
77
+ catch (e) {
78
+ // ESRCH = no such process; EPERM = exists but we can't signal it
79
+ return e.code === "EPERM";
80
+ }
81
+ }
@@ -33,3 +33,17 @@ Or switch at runtime:
33
33
 
34
34
  - `ANTHROPIC_API_KEY` must be set in your environment
35
35
  - Claude Code manages its own model selection — no model configuration needed in agent-sh
36
+
37
+ ## What this bridge is
38
+
39
+ A pure protocol translator between the Claude Agent SDK's event stream and agent-sh's bus events. Claude Code uses its own built-in tools exactly as the SDK ships them (`Read`, `Edit`, `Write`, `Bash`, `Glob`, `Grep`). The bridge adds no tools of its own.
40
+
41
+ ## What this bridge intentionally does NOT bundle
42
+
43
+ Three PTY-access tools are left out on purpose:
44
+
45
+ - `terminal_read` — observe the user's live terminal screen
46
+ - `terminal_keys` — send keystrokes to the user's PTY
47
+ - `user_shell` — run commands in the user's live shell with lasting `cd`/`export`/`source` effects
48
+
49
+ These are opt-in capabilities that belong in their own extensions. If you want any of them with Claude Code, write a companion extension that uses the SDK's `tool()` + `createSdkMcpServer()` to expose them as MCP tools, and extend the bridge (or fork it) to attach that MCP server to the SDK's `query()` options.
@@ -2,8 +2,14 @@
2
2
  * Claude Code bridge — runs Claude Code Agent SDK in-process as agent-sh's backend.
3
3
  *
4
4
  * Uses the official @anthropic-ai/claude-agent-sdk to spawn a Claude Code
5
- * session with custom MCP tools for PTY access. Claude Code
6
- * handles its own model selection, tool execution, and permissions.
5
+ * session. Claude Code handles its own model selection, tool execution, and
6
+ * permissions the bridge is a pure protocol translator between the SDK's
7
+ * event stream and agent-sh's bus events.
8
+ *
9
+ * PTY-access tools (`terminal_read`, `terminal_keys`, `user_shell`) are
10
+ * intentionally NOT bundled here. If you want Claude Code to observe or
11
+ * drive the user's live terminal, load a companion extension that
12
+ * registers those tools as MCP tools the SDK can consume.
7
13
  *
8
14
  * Setup (from repo root):
9
15
  * npm run build && npm link # register local agent-sh globally
@@ -15,103 +21,16 @@
15
21
  *
16
22
  * Requires: Claude Code CLI installed and authenticated (claude login).
17
23
  */
18
- import {
19
- query,
20
- tool,
21
- createSdkMcpServer,
22
- type Query,
23
- } from "@anthropic-ai/claude-agent-sdk";
24
- import { z } from "zod";
24
+ import { query, type Query } from "@anthropic-ai/claude-agent-sdk";
25
25
  import { readFile } from "node:fs/promises";
26
26
  import { resolve } from "node:path";
27
27
  import type { ExtensionContext } from "agent-sh/types";
28
- import type { EventBus } from "agent-sh/event-bus";
29
28
  import { computeDiff, type DiffResult } from "agent-sh/utils/diff";
30
29
 
31
- // ── Helpers ──────────────────────────────────────────────────────
32
- function interpretEscapes(str: string): string {
33
- return str.replace(/\\(x[0-9a-fA-F]{2}|r|n|t|\\|0)/g, (_, seq: string) => {
34
- if (seq === "r") return "\r";
35
- if (seq === "n") return "\n";
36
- if (seq === "t") return "\t";
37
- if (seq === "\\") return "\\";
38
- if (seq === "0") return "\0";
39
- if (seq.startsWith("x")) return String.fromCharCode(parseInt(seq.slice(1), 16));
40
- return seq;
41
- });
42
- }
43
-
44
- function settle(ms = 100): Promise<void> {
45
- return new Promise((resolve) => setTimeout(resolve, ms));
46
- }
47
-
48
- // ── terminal_read MCP tool ────────────────────────────────────────
49
- function createTerminalReadTool(ctx: ExtensionContext) {
50
- return tool(
51
- "terminal_read",
52
- "Read the current terminal screen contents. Returns clean text (ANSI stripped) " +
53
- "with cursor position and whether an alternate-screen program (vim, htop, less) is active. " +
54
- "Use this to see what the user sees before sending keystrokes with terminal_keys.",
55
- {},
56
- async () => {
57
- const tb = ctx.terminalBuffer;
58
- if (!tb) return { content: [{ type: "text" as const, text: "terminal buffer not available" }] };
59
- const { text, altScreen, cursorX, cursorY } = tb.readScreen();
60
- const info = [
61
- altScreen ? "mode: alternate screen" : "mode: normal",
62
- `cursor: row=${cursorY} col=${cursorX}`,
63
- ].join(", ");
64
- return { content: [{ type: "text" as const, text: `[${info}]\n\n${text}` }] };
65
- },
66
- );
67
- }
68
-
69
- // ── terminal_keys MCP tool ───────────────────────────────────────
70
- function createTerminalKeysTool(bus: EventBus, ctx: ExtensionContext) {
71
- return tool(
72
- "terminal_keys",
73
- "Send keystrokes to the user's live terminal. The keys are written directly to the PTY " +
74
- "as if the user typed them. Use escape sequences for special keys:\n" +
75
- " - Escape: \\x1b - Enter: \\r - Tab: \\t\n" +
76
- " - Ctrl+C: \\x03 - Arrow keys: \\x1b[A/B/C/D - Backspace: \\x7f\n" +
77
- "Example: to quit vim without saving, send keys=\"\\x1b:q!\\r\".\n" +
78
- "Always call terminal_read after sending keys to verify the result.",
79
- {
80
- keys: z.string().describe("Keystrokes to send (use \\x1b for Escape, \\r for Enter, etc.)"),
81
- settle_ms: z.number().optional().describe("Wait time in ms after sending keys (default: 150)"),
82
- },
83
- async (args) => {
84
- const keys = interpretEscapes(args.keys);
85
- const settleMs = args.settle_ms ?? 150;
86
- bus.emit("shell:stdout-show", {});
87
- process.stdout.write("\n");
88
- bus.emit("shell:pty-write", { data: keys });
89
- await settle(settleMs);
90
-
91
- const tb = ctx.terminalBuffer;
92
- if (!tb) return { content: [{ type: "text" as const, text: "Keys sent." }] };
93
- const { text, altScreen, cursorX, cursorY } = tb.readScreen();
94
- const info = [
95
- altScreen ? "mode: alternate screen" : "mode: normal",
96
- `cursor: row=${cursorY} col=${cursorX}`,
97
- ].join(", ");
98
- return { content: [{ type: "text" as const, text: `Keys sent. Screen after:\n[${info}]\n\n${text}` }] };
99
- },
100
- );
101
- }
102
-
103
30
  // ── Extension entry point ─────────────────────────────────────────
104
31
  export default function activate(ctx: ExtensionContext): void {
105
32
  const { bus } = ctx;
106
33
 
107
- const termReadTool = createTerminalReadTool(ctx);
108
- const termKeysTool = createTerminalKeysTool(bus, ctx);
109
- const shellServer = createSdkMcpServer({
110
- name: "agent-sh",
111
- version: "1.0.0",
112
- tools: [termReadTool, termKeysTool],
113
- });
114
-
115
34
  let activeQuery: Query | null = null;
116
35
  const listeners: Array<{ event: string; fn: Function }> = [];
117
36
 
@@ -119,11 +38,11 @@ export default function activate(ctx: ExtensionContext): void {
119
38
 
120
39
  /** Map Claude Code tool names to agent-sh display kinds. */
121
40
  function toolKind(name: string): string {
122
- if (name === "Read" || name.includes("terminal_read")) return "read";
41
+ if (name === "Read") return "read";
123
42
  if (name === "Edit") return "edit";
124
43
  if (name === "Write") return "write";
125
44
  if (name === "Glob" || name === "Grep") return "search";
126
- if (name === "Bash" || name.includes("terminal_keys")) return "execute";
45
+ if (name === "Bash") return "execute";
127
46
  return "execute";
128
47
  }
129
48
 
@@ -150,7 +69,6 @@ export default function activate(ctx: ExtensionContext): void {
150
69
  if (name === "Bash") return `$ ${str(input.command)}`;
151
70
  if (name === "Read" || name === "Edit" || name === "Write") return str(input.file_path ?? input.path);
152
71
  if (name === "Grep" || name === "Glob") return `${str(input.pattern)} ${str(input.path)}`.trim();
153
- if (name.includes("terminal_keys")) return str(input.keys);
154
72
  return name;
155
73
  }
156
74
 
@@ -180,15 +98,9 @@ export default function activate(ctx: ExtensionContext): void {
180
98
  preset: "claude_code",
181
99
  append:
182
100
  "You are running inside agent-sh, a terminal wrapper.\n" +
183
- "Use your standard tools (Read, Edit, Write, Bash, Glob, Grep) for investigation.\n" +
184
- "Use mcp__agent-sh__terminal_read and mcp__agent-sh__terminal_keys to observe and interact with the user's live terminal.",
101
+ "Use your standard tools (Read, Edit, Write, Bash, Glob, Grep) for investigation.",
185
102
  },
186
- mcpServers: { "agent-sh": shellServer },
187
- allowedTools: [
188
- "mcp__agent-sh__terminal_read",
189
- "mcp__agent-sh__terminal_keys",
190
- "Read", "Edit", "Write", "Bash", "Glob", "Grep",
191
- ],
103
+ allowedTools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"],
192
104
  permissionMode: "acceptEdits",
193
105
  includePartialMessages: true,
194
106
  },
@@ -33,3 +33,19 @@ Or switch at runtime:
33
33
 
34
34
  - pi must be configured separately (`~/.pi/settings.json`) with API keys and model preferences
35
35
  - agent-sh does not override pi's configuration — it uses whatever pi is set up with
36
+
37
+ ## What this bridge is
38
+
39
+ A pure protocol translator between pi's event stream and agent-sh's bus events. Pi's built-in tools (command execution, file ops, etc.) are used exactly as pi ships them. The bridge adds no tools of its own.
40
+
41
+ ## What this bridge intentionally does NOT bundle
42
+
43
+ Three PTY-access tools are left out on purpose:
44
+
45
+ - `terminal_read` — observe the user's live terminal screen
46
+ - `terminal_keys` — send keystrokes to the user's PTY
47
+ - `user_shell` — run commands in the user's live shell with lasting `cd`/`export`/`source` effects
48
+
49
+ These are opt-in capabilities that belong in their own extensions. If you want any of them with pi, write a small companion extension that registers the tool as a pi `ToolDefinition` (TypeBox schema, wired to the relevant bus event: `shell:pty-write`, `shell:exec-request`, or `ctx.terminalBuffer.readScreen()`) and load it alongside pi-bridge.
50
+
51
+ Keeping this split means the bridge stays narrow — only translating events — and the capability surface is composable per-backend.