agent-sh 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +12 -9
  2. package/dist/agent/agent-loop.d.ts +0 -3
  3. package/dist/agent/agent-loop.js +18 -35
  4. package/dist/agent/conversation-state.js +8 -2
  5. package/dist/agent/nuclear-form.d.ts +2 -0
  6. package/dist/agent/nuclear-form.js +11 -1
  7. package/dist/agent/system-prompt.js +1 -1
  8. package/dist/agent/token-budget.d.ts +8 -12
  9. package/dist/agent/token-budget.js +5 -40
  10. package/dist/agent/tool-registry.js +6 -0
  11. package/dist/agent/types.d.ts +3 -1
  12. package/dist/context-manager.d.ts +1 -21
  13. package/dist/context-manager.js +26 -163
  14. package/dist/event-bus.d.ts +0 -1
  15. package/dist/extension-loader.js +25 -4
  16. package/dist/extensions/agent-backend.js +3 -2
  17. package/dist/extensions/index.js +0 -1
  18. package/dist/extensions/tui-renderer.js +47 -29
  19. package/dist/settings.d.ts +3 -11
  20. package/dist/settings.js +0 -4
  21. package/dist/shell/input-handler.js +14 -9
  22. package/dist/types.d.ts +3 -0
  23. package/dist/utils/ansi.d.ts +6 -1
  24. package/dist/utils/ansi.js +114 -7
  25. package/dist/utils/box-frame.js +8 -2
  26. package/dist/utils/llm-client.d.ts +4 -0
  27. package/dist/utils/llm-client.js +8 -0
  28. package/dist/utils/markdown.d.ts +4 -0
  29. package/dist/utils/markdown.js +136 -48
  30. package/dist/utils/package-version.d.ts +1 -0
  31. package/dist/utils/package-version.js +10 -0
  32. package/dist/utils/shell-output-spill.d.ts +2 -0
  33. package/dist/utils/shell-output-spill.js +81 -0
  34. package/examples/extensions/claude-code-bridge/README.md +14 -0
  35. package/examples/extensions/claude-code-bridge/index.ts +13 -101
  36. package/examples/extensions/pi-bridge/README.md +16 -0
  37. package/examples/extensions/pi-bridge/index.ts +8 -154
  38. package/package.json +9 -1
  39. package/dist/extensions/shell-recall.d.ts +0 -9
  40. package/dist/extensions/shell-recall.js +0 -8
@@ -10,9 +10,67 @@ 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 mostly "Ambiguous" width — render as
56
+ // 1 column in non-CJK terminal locales (e.g. ❯, ⌘, ★, ♦). But a handful
57
+ // of dingbats have Emoji_Presentation=Yes and render as 2 cols everywhere.
58
+ if (codePoint === 0x2705 || // ✅ white heavy check mark
59
+ codePoint === 0x270a || // ✊ raised fist
60
+ codePoint === 0x270b || // ✋ raised hand
61
+ codePoint === 0x2728 || // ✨ sparkles
62
+ codePoint === 0x274c || // ❌ cross mark
63
+ codePoint === 0x274e || // ❎ negative squared cross mark
64
+ (codePoint >= 0x2753 && codePoint <= 0x2755) || // ❓❔❕
65
+ codePoint === 0x2757 || // ❗ heavy exclamation mark
66
+ (codePoint >= 0x2795 && codePoint <= 0x2797) || // ➕➖➗
67
+ codePoint === 0x27b0 || // ➰ curly loop
68
+ codePoint === 0x27bf // ➿ double curly loop
69
+ )
70
+ return 2;
71
+ // Regional indicator symbols (flag emoji components)
72
+ if (codePoint >= 0x1f1e6 && codePoint <= 0x1f1ff)
73
+ return 2;
16
74
  // CJK Unified Ideographs
17
75
  if (codePoint >= 0x4e00 && codePoint <= 0x9fff)
18
76
  return 2;
