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.
- package/README.md +11 -9
- package/dist/agent/agent-loop.d.ts +0 -3
- package/dist/agent/agent-loop.js +12 -35
- package/dist/agent/conversation-state.js +8 -2
- package/dist/agent/token-budget.d.ts +8 -12
- package/dist/agent/token-budget.js +5 -40
- package/dist/agent/types.d.ts +0 -1
- package/dist/context-manager.d.ts +1 -21
- package/dist/context-manager.js +26 -163
- package/dist/event-bus.d.ts +0 -1
- package/dist/extension-loader.js +25 -4
- package/dist/extensions/agent-backend.js +3 -2
- package/dist/extensions/index.js +0 -1
- package/dist/extensions/tui-renderer.js +5 -2
- package/dist/settings.d.ts +3 -11
- package/dist/settings.js +0 -4
- package/dist/shell/input-handler.js +8 -9
- package/dist/types.d.ts +3 -0
- package/dist/utils/ansi.d.ts +3 -1
- package/dist/utils/ansi.js +68 -7
- package/dist/utils/box-frame.js +8 -2
- package/dist/utils/markdown.js +23 -8
- package/dist/utils/package-version.d.ts +1 -0
- package/dist/utils/package-version.js +10 -0
- package/dist/utils/shell-output-spill.d.ts +2 -0
- package/dist/utils/shell-output-spill.js +81 -0
- package/examples/extensions/claude-code-bridge/README.md +14 -0
- package/examples/extensions/claude-code-bridge/index.ts +13 -101
- package/examples/extensions/pi-bridge/README.md +16 -0
- package/examples/extensions/pi-bridge/index.ts +8 -154
- package/package.json +1 -1
- package/dist/extensions/shell-recall.d.ts +0 -9
- package/dist/extensions/shell-recall.js +0 -8
package/dist/extensions/index.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/settings.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
37
|
+
/** Lines kept from start of spilled shell output. */
|
|
42
38
|
shellHeadLines?: number;
|
|
43
|
-
/** Lines kept from end of
|
|
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
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
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
|
-
//
|
|
161
|
-
|
|
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;
|
package/dist/utils/ansi.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/utils/ansi.js
CHANGED
|
@@ -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 >
|
|
84
|
-
|
|
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
|
-
|
|
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.
|
package/dist/utils/box-frame.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/utils/markdown.js
CHANGED
|
@@ -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
|
-
|
|
36
|
+
const wordWidth = visibleLen(word);
|
|
37
|
+
if (currentWidth + wordWidth <= maxWidth) {
|
|
37
38
|
currentLine += word;
|
|
38
|
-
currentWidth +=
|
|
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
|
-
|
|
45
|
-
|
|
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 +=
|
|
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
|
|
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,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
|
|
6
|
-
*
|
|
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"
|
|
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"
|
|
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
|
|
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
|
-
|
|
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.
|