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.
@@ -24,13 +24,27 @@ function sendError(id, code, message) {
24
24
  send({ id, error: { code, message } });
25
25
  }
26
26
  // ── Tool definition ─────────────────────────────────────────────
27
+ const SHELL_CWD_TOOL = {
28
+ name: "shell_cwd",
29
+ description: "Get the user's current working directory in their live shell. " +
30
+ "IMPORTANT: Your internal working directory may differ from the user's actual shell cwd — " +
31
+ "the user may have cd'd after your session started. Call this tool to get the real cwd " +
32
+ "before file operations if you're unsure.",
33
+ inputSchema: {
34
+ type: "object",
35
+ properties: {},
36
+ },
37
+ };
27
38
  const USER_SHELL_TOOL = {
28
39
  name: "user_shell",
29
40
  description: "Execute a command in the user's live terminal session. " +
30
41
  "Use this for commands that should affect the user's shell state: " +
31
42
  "cd, export, source, pushd/popd, alias, etc. " +
32
43
  "The command runs in the user's actual shell with their full environment " +
33
- "(aliases, functions, PATH), not an isolated subprocess.",
44
+ "(aliases, functions, PATH), not an isolated subprocess. " +
45
+ "NOTE: Your internal cwd may be stale — the user may have cd'd. " +
46
+ "Check the shell context for [shell cwd:...] labels or call shell_cwd " +
47
+ "to determine the real working directory. Use absolute paths when possible.",
34
48
  inputSchema: {
35
49
  type: "object",
36
50
  properties: {
@@ -46,10 +60,11 @@ const SHELL_RECALL_TOOL = {
46
60
  name: "shell_recall",
47
61
  description: "Retrieve past shell commands, agent responses, and tool executions from the session history. " +
48
62
  "Use this to look up truncated output, search for previous commands or errors, " +
49
- "or browse recent exchanges. Three operations: " +
50
- '"search" finds exchanges matching a query, ' +
51
- '"expand" retrieves full untruncated content by exchange ID, ' +
52
- '"browse" lists recent exchange summaries.',
63
+ "or browse recent exchanges. Each entry shows [shell cwd:...] so you can see " +
64
+ "which directory commands were run in. Operations: " +
65
+ '"browse" lists recent exchange summaries with line counts, ' +
66
+ '"search" finds exchanges matching a regex query, ' +
67
+ '"expand" retrieves content by exchange ID (use start/end for specific line ranges).',
53
68
  inputSchema: {
54
69
  type: "object",
55
70
  properties: {
@@ -60,13 +75,21 @@ const SHELL_RECALL_TOOL = {
60
75
  },
61
76
  query: {
62
77
  type: "string",
63
- description: 'Search query (required for "search" operation)',
78
+ description: 'Search query — supports regex (required for "search" operation)',
64
79
  },
65
80
  ids: {
66
81
  type: "array",
67
82
  items: { type: "number" },
68
83
  description: 'Exchange IDs to expand (required for "expand" operation)',
69
84
  },
85
+ start: {
86
+ type: "number",
87
+ description: "Start line number, 1-indexed (optional, for expand)",
88
+ },
89
+ end: {
90
+ type: "number",
91
+ description: "End line number, inclusive (optional, for expand)",
92
+ },
70
93
  },
71
94
  },
72
95
  };
@@ -127,14 +150,18 @@ async function handleRequest(id, method, params) {
127
150
  // Client acknowledgement — nothing to do
128
151
  break;
129
152
  case "tools/list":
130
- sendResult(id, { tools: [USER_SHELL_TOOL, SHELL_RECALL_TOOL] });
153
+ sendResult(id, { tools: [SHELL_CWD_TOOL, USER_SHELL_TOOL, SHELL_RECALL_TOOL] });
131
154
  break;
132
155
  case "tools/call": {
133
156
  const toolName = params?.name;
134
157
  const args = params?.arguments ?? {};
135
158
  try {
136
159
  let text;
137
- if (toolName === "user_shell") {
160
+ if (toolName === "shell_cwd") {
161
+ const result = await callSocket("shell/cwd", {});
162
+ text = `User's current working directory: ${result.cwd}`;
163
+ }
164
+ else if (toolName === "user_shell") {
138
165
  const command = args.command;
139
166
  if (!command || typeof command !== "string") {
140
167
  sendError(id, -32602, "Missing required parameter: command");
@@ -148,6 +175,8 @@ async function handleRequest(id, method, params) {
148
175
  operation: args.operation || "browse",
149
176
  query: args.query,
150
177
  ids: args.ids,
178
+ start: args.start,
179
+ end: args.end,
151
180
  });
152
181
  text = result.result || "(no results)";
153
182
  }
@@ -0,0 +1,33 @@
1
+ export declare const CONFIG_DIR: string;
2
+ export interface Settings {
3
+ /** Extensions to load (npm packages or file paths). */
4
+ extensions?: string[];
5
+ /** Max agent query history entries to keep. */
6
+ historySize?: number;
7
+ /** Recent exchanges included in agent context window. */
8
+ contextWindowSize?: number;
9
+ /** Context budget in bytes (~4 chars per token). */
10
+ contextBudget?: number;
11
+ /** Shell output lines before truncation kicks in. */
12
+ shellTruncateThreshold?: number;
13
+ /** Lines kept from start of truncated shell output. */
14
+ shellHeadLines?: number;
15
+ /** Lines kept from end of truncated shell output. */
16
+ shellTailLines?: number;
17
+ /** Max lines for recall expand before requiring line ranges. */
18
+ recallExpandMaxLines?: number;
19
+ /** Max command output lines shown inline in TUI. */
20
+ maxCommandOutputLines?: number;
21
+ /** Max read tool output lines shown inline in TUI (0 = hide). */
22
+ readOutputMaxLines?: number;
23
+ /** Max diff lines shown before "ctrl+o to expand". */
24
+ diffMaxLines?: number;
25
+ /** Register MCP server for bridge tools (shell_cwd, user_shell, shell_recall). Default true. */
26
+ enableMcp?: boolean;
27
+ }
28
+ declare const DEFAULTS: Required<Settings>;
29
+ /** Load settings from disk (cached after first call). */
30
+ export declare function getSettings(): Settings & typeof DEFAULTS;
31
+ /** Reset cached settings (for testing or after external edit). */
32
+ export declare function reloadSettings(): void;
33
+ export {};
@@ -0,0 +1,43 @@
1
+ /**
2
+ * User settings loaded from ~/.agent-sh/settings.json.
3
+ *
4
+ * Settings are loaded once at startup and available synchronously
5
+ * throughout the app. Unknown keys are preserved on write.
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import * as os from "node:os";
10
+ export const CONFIG_DIR = path.join(os.homedir(), ".agent-sh");
11
+ const SETTINGS_PATH = path.join(CONFIG_DIR, "settings.json");
12
+ const DEFAULTS = {
13
+ extensions: [],
14
+ historySize: 500,
15
+ contextWindowSize: 20,
16
+ contextBudget: 16384,
17
+ shellTruncateThreshold: 10,
18
+ shellHeadLines: 5,
19
+ shellTailLines: 5,
20
+ recallExpandMaxLines: 100,
21
+ maxCommandOutputLines: 5,
22
+ readOutputMaxLines: 0,
23
+ diffMaxLines: 20,
24
+ enableMcp: true,
25
+ };
26
+ let cached = null;
27
+ /** Load settings from disk (cached after first call). */
28
+ export function getSettings() {
29
+ if (!cached) {
30
+ try {
31
+ const raw = fs.readFileSync(SETTINGS_PATH, "utf-8");
32
+ cached = JSON.parse(raw);
33
+ }
34
+ catch {
35
+ cached = {};
36
+ }
37
+ }
38
+ return { ...DEFAULTS, ...cached };
39
+ }
40
+ /** Reset cached settings (for testing or after external edit). */
41
+ export function reloadSettings() {
42
+ cached = null;
43
+ }
package/dist/shell.d.ts CHANGED
@@ -6,6 +6,7 @@ export declare class Shell implements InputContext {
6
6
  private inputHandler;
7
7
  private outputParser;
8
8
  private paused;
9
+ private echoSkip;
9
10
  private agentActive;
10
11
  private isZsh;
11
12
  private tmpDir?;
package/dist/shell.js CHANGED
@@ -10,6 +10,7 @@ export class Shell {
10
10
  inputHandler;
11
11
  outputParser;
12
12
  paused = false;
13
+ echoSkip = false;
13
14
  agentActive = false;
14
15
  isZsh = false;
15
16
  tmpDir;
@@ -98,6 +99,18 @@ export class Shell {
98
99
  ].join("\n") + "\n");
99
100
  shellArgs = ["--rcfile", path.join(this.tmpDir, ".bashrc")];
100
101
  }
102
+ // Pause stdin before spawning PTY to avoid TTY contention on macOS.
103
+ // The PTY will become the controlling terminal for the child shell.
104
+ const wasRaw = process.stdin.isTTY && process.stdin.isRaw;
105
+ if (process.stdin.isTTY) {
106
+ try {
107
+ process.stdin.setRawMode(false);
108
+ process.stdin.pause();
109
+ }
110
+ catch {
111
+ // Ignore
112
+ }
113
+ }
101
114
  this.ptyProcess = pty.spawn(shellBin, shellArgs, {
102
115
  name: "xterm-256color",
103
116
  cols: opts.cols,
@@ -105,6 +118,18 @@ export class Shell {
105
118
  cwd: opts.cwd,
106
119
  env,
107
120
  });
121
+ // Restore stdin after PTY is created
122
+ if (process.stdin.isTTY) {
123
+ try {
124
+ process.stdin.resume();
125
+ if (wasRaw) {
126
+ process.stdin.setRawMode(true);
127
+ }
128
+ }
129
+ catch {
130
+ // Ignore - will be set up later in index.ts
131
+ }
132
+ }
108
133
  this.bus = opts.bus;
109
134
  this.outputParser = new OutputParser(opts.bus, opts.cwd);
110
135
  // Ensure temp dir cleanup on abnormal exit (SIGKILL won't fire this,
@@ -178,9 +203,20 @@ export class Shell {
178
203
  setupOutput() {
179
204
  this.ptyProcess.onData((data) => {
180
205
  this.outputParser.processData(data);
181
- if (!this.paused) {
182
- process.stdout.write(data);
206
+ if (this.paused)
207
+ return;
208
+ // During user_shell exec, skip the command echo (first line)
209
+ if (this.echoSkip) {
210
+ const nlIdx = data.indexOf("\n");
211
+ if (nlIdx === -1)
212
+ return;
213
+ this.echoSkip = false;
214
+ const rest = data.slice(nlIdx + 1);
215
+ if (rest)
216
+ process.stdout.write(rest);
217
+ return;
183
218
  }
219
+ process.stdout.write(data);
184
220
  });
185
221
  }
186
222
  setupInput() {
@@ -202,6 +238,7 @@ export class Shell {
202
238
  this.bus.on("agent:processing-done", () => {
203
239
  this.paused = false;
204
240
  this.agentActive = false;
241
+ this.echoSkip = true;
205
242
  this.freshPrompt();
206
243
  });
207
244
  // Permission prompts need stdout unpaused so the interactive UI renders,
@@ -217,10 +254,12 @@ export class Shell {
217
254
  // stdout is paused during agent processing, so PTY output flows through
218
255
  // OutputParser (for OSC detection) but never reaches the terminal.
219
256
  this.bus.onPipeAsync("shell:exec-request", async (payload) => {
257
+ this.echoSkip = true;
258
+ this.paused = false;
259
+ process.stdout.write("\n");
220
260
  const output = await new Promise((resolve, reject) => {
221
261
  const timeout = setTimeout(() => {
222
262
  this.bus.off("shell:command-done", handler);
223
- // Kill any hung command
224
263
  this.ptyProcess.write("\x03");
225
264
  reject(new Error("Shell exec timed out after 30s"));
226
265
  }, 30_000);
@@ -230,10 +269,11 @@ export class Shell {
230
269
  resolve({ output: e.output, cwd: e.cwd });
231
270
  };
232
271
  this.bus.on("shell:command-done", handler);
233
- // Start capture and write to PTY
234
272
  this.outputParser.onCommandEntered(payload.command, this.outputParser.getCwd());
235
273
  this.ptyProcess.write(payload.command + "\r");
236
274
  });
275
+ this.paused = true;
276
+ this.echoSkip = false;
237
277
  return { ...payload, output: output.output, cwd: output.cwd, done: true };
238
278
  });
239
279
  }
package/dist/types.d.ts CHANGED
@@ -49,6 +49,8 @@ export type Exchange = {
49
49
  exitCode: number | null;
50
50
  outputLines: number;
51
51
  outputBytes: number;
52
+ /** Who initiated this command: "user" (typed) or "agent" (via user_shell). */
53
+ source: "user" | "agent";
52
54
  } | {
53
55
  type: "agent_query";
54
56
  id: number;
@@ -6,7 +6,10 @@ export declare const RED = "\u001B[31m";
6
6
  export declare const GRAY = "\u001B[90m";
7
7
  export declare const BOLD = "\u001B[1m";
8
8
  export declare const RESET = "\u001B[0m";
9
- /** Measure visible string length, excluding SGR (color/style) sequences. */
9
+ /**
10
+ * Measure visible string length in terminal columns.
11
+ * Excludes SGR (color/style) sequences and accounts for CJK double-width chars.
12
+ */
10
13
  export declare function visibleLen(str: string): number;
11
14
  /** Strip all ANSI escape sequences (SGR, OSC, CSI, private mode) and carriage returns. */
12
15
  export declare function stripAnsi(str: string): string;
@@ -8,9 +8,67 @@ export const GRAY = "\x1b[90m";
8
8
  export const BOLD = "\x1b[1m";
9
9
  export const RESET = "\x1b[0m";
10
10
  // ── ANSI utility functions ───────────────────────────────────
11
- /** Measure visible string length, excluding SGR (color/style) sequences. */
11
+ /**
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.
14
+ */
15
+ function charWidth(codePoint) {
16
+ // CJK Unified Ideographs
17
+ if (codePoint >= 0x4e00 && codePoint <= 0x9fff)
18
+ return 2;
19
+ // CJK Unified Ideographs Extension A
20
+ if (codePoint >= 0x3400 && codePoint <= 0x4dbf)
21
+ return 2;
22
+ // Hangul Syllables
23
+ if (codePoint >= 0xac00 && codePoint <= 0xd7af)
24
+ return 2;
25
+ // CJK Unified Ideographs Extension B-F and other CJK blocks
26
+ if (codePoint >= 0x20000 && codePoint <= 0x2ebef)
27
+ return 2;
28
+ // Fullwidth ASCII variants
29
+ if (codePoint >= 0xff01 && codePoint <= 0xff5e)
30
+ return 2;
31
+ // Halfwidth Katakana (actually narrow, skip)
32
+ // Fullwidth bracket forms
33
+ if (codePoint >= 0xff5f && codePoint <= 0xff60)
34
+ return 2;
35
+ // Fullwidth symbol variants
36
+ if (codePoint >= 0xffe0 && codePoint <= 0xffe6)
37
+ return 2;
38
+ // Japanese hiragana and katakana
39
+ if (codePoint >= 0x3040 && codePoint <= 0x309f)
40
+ return 2;
41
+ if (codePoint >= 0x30a0 && codePoint <= 0x30ff)
42
+ return 2;
43
+ // CJK symbols and punctuation
44
+ if (codePoint >= 0x3000 && codePoint <= 0x303f)
45
+ return 2;
46
+ // Enclosed CJK letters and months
47
+ if (codePoint >= 0x3200 && codePoint <= 0x32ff)
48
+ return 2;
49
+ // CJK compatibility
50
+ if (codePoint >= 0x3300 && codePoint <= 0x33ff)
51
+ return 2;
52
+ // Hangul Jamo
53
+ if (codePoint >= 0x1100 && codePoint <= 0x11ff)
54
+ return 2;
55
+ // Hangul compatibility Jamo
56
+ if (codePoint >= 0x3130 && codePoint <= 0x318f)
57
+ return 2;
58
+ return 1;
59
+ }
60
+ /**
61
+ * Measure visible string length in terminal columns.
62
+ * Excludes SGR (color/style) sequences and accounts for CJK double-width chars.
63
+ */
12
64
  export function visibleLen(str) {
13
- return str.replace(/\x1b\[[^m]*m/g, "").length;
65
+ // First strip ANSI escape sequences
66
+ const cleanStr = str.replace(/\x1b\[[^m]*m/g, "");
67
+ let width = 0;
68
+ for (const char of cleanStr) {
69
+ width += charWidth(char.codePointAt(0) ?? 0);
70
+ }
71
+ return width;
14
72
  }
15
73
  /** Strip all ANSI escape sequences (SGR, OSC, CSI, private mode) and carriage returns. */
16
74
  export function stripAnsi(str) {
@@ -16,6 +16,8 @@ export type LineEditAction = {
16
16
  action: "delete-empty";
17
17
  } | {
18
18
  action: "tab";
19
+ } | {
20
+ action: "shift+tab";
19
21
  } | {
20
22
  action: "arrow-up";
21
23
  } | {
@@ -24,12 +26,30 @@ export type LineEditAction = {
24
26
  export declare class LineEditor {
25
27
  buffer: string;
26
28
  cursor: number;
29
+ private pendingSeq;
27
30
  /** Process raw terminal input, return actions for the consumer. */
28
31
  feed(data: string): LineEditAction[];
32
+ /** Check if there's a pending incomplete escape sequence. */
33
+ hasPendingEscape(): boolean;
34
+ /** Flush a pending sequence — treat bare \x1b as cancel, discard incomplete CSI. */
35
+ flushPendingEscape(): LineEditAction[];
29
36
  clear(): void;
37
+ private readonly bindings;
38
+ /** Resolve a key name from the bindings table and execute it. */
39
+ private dispatch;
40
+ /** Map a legacy control character to a key name. */
41
+ private static readonly CTRL_MAP;
42
+ private handleControl;
43
+ /** Handle a kitty protocol CSI u sequence. Params format: "keycode;modifier". */
44
+ private handleKittyKey;
45
+ private insertAt;
46
+ private moveTo;
47
+ private deleteBackward;
48
+ private deleteForward;
49
+ private deleteRange;
30
50
  /**
31
51
  * Parse and handle a CSI sequence (\x1b[...) starting at `start`.
32
- * Returns the number of bytes consumed.
52
+ * Returns the number of bytes consumed and whether the sequence was incomplete.
33
53
  */
34
54
  private handleCSI;
35
55
  private wordBackward;