@@ -28,7 +86,6 @@ export function charWidth(codePoint) {
28
86
  // Fullwidth ASCII variants
29
87
  if (codePoint >= 0xff01 && codePoint <= 0xff5e)
30
88
  return 2;
31
- // Halfwidth Katakana (actually narrow, skip)
32
89
  // Fullwidth bracket forms
33
90
  if (codePoint >= 0xff5f && codePoint <= 0xff60)
34
91
  return 2;
@@ -76,18 +133,68 @@ export function visibleLen(str) {
76
133
  */
77
134
  export function truncateToWidth(str, maxWidth) {
78
135
  const clean = str.replace(/\x1b\[[^m]*m/g, "");
136
+ if (maxWidth <= 0)
137
+ return "";
138
+ // First check if the entire string fits
139
+ let fullWidth = 0;
140
+ for (const char of clean) {
141
+ fullWidth += charWidth(char.codePointAt(0) ?? 0);
142
+ }
143
+ if (fullWidth <= maxWidth)
144
+ return clean;
145
+ // String doesn't fit — truncate with "…"
146
+ // At maxWidth=1 the ellipsis alone fills the budget.
147
+ if (maxWidth === 1)
148
+ return "…";
149
+ // Reserve 1 column for "…", so target content width is maxWidth - 1
150
+ const target = maxWidth - 1;
79
151
  let width = 0;
80
152
  let i = 0;
81
153
  for (const char of clean) {
82
154
  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
- }
155
+ if (width + cw > target)
156
+ break;
87
157
  width += cw;
88
158
  i += char.length;
89
159
  }
90
- return clean;
160
+ // If nothing fit (first char is wider than target), just show the ellipsis
161
+ // rather than emit a character that would overflow the budget.
162
+ if (i === 0)
163
+ return "…";
164
+ return clean.slice(0, i) + "…";
165
+ }
166
+ /** Truncate to visible width while preserving SGR sequences — use when
167
+ * input carries color/bold codes. `truncateToWidth` strips them. */
168
+ export function truncateAnsiToWidth(str, maxWidth) {
169
+ if (maxWidth <= 0)
170
+ return "";
171
+ if (visibleLen(str) <= maxWidth)
172
+ return str;
173
+ if (maxWidth === 1)
174
+ return "…";
175
+ const target = maxWidth - 1;
176
+ let width = 0;
177
+ let out = "";
178
+ let i = 0;
179
+ while (i < str.length) {
180
+ if (str[i] === "\x1b" && str[i + 1] === "[") {
181
+ const end = str.indexOf("m", i);
182
+ if (end !== -1) {
183
+ out += str.slice(i, end + 1);
184
+ i = end + 1;
185
+ continue;
186
+ }
187
+ }
188
+ const cp = str.codePointAt(i) ?? 0;
189
+ const cw = charWidth(cp);
190
+ if (width + cw > target)
191
+ break;
192
+ const chLen = cp > 0xffff ? 2 : 1;
193
+ out += str.slice(i, i + chLen);
194
+ width += cw;
195
+ i += chLen;
196
+ }
197
+ return out + "\x1b[0m…";
91
198
  }
92
199
  /**
93
200
  * 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
  }
@@ -12,6 +12,10 @@ export interface LlmClientConfig {
12
12
  apiKey: string;
13
13
  baseURL?: string;
14
14
  model: string;
15
+ /** Sent as OpenRouter X-Title; ignored by other providers. */
16
+ appName?: string;
17
+ /** Sent as OpenRouter HTTP-Referer; ignored by other providers. */
18
+ appUrl?: string;
15
19
  }
16
20
  export declare class LlmClient {
17
21
  private config;
@@ -6,6 +6,12 @@
6
6
  * (command suggestions, completions).
7
7
  */
8
8
  import OpenAI from "openai";
9
+ function attributionHeaders(config) {
10
+ return {
11
+ "HTTP-Referer": config.appUrl ?? "https://agent-sh.dev",
12
+ "X-Title": config.appName ?? "agent-sh",
13
+ };
14
+ }
9
15
  export class LlmClient {
10
16
  config;
11
17
  client;
@@ -15,6 +21,7 @@ export class LlmClient {
15
21
  this.client = new OpenAI({
16
22
  apiKey: config.apiKey,
17
23
  baseURL: config.baseURL,
24
+ defaultHeaders: attributionHeaders(config),
18
25
  });
19
26
  this.model = config.model;
20
27
  }
@@ -24,6 +31,7 @@ export class LlmClient {
24
31
  this.client = new OpenAI({
25
32
  apiKey: newConfig.apiKey,
26
33
  baseURL: newConfig.baseURL,
34
+ defaultHeaders: attributionHeaders(newConfig),
27
35
  });
28
36
  this.model = newConfig.model;
29
37
  }
@@ -2,6 +2,10 @@ export declare const MAX_CONTENT_WIDTH = 90;
2
2
  /**
3
3
  * Word-wrap a string (which may contain ANSI codes) to a maximum visible width.
4
4
  * Returns an array of lines, each fitting within `maxWidth` visible characters.
5
+ *
6
+ * Handles CJK text by breaking between wide characters and applying basic
7
+ * CJK rules (closing punctuation sticks to the previous line; opening
8
+ * punctuation sticks to the next).
5
9
  */
6
10
  export declare function wrapLine(text: string, maxWidth: number): string[];
7
11
  /**
@@ -1,9 +1,65 @@
1
- import { visibleLen, truncateToWidth, padEndToWidth } from "./ansi.js";
1
+ import { visibleLen, truncateAnsiToWidth, padEndToWidth, charWidth } from "./ansi.js";
2
2
  import { palette as p } from "./palette.js";
3
3
  export const MAX_CONTENT_WIDTH = 90;
4
+ // CJK line-breaking rules: closing punctuation must not start a line,
5
+ // opening punctuation must not end a line. Both CJK fullwidth and ASCII
6
+ // equivalents are included so mixed text wraps correctly.
7
+ const CJK_NO_LINE_START = new Set([
8
+ "。", ",", "、", ".", ";", ":", "!", "?",
9
+ ")", "」", "』", "】", "》", "〉", "〕", "]", "}",
10
+ "・", "々", "〜", "~", "ー",
11
+ ".", ",", ";", ":", "!", "?", ")", "]", "}",
12
+ ]);
13
+ const CJK_NO_LINE_END = new Set([
14
+ "(", "「", "『", "【", "《", "〈", "〔", "[", "{",
15
+ "(", "[", "{",
16
+ ]);
17
+ /**
18
+ * Tokenize a visible-text run into units suitable for wrapping.
19
+ * Each width-2 character (CJK, fullwidth, emoji) becomes its own token so the
20
+ * wrapper can break between them; ASCII runs stay together as word tokens.
21
+ */
22
+ function tokenizeVisible(text) {
23
+ const tokens = [];
24
+ let ascii = "";
25
+ const flush = () => { if (ascii) {
26
+ tokens.push(ascii);
27
+ ascii = "";
28
+ } };
29
+ let i = 0;
30
+ while (i < text.length) {
31
+ const cp = text.codePointAt(i) ?? 0;
32
+ const chLen = cp > 0xffff ? 2 : 1;
33
+ const ch = text.slice(i, i + chLen);
34
+ if (ch === " ") {
35
+ flush();
36
+ let spaces = "";
37
+ while (i < text.length && text[i] === " ") {
38
+ spaces += " ";
39
+ i += 1;
40
+ }
41
+ tokens.push(spaces);
42
+ continue;
43
+ }
44
+ if (charWidth(cp) === 2) {
45
+ flush();
46
+ tokens.push(ch);
47
+ i += chLen;
48
+ continue;
49
+ }
50
+ ascii += ch;
51
+ i += chLen;
52
+ }
53
+ flush();
54
+ return tokens;
55
+ }
4
56
  /**
5
57
  * Word-wrap a string (which may contain ANSI codes) to a maximum visible width.
6
58
  * Returns an array of lines, each fitting within `maxWidth` visible characters.
59
+ *
60
+ * Handles CJK text by breaking between wide characters and applying basic
61
+ * CJK rules (closing punctuation sticks to the previous line; opening
62
+ * punctuation sticks to the next).
7
63
  */
8
64
  export function wrapLine(text, maxWidth) {
9
65
  if (!(maxWidth > 0))
@@ -11,63 +67,92 @@ export function wrapLine(text, maxWidth) {
11
67
  if (visibleLen(text) <= maxWidth)
12
68
  return [text];
13
69
  const result = [];
14
- // Split into segments: ANSI codes and visible text
15
70
  const segments = text.match(/(\x1b\[[^m]*m|[^\x1b]+)/g) || [text];
16
- let currentLine = "";
17
- let currentWidth = 0;
18
- let activeStyles = ""; // track ANSI styles to reapply after wraps
71
+ let lineTokens = [];
72
+ let lineWidth = 0;
73
+ let activeStyles = "";
74
+ let lastVisibleIdx = -1;
75
+ const commit = () => {
76
+ result.push(lineTokens.join("") + p.reset);
77
+ lineTokens = activeStyles ? [activeStyles] : [];
78
+ lineWidth = 0;
79
+ lastVisibleIdx = -1;
80
+ };
19
81
  for (const seg of segments) {
20
82
  if (seg.startsWith("\x1b[")) {
21
- // ANSI code — track it, add to current line
22
- currentLine += seg;
23
- if (seg === p.reset) {
83
+ lineTokens.push(seg);
84
+ if (seg === p.reset)
24
85
  activeStyles = "";
25
- }
26
- else {
86
+ else
27
87
  activeStyles += seg;
28
- }
29
88
  continue;
30
89
  }
31
- // Visible text split into words
32
- const words = seg.split(/( +)/);
33
- for (const word of words) {
34
- if (word.length === 0)
90
+ for (const token of tokenizeVisible(seg)) {
91
+ const tokenWidth = visibleLen(token);
92
+ const isSpace = token[0] === " ";
93
+ if (lineWidth + tokenWidth <= maxWidth) {
94
+ lineTokens.push(token);
95
+ lineWidth += tokenWidth;
96
+ if (!isSpace)
97
+ lastVisibleIdx = lineTokens.length - 1;
35
98
  continue;
36
- if (currentWidth + word.length <= maxWidth) {
37
- currentLine += word;
38
- currentWidth += word.length;
39
99
  }
40
- else if (currentWidth === 0) {
41
- // Single word longer than maxWidth — hard break
42
- let remaining = word;
100
+ // Token doesn't fit on the current line.
101
+ if (isSpace)
102
+ continue; // spaces at wrap points are dropped
103
+ if (lineWidth === 0) {
104
+ // Token longer than the entire line — hard-break by char width.
105
+ let remaining = token;
43
106
  while (remaining.length > 0) {
44
- const chunk = remaining.slice(0, maxWidth - currentWidth || maxWidth);
45
- remaining = remaining.slice(chunk.length);
46
- currentLine += chunk;
47
- if (remaining.length > 0) {
48
- result.push(currentLine + p.reset);
49
- currentLine = activeStyles;
50
- currentWidth = 0;
51
- }
52
- else {
53
- currentWidth += chunk.length;
107
+ let fitLen = 0, fitWidth = 0;
108
+ for (const ch of remaining) {
109
+ const cw = charWidth(ch.codePointAt(0) ?? 0);
110
+ if (fitWidth + cw > maxWidth)
111
+ break;
112
+ fitWidth += cw;
113
+ fitLen += ch.length;
54
114
  }
115
+ if (fitLen === 0)
116
+ fitLen = remaining[0]?.length ?? 1;
117
+ const chunk = remaining.slice(0, fitLen);
118
+ remaining = remaining.slice(fitLen);
119
+ lineTokens.push(chunk);
120
+ lineWidth += visibleLen(chunk);
121
+ lastVisibleIdx = lineTokens.length - 1;
122
+ if (remaining.length > 0)
123
+ commit();
124
+ }
125
+ continue;
126
+ }
127
+ // Rule (a): closing punctuation must not start a line. Allow up to 2
128
+ // columns of overflow so the punctuation stays with its phrase.
129
+ if (CJK_NO_LINE_START.has(token)) {
130
+ lineTokens.push(token);
131
+ lineWidth += tokenWidth;
132
+ commit();
133
+ continue;
134
+ }
135
+ // Rule (b): opening punctuation must not end a line. Pull the trailing
136
+ // opener down to the next line with us.
137
+ let carried = [];
138
+ if (lastVisibleIdx >= 0 && CJK_NO_LINE_END.has(lineTokens[lastVisibleIdx])) {
139
+ carried = lineTokens.splice(lastVisibleIdx);
140
+ while (lineTokens.length > 0 && /^ +$/.test(lineTokens[lineTokens.length - 1])) {
141
+ lineTokens.pop();
55
142
  }
56
143
  }
57
- else {
58
- // Wrap to next line
59
- result.push(currentLine + p.reset);
60
- currentLine = activeStyles;
61
- currentWidth = 0;
62
- // Skip leading spaces on new line
63
- const trimmed = word.replace(/^ +/, "");
64
- currentLine += trimmed;
65
- currentWidth = trimmed.length;
144
+ commit();
145
+ for (const t of carried) {
146
+ lineTokens.push(t);
147
+ lineWidth += visibleLen(t);
66
148
  }
149
+ lineTokens.push(token);
150
+ lineWidth += tokenWidth;
151
+ lastVisibleIdx = lineTokens.length - 1;
67
152
  }
68
153
  }
69
- if (currentLine.length > 0) {
70
- result.push(currentLine);
154
+ if (lineWidth > 0) {
155
+ result.push(lineTokens.join(""));
71
156
  }
72
157
  return result;
73
158
  }
@@ -173,17 +258,17 @@ export class MarkdownRenderer {
173
258
  while (row.length < numCols)
174
259
  row.push("");
175
260
  }
176
- // Calculate column widths from content
261
+ // Width from rendered cell — raw `**bold**` over-counts by 4 per pair.
177
262
  const colWidths = new Array(numCols).fill(0);
178
263
  for (const row of dataRows) {
179
264
  for (let c = 0; c < numCols; c++) {
180
- colWidths[c] = Math.max(colWidths[c], visibleLen(row[c]));
265
+ colWidths[c] = Math.max(colWidths[c], visibleLen(this.renderInline(row[c])));
181
266
  }
182
267
  }
183
- // Shrink columns proportionally if total exceeds content width
184
- // Account for separators: " │ " between cols (3 chars each) + 2 outer padding
268
+ // Tables bypass the prose width cap borders guide the eye, so wider is fine.
185
269
  const separatorWidth = (numCols - 1) * 3;
186
- const availableWidth = this.contentWidth - separatorWidth;
270
+ const tableWidth = Math.max(10, this.width - 2);
271
+ const availableWidth = tableWidth - separatorWidth;
187
272
  const totalWidth = colWidths.reduce((a, b) => a + b, 0);
188
273
  if (totalWidth > availableWidth && availableWidth > numCols) {
189
274
  const scale = availableWidth / totalWidth;
@@ -201,7 +286,10 @@ export class MarkdownRenderer {
201
286
  const isHeader = hasHeader && i === 0;
202
287
  const cells = row.map((cell, c) => {
203
288
  const w = colWidths[c];
204
- const text = visibleLen(cell) > w ? truncateToWidth(cell, w) : padEndToWidth(cell, w);
289
+ const rendered = this.renderInline(cell);
290
+ const text = visibleLen(rendered) > w
291
+ ? truncateAnsiToWidth(rendered, w)
292
+ : padEndToWidth(rendered, w);
205
293
  return isHeader ? `${p.bold}${text}${p.reset}` : text;
206
294
  });
207
295
  this.writeLine(`${p.dim}│${p.reset} ${cells.join(` ${p.dim}│${p.reset} `)} ${p.dim}│${p.reset}`);
@@ -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.