agent-sh 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,12 +5,22 @@
5
5
  * terminal input bytes and receive high-level actions back. Buffer and
6
6
  * cursor state are public for rendering.
7
7
  */
8
+ // ── Kitty protocol keycode → readable name ──────────────────────
9
+ const KITTY_KEY_NAMES = {
10
+ 9: "tab", 13: "enter", 27: "escape", 127: "backspace",
11
+ };
8
12
  // ── Line editor ─────────────────────────────────────────────────
9
13
  export class LineEditor {
10
14
  buffer = "";
11
15
  cursor = 0;
16
+ pendingSeq = ""; // buffered incomplete escape sequence
12
17
  /** Process raw terminal input, return actions for the consumer. */
13
18
  feed(data) {
19
+ // If we had a pending incomplete escape sequence, prepend it
20
+ if (this.pendingSeq) {
21
+ data = this.pendingSeq + data;
22
+ this.pendingSeq = "";
23
+ }
14
24
  const actions = [];
15
25
  let i = 0;
16
26
  while (i < data.length) {
@@ -18,16 +28,66 @@ export class LineEditor {
18
28
  // ── Escape sequences ────────────────────────────────
19
29
  if (ch === "\x1b") {
20
30
  const next = data[i + 1];
21
- // Bare Escape (nothing follows in this chunk)
31
+ // Incomplete escape buffer and wait for next feed()
22
32
  if (next == null) {
23
- actions.push({ action: "cancel" });
33
+ this.pendingSeq = "\x1b";
24
34
  i++;
25
35
  continue;
26
36
  }
27
37
  // CSI sequence: \x1b[...
28
38
  if (next === "[") {
29
- const { consumed } = this.handleCSI(data, i, actions);
30
- i += consumed;
39
+ const { consumed, incomplete } = this.handleCSI(data, i, actions);
40
+ if (incomplete) {
41
+ this.pendingSeq = data.slice(i, i + consumed);
42
+ i += consumed;
43
+ }
44
+ else {
45
+ i += consumed;
46
+ }
47
+ continue;
48
+ }
49
+ // SS3 sequence: \x1bO... (application cursor mode — arrow keys, Home, End)
50
+ if (next === "O") {
51
+ const ss3Final = data[i + 2];
52
+ if (ss3Final == null) {
53
+ // Incomplete — buffer for next feed()
54
+ this.pendingSeq = data.slice(i, i + 2);
55
+ i += 2;
56
+ continue;
57
+ }
58
+ i += 3; // consume \x1b O <final>
59
+ switch (ss3Final) {
60
+ case "A":
61
+ actions.push({ action: "arrow-up" });
62
+ break;
63
+ case "B":
64
+ actions.push({ action: "arrow-down" });
65
+ break;
66
+ case "C":
67
+ if (this.cursor < this.buffer.length) {
68
+ this.cursor++;
69
+ actions.push({ action: "changed" });
70
+ }
71
+ break;
72
+ case "D":
73
+ if (this.cursor > 0) {
74
+ this.cursor--;
75
+ actions.push({ action: "changed" });
76
+ }
77
+ break;
78
+ case "H": // Home
79
+ if (this.cursor > 0) {
80
+ this.cursor = 0;
81
+ actions.push({ action: "changed" });
82
+ }
83
+ break;
84
+ case "F": // End
85
+ if (this.cursor < this.buffer.length) {
86
+ this.cursor = this.buffer.length;
87
+ actions.push({ action: "changed" });
88
+ }
89
+ break;
90
+ }
31
91
  continue;
32
92
  }
33
93
  // Alt/Option + key: \x1b followed by char
@@ -56,98 +116,10 @@ export class LineEditor {
56
116
  continue;
57
117
  }
58
118
  // ── Control characters ──────────────────────────────
59
- if (ch === "\r") {
60
- actions.push({ action: "submit", buffer: this.buffer });
61
- i++;
62
- continue;
63
- }
64
- if (ch === "\x03") {
65
- actions.push({ action: "cancel" });
66
- i++;
67
- continue;
68
- }
69
- if (ch === "\t") {
70
- actions.push({ action: "tab" });
71
- i++;
72
- continue;
73
- }
74
- if (ch === "\x7f" || ch === "\b") {
75
- // Backspace
76
- if (this.buffer.length === 0) {
77
- actions.push({ action: "delete-empty" });
78
- }
79
- else if (this.cursor > 0) {
80
- this.buffer = this.buffer.slice(0, this.cursor - 1) + this.buffer.slice(this.cursor);
81
- this.cursor--;
82
- actions.push({ action: "changed" });
83
- }
84
- i++;
85
- continue;
86
- }
87
- // Ctrl-A: home
88
- if (ch === "\x01") {
89
- if (this.cursor > 0) {
90
- this.cursor = 0;
91
- actions.push({ action: "changed" });
92
- }
93
- i++;
94
- continue;
95
- }
96
- // Ctrl-E: end
97
- if (ch === "\x05") {
98
- if (this.cursor < this.buffer.length) {
99
- this.cursor = this.buffer.length;
100
- actions.push({ action: "changed" });
101
- }
102
- i++;
103
- continue;
104
- }
105
- // Ctrl-B: back one char
106
- if (ch === "\x02") {
107
- if (this.cursor > 0) {
108
- this.cursor--;
109
- actions.push({ action: "changed" });
110
- }
111
- i++;
112
- continue;
113
- }
114
- // Ctrl-F: forward one char
115
- if (ch === "\x06") {
116
- if (this.cursor < this.buffer.length) {
117
- this.cursor++;
118
- actions.push({ action: "changed" });
119
- }
120
- i++;
121
- continue;
122
- }
123
- // Ctrl-U: delete to start of line
124
- if (ch === "\x15") {
125
- if (this.cursor > 0) {
126
- this.buffer = this.buffer.slice(this.cursor);
127
- this.cursor = 0;
128
- actions.push({ action: "changed" });
129
- }
130
- i++;
131
- continue;
132
- }
133
- // Ctrl-K: delete to end of line
134
- if (ch === "\x0b") {
135
- if (this.cursor < this.buffer.length) {
136
- this.buffer = this.buffer.slice(0, this.cursor);
137
- actions.push({ action: "changed" });
138
- }
139
- i++;
140
- continue;
141
- }
142
- // Ctrl-W: delete word backward
143
- if (ch === "\x17") {
144
- if (this.deleteWordBackward())
145
- actions.push({ action: "changed" });
146
- i++;
147
- continue;
148
- }
149
- // Other control chars — ignore
150
- if (ch.charCodeAt(0) < 0x20) {
119
+ if (ch.charCodeAt(0) < 0x20 || ch === "\x7f") {
120
+ const action = this.handleControl(ch);
121
+ if (action)
122
+ actions.push(action);
151
123
  i++;
152
124
  continue;
153
125
  }
@@ -159,14 +131,123 @@ export class LineEditor {
159
131
  }
160
132
  return actions;
161
133
  }
134
+ /** Check if there's a pending incomplete escape sequence. */
135
+ hasPendingEscape() {
136
+ return this.pendingSeq.length > 0;
137
+ }
138
+ /** Flush a pending sequence — treat bare \x1b as cancel, discard incomplete CSI. */
139
+ flushPendingEscape() {
140
+ if (!this.pendingSeq)
141
+ return [];
142
+ const wasBarEscape = this.pendingSeq === "\x1b";
143
+ this.pendingSeq = "";
144
+ return wasBarEscape ? [{ action: "cancel" }] : [];
145
+ }
162
146
  clear() {
163
147
  this.buffer = "";
164
148
  this.cursor = 0;
149
+ this.pendingSeq = "";
150
+ }
151
+ // ── Key bindings ────────────────────────────────────────────
152
+ //
153
+ // Single source of truth for all keybindings. Both legacy control
154
+ // characters and kitty protocol sequences resolve to a key name
155
+ // and look it up here. To add a binding, add one entry.
156
+ bindings = {
157
+ "enter": () => ({ action: "submit", buffer: this.buffer }),
158
+ "ctrl+c": () => ({ action: "cancel" }),
159
+ "tab": () => ({ action: "tab" }),
160
+ "backspace": () => this.deleteBackward(),
161
+ "ctrl+d": () => this.buffer.length === 0 ? { action: "delete-empty" } : this.deleteForward(),
162
+ "ctrl+a": () => this.moveTo(0),
163
+ "ctrl+e": () => this.moveTo(this.buffer.length),
164
+ "ctrl+b": () => this.moveTo(this.cursor - 1),
165
+ "ctrl+f": () => this.moveTo(this.cursor + 1),
166
+ "ctrl+u": () => this.deleteRange(0, this.cursor),
167
+ "ctrl+k": () => this.deleteRange(this.cursor, this.buffer.length),
168
+ "ctrl+w": () => this.deleteWordBackward() ? { action: "changed" } : null,
169
+ "shift+enter": () => this.insertAt("\n"),
170
+ "shift+tab": () => ({ action: "shift+tab" }),
171
+ };
172
+ /** Resolve a key name from the bindings table and execute it. */
173
+ dispatch(key) {
174
+ return this.bindings[key]?.() ?? null;
175
+ }
176
+ // ── Legacy control character mapping ───────────────────────
177
+ /** Map a legacy control character to a key name. */
178
+ static CTRL_MAP = {
179
+ "\r": "enter", "\x03": "ctrl+c", "\t": "tab",
180
+ "\x7f": "backspace", "\b": "backspace",
181
+ "\x01": "ctrl+a", "\x02": "ctrl+b", "\x04": "ctrl+d",
182
+ "\x05": "ctrl+e", "\x06": "ctrl+f", "\x0b": "ctrl+k",
183
+ "\x15": "ctrl+u", "\x17": "ctrl+w",
184
+ };
185
+ handleControl(ch) {
186
+ const key = LineEditor.CTRL_MAP[ch];
187
+ return key ? this.dispatch(key) : null;
188
+ }
189
+ // ── Kitty keyboard protocol ────────────────────────────────
190
+ /** Handle a kitty protocol CSI u sequence. Params format: "keycode;modifier". */
191
+ handleKittyKey(params) {
192
+ const [kc, mod] = params.split(";").map(Number);
193
+ const keycode = kc;
194
+ const mods = (mod ?? 1) - 1; // kitty modifier bits
195
+ // Build key name from modifier + keycode
196
+ const modNames = [];
197
+ if (mods & 4)
198
+ modNames.push("ctrl");
199
+ if (mods & 1)
200
+ modNames.push("shift");
201
+ if (mods & 2)
202
+ modNames.push("alt");
203
+ const keyName = KITTY_KEY_NAMES[keycode] ?? String.fromCharCode(keycode);
204
+ const fullName = [...modNames, keyName].join("+");
205
+ // Try exact binding first, then fall back to ctrl char mapping
206
+ return this.dispatch(fullName)
207
+ ?? ((mods & 4) && keycode >= 97 && keycode <= 122
208
+ ? this.dispatch(`ctrl+${String.fromCharCode(keycode)}`)
209
+ : null)
210
+ ?? (mods === 0 ? this.handleControl(String.fromCharCode(keycode)) : null);
211
+ }
212
+ // ── Editing primitives ─────────────────────────────────────
213
+ insertAt(ch) {
214
+ this.buffer = this.buffer.slice(0, this.cursor) + ch + this.buffer.slice(this.cursor);
215
+ this.cursor++;
216
+ return { action: "changed" };
217
+ }
218
+ moveTo(pos) {
219
+ const clamped = Math.max(0, Math.min(pos, this.buffer.length));
220
+ if (clamped === this.cursor)
221
+ return null;
222
+ this.cursor = clamped;
223
+ return { action: "changed" };
224
+ }
225
+ deleteBackward() {
226
+ if (this.buffer.length === 0)
227
+ return { action: "delete-empty" };
228
+ if (this.cursor <= 0)
229
+ return null;
230
+ this.buffer = this.buffer.slice(0, this.cursor - 1) + this.buffer.slice(this.cursor);
231
+ this.cursor--;
232
+ return { action: "changed" };
233
+ }
234
+ deleteForward() {
235
+ if (this.cursor >= this.buffer.length)
236
+ return null;
237
+ this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(this.cursor + 1);
238
+ return { action: "changed" };
239
+ }
240
+ deleteRange(start, end) {
241
+ if (start >= end)
242
+ return null;
243
+ this.buffer = this.buffer.slice(0, start) + this.buffer.slice(end);
244
+ this.cursor = start;
245
+ return { action: "changed" };
165
246
  }
166
247
  // ── CSI sequence handling ───────────────────────────────────
167
248
  /**
168
249
  * Parse and handle a CSI sequence (\x1b[...) starting at `start`.
169
- * Returns the number of bytes consumed.
250
+ * Returns the number of bytes consumed and whether the sequence was incomplete.
170
251
  */
171
252
  handleCSI(data, start, actions) {
172
253
  // Skip \x1b[
@@ -177,8 +258,12 @@ export class LineEditor {
177
258
  params += data[j];
178
259
  j++;
179
260
  }
180
- const final = j < data.length ? data[j] : "";
181
- const consumed = j - start + (final ? 1 : 0);
261
+ // If we ran out of data before the final byte, sequence is incomplete
262
+ if (j >= data.length) {
263
+ return { consumed: j - start, incomplete: true };
264
+ }
265
+ const final = data[j];
266
+ const consumed = j - start + 1;
182
267
  // Dispatch on final byte
183
268
  switch (final) {
184
269
  case "A": // Up arrow
@@ -223,6 +308,15 @@ export class LineEditor {
223
308
  actions.push({ action: "changed" });
224
309
  }
225
310
  break;
311
+ case "Z": // Shift+Tab (legacy CSI sequence)
312
+ actions.push({ action: "shift+tab" });
313
+ break;
314
+ case "u": { // Kitty keyboard protocol: \x1b[<keycode>;<modifier>u
315
+ const action = this.handleKittyKey(params);
316
+ if (action)
317
+ actions.push(action);
318
+ break;
319
+ }
226
320
  case "~": // Extended keys: Delete (3~), etc.
227
321
  if (params === "3") {
228
322
  // Delete key: delete char under cursor
@@ -108,13 +108,13 @@ export class MarkdownRenderer {
108
108
  }
109
109
  }
110
110
  printTopBorder() {
111
- const w = Math.min(this.contentWidth, 40);
112
- process.stdout.write(`${p.dim}${p.accent}${"─".repeat(w)}${p.reset}\n`);
111
+ const termW = process.stdout.columns || 80;
112
+ process.stdout.write(`${p.dim}${p.accent}${"─".repeat(termW)}${p.reset}\n`);
113
113
  this.firstLine = true;
114
114
  }
115
115
  printBottomBorder() {
116
- const w = Math.min(this.contentWidth, 40);
117
- process.stdout.write(`${p.dim}${p.accent}${"─".repeat(w)}${p.reset}\n`);
116
+ const termW = process.stdout.columns || 80;
117
+ process.stdout.write(`${p.dim}${p.accent}${"─".repeat(termW)}${p.reset}\n`);
118
118
  }
119
119
  processBuffer() {
120
120
  const lines = this.buffer.split("\n");
@@ -38,5 +38,7 @@ export declare function createSpinner(): SpinnerState;
38
38
  */
39
39
  export declare function startSpinner(label: string, opts?: {
40
40
  color?: string;
41
+ hint?: string;
42
+ startTime?: number;
41
43
  }): SpinnerState;
42
44
  export declare function stopSpinner(state: SpinnerState): void;
@@ -61,28 +61,53 @@ export function renderToolCall(tool, width) {
61
61
  return [`${p.warning}${text}${p.reset}`];
62
62
  }
63
63
  const lines = [];
64
- lines.push(`${p.warning}${p.bold}${icon} ${tool.title}${p.reset}`);
64
+ // Build a compact detail string to append after the title
65
+ let detail = "";
65
66
  if (mode === "full") {
66
- // Show file locations if available
67
- if (tool.locations && tool.locations.length > 0) {
68
- for (const loc of tool.locations) {
69
- const lineInfo = loc.line ? `:${loc.line}` : "";
70
- lines.push(` ${p.dim}${loc.path}${lineInfo}${p.reset}`);
71
- }
72
- }
73
- // Show command string for terminal tools
74
67
  if (tool.command) {
75
- const maxCmdW = Math.max(1, width - 4);
76
- const cmd = tool.command.length > maxCmdW
77
- ? tool.command.slice(0, maxCmdW - 1) + "…"
78
- : tool.command;
79
- lines.push(` ${p.dim}$ ${cmd}${p.reset}`);
68
+ detail = `$ ${tool.command}`;
69
+ }
70
+ else if (tool.locations && tool.locations.length > 0) {
71
+ const loc = tool.locations[0];
72
+ const lineInfo = loc.line ? `:${loc.line}` : "";
73
+ detail = `${loc.path}${lineInfo}`;
74
+ }
75
+ else if (tool.rawInput) {
76
+ const raw = tool.rawInput;
77
+ if (raw && typeof raw === "object") {
78
+ if (typeof raw.command === "string") {
79
+ detail = `$ ${raw.command}`;
80
+ }
81
+ else if (typeof raw.operation === "string") {
82
+ detail = raw.operation;
83
+ if (raw.ids && Array.isArray(raw.ids)) {
84
+ detail += ` #${raw.ids.join(",")}`;
85
+ }
86
+ if (typeof raw.query === "string") {
87
+ detail += ` "${raw.query}"`;
88
+ }
89
+ }
90
+ else {
91
+ detail = formatRawInput(tool.rawInput, width - tool.title.length - 6);
92
+ }
93
+ }
80
94
  }
81
- // Show raw input args for non-terminal, non-file tools
82
- if (!tool.command && !tool.locations?.length && tool.rawInput) {
83
- const detail = formatRawInput(tool.rawInput, width - 4);
84
- if (detail)
85
- lines.push(` ${p.dim}${detail}${p.reset}`);
95
+ }
96
+ // Render as single line: title: detail
97
+ const maxDetailW = Math.max(1, width - tool.title.length - 6);
98
+ if (detail) {
99
+ if (detail.length > maxDetailW)
100
+ detail = detail.slice(0, maxDetailW - 1) + "…";
101
+ lines.push(`${p.warning}${p.bold}${icon} ${tool.title}${p.reset}${p.dim}: ${detail}${p.reset}`);
102
+ }
103
+ else {
104
+ lines.push(`${p.warning}${p.bold}${icon} ${tool.title}${p.reset}`);
105
+ }
106
+ // Show additional file locations on separate lines (if more than one)
107
+ if (mode === "full" && tool.locations && tool.locations.length > 1) {
108
+ for (const loc of tool.locations.slice(1)) {
109
+ const lineInfo = loc.line ? `:${loc.line}` : "";
110
+ lines.push(` ${p.dim}${loc.path}${lineInfo}${p.reset}`);
86
111
  }
87
112
  }
88
113
  return lines;
@@ -168,12 +193,15 @@ export function createSpinner() {
168
193
  */
169
194
  export function startSpinner(label, opts) {
170
195
  const state = createSpinner();
196
+ if (opts?.startTime)
197
+ state.startTime = opts.startTime;
171
198
  const color = opts?.color ?? p.accent;
199
+ const hint = opts?.hint ? ` ${p.dim}${opts.hint}${p.reset}` : "";
172
200
  state.interval = setInterval(() => {
173
201
  const frame = SPINNER_FRAMES[state.frame % SPINNER_FRAMES.length];
174
202
  const elapsed = formatElapsed(Date.now() - state.startTime);
175
203
  const timer = elapsed ? ` ${p.dim}${elapsed}${p.reset}` : "";
176
- process.stdout.write(`\r ${color}${frame} ${label}...${p.reset}${timer}\x1b[K`);
204
+ process.stdout.write(`\r ${color}${frame} ${label}...${p.reset}${timer}${hint}\x1b[K`);
177
205
  state.frame++;
178
206
  }, 80);
179
207
  return state;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Pi extension: agent-sh tools (user_shell + shell_recall).
3
+ *
4
+ * When running inside agent-sh, registers tools that communicate with
5
+ * the user's live terminal via a Unix domain socket (JSON-RPC 2.0).
6
+ *
7
+ * - user_shell: execute commands in the user's live PTY
8
+ * - shell_recall: search/expand/browse session exchange history
9
+ *
10
+ * Socket path comes from the AGENT_SH_SOCKET env var.
11
+ * When not running inside agent-sh, the extension silently does nothing.
12
+ */
13
+
14
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
15
+ import { Type } from "@sinclair/typebox";
16
+ import { createConnection } from "node:net";
17
+
18
+ const SOCKET_PATH = process.env.AGENT_SH_SOCKET;
19
+
20
+ export default function (pi: ExtensionAPI): void {
21
+ if (!SOCKET_PATH) return; // Not running inside agent-sh
22
+
23
+ pi.registerTool({
24
+ name: "shell_cwd",
25
+ label: "Shell CWD",
26
+ description:
27
+ "Get the user's current working directory in their live shell. " +
28
+ "IMPORTANT: Your internal working directory may differ from the user's actual shell cwd — " +
29
+ "the user may have cd'd after your session started. Call this tool to get the real cwd " +
30
+ "before file operations if you're unsure.",
31
+ promptSnippet:
32
+ "Get the user's real shell cwd (may differ from your internal cwd).",
33
+ parameters: Type.Object({}),
34
+
35
+ async execute() {
36
+ const result = (await callSocket("shell/cwd", {})) as { cwd: string };
37
+ return {
38
+ content: [{ type: "text", text: `User's current working directory: ${result.cwd}` }],
39
+ };
40
+ },
41
+ });
42
+
43
+ pi.registerTool({
44
+ name: "user_shell",
45
+ label: "User Shell",
46
+ description:
47
+ "Execute a command in the user's live terminal session. " +
48
+ "Use this for commands that should affect the user's shell state: " +
49
+ "cd, export, source, pushd/popd, alias, etc. " +
50
+ "The command runs in the user's actual shell with their full environment " +
51
+ "(aliases, functions, PATH), not an isolated subprocess. " +
52
+ "NOTE: Your internal cwd may be stale — the user may have cd'd. " +
53
+ "Check the shell context for [shell cwd:...] labels or call shell_cwd " +
54
+ "to determine the real working directory. Use absolute paths when possible.",
55
+ promptSnippet:
56
+ "Run commands in the user's live shell (cd, export, source — affects their session). " +
57
+ "Your internal cwd may be stale — check shell context or use shell_cwd for the real cwd.",
58
+ parameters: Type.Object({
59
+ command: Type.String({ description: "Shell command to execute in the user's live terminal" }),
60
+ }),
61
+
62
+ async execute(_toolCallId, params) {
63
+ const result = (await callSocket("shell/exec", { command: params.command })) as {
64
+ output: string;
65
+ cwd: string;
66
+ };
67
+ return {
68
+ content: [{ type: "text", text: result.output || "(no output)" }],
69
+ };
70
+ },
71
+ });
72
+
73
+ pi.registerTool({
74
+ name: "shell_recall",
75
+ label: "Shell Recall",
76
+ description:
77
+ "Retrieve past shell commands, agent responses, and tool executions from the session history. " +
78
+ "Use this to look up truncated output, search for previous commands or errors, " +
79
+ "or browse recent exchanges. Each entry shows [shell cwd:...] so you can see " +
80
+ "which directory commands were run in. Operations: " +
81
+ '"browse" lists recent exchange summaries with line counts, ' +
82
+ '"search" finds exchanges matching a regex query, ' +
83
+ '"expand" retrieves content by exchange ID (use start/end for specific line ranges).',
84
+ promptSnippet:
85
+ "Look up session history — search past commands/output, expand truncated exchanges, or browse recent activity.",
86
+ parameters: Type.Object({
87
+ operation: Type.Optional(
88
+ Type.Union([Type.Literal("search"), Type.Literal("expand"), Type.Literal("browse")], {
89
+ description: 'Operation to perform (default: "browse")',
90
+ }),
91
+ ),
92
+ query: Type.Optional(
93
+ Type.String({ description: 'Search query — supports regex (required for "search")' }),
94
+ ),
95
+ ids: Type.Optional(
96
+ Type.Array(Type.Number(), {
97
+ description: 'Exchange IDs to expand (required for "expand")',
98
+ }),
99
+ ),
100
+ start: Type.Optional(
101
+ Type.Number({ description: "Start line number, 1-indexed (optional, for expand)" }),
102
+ ),
103
+ end: Type.Optional(
104
+ Type.Number({ description: "End line number, inclusive (optional, for expand)" }),
105
+ ),
106
+ }),
107
+
108
+ async execute(_toolCallId, params) {
109
+ const result = (await callSocket("shell/recall", {
110
+ operation: params.operation || "browse",
111
+ query: params.query,
112
+ ids: params.ids,
113
+ start: params.start,
114
+ end: params.end,
115
+ })) as { result: string };
116
+ return {
117
+ content: [{ type: "text", text: result.result || "(no results)" }],
118
+ };
119
+ },
120
+ });
121
+ }
122
+
123
+ // -- agent-sh socket client (JSON-RPC 2.0) --
124
+
125
+ let rpcId = 0;
126
+
127
+ function callSocket(method: string, params?: Record<string, unknown>): Promise<unknown> {
128
+ return new Promise((resolve, reject) => {
129
+ const conn = createConnection(SOCKET_PATH!);
130
+ let buffer = "";
131
+
132
+ conn.on("connect", () => {
133
+ const msg = { jsonrpc: "2.0", id: ++rpcId, method, params: params ?? {} };
134
+ conn.write(JSON.stringify(msg) + "\n");
135
+ });
136
+
137
+ conn.on("data", (chunk) => {
138
+ buffer += chunk.toString();
139
+ const newlineIdx = buffer.indexOf("\n");
140
+ if (newlineIdx === -1) return;
141
+
142
+ const line = buffer.slice(0, newlineIdx).trim();
143
+ conn.destroy();
144
+
145
+ try {
146
+ const response = JSON.parse(line);
147
+ if (response.error) {
148
+ reject(new Error(response.error.message || "RPC error"));
149
+ } else {
150
+ resolve(response.result);
151
+ }
152
+ } catch {
153
+ reject(new Error(`Invalid response from agent-sh: ${line}`));
154
+ }
155
+ });
156
+
157
+ conn.on("error", (err) => {
158
+ reject(new Error(`Failed to connect to agent-sh: ${err.message}`));
159
+ });
160
+
161
+ conn.setTimeout(35_000, () => {
162
+ conn.destroy();
163
+ reject(new Error("Connection to agent-sh timed out"));
164
+ });
165
+ });
166
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A shell-first terminal where any ACP-compatible AI agent is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",