agent-sh 0.1.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.
package/dist/shell.js CHANGED
@@ -10,7 +10,9 @@ export class Shell {
10
10
  inputHandler;
11
11
  outputParser;
12
12
  paused = false;
13
+ echoSkip = false;
13
14
  agentActive = false;
15
+ isZsh = false;
14
16
  tmpDir;
15
17
  constructor(opts) {
16
18
  // Build environment — filter out undefined values (node-pty's native
@@ -38,6 +40,7 @@ export class Shell {
38
40
  let shellArgs;
39
41
  const osc7Cmd = 'printf "\\e]7;file://%s%s\\a" "$(hostname)" "$PWD"';
40
42
  const promptMarker = 'printf "\\e]9999;PROMPT\\a"';
43
+ this.isZsh = isZsh;
41
44
  if (isZsh) {
42
45
  // For zsh: use ZDOTDIR to source user's real config, then append
43
46
  // our hooks via precmd_functions (additive — doesn't clobber p10k/omz).
@@ -68,6 +71,14 @@ export class Shell {
68
71
  " }",
69
72
  "fi",
70
73
  "zle -N zle-line-init __agent_sh_line_init",
74
+ "",
75
+ "# Hidden widget to trigger prompt redraw from Node.js side",
76
+ "# Bound to an unused escape sequence that no real key produces",
77
+ "__agent_sh_redraw() {",
78
+ " zle reset-prompt",
79
+ "}",
80
+ "zle -N __agent_sh_redraw",
81
+ "bindkey '\\e[9999~' __agent_sh_redraw",
71
82
  ].join("\n") + "\n");
72
83
  env.ZDOTDIR = this.tmpDir;
73
84
  shellArgs = ["--no-globalrcs"];
@@ -88,6 +99,18 @@ export class Shell {
88
99
  ].join("\n") + "\n");
89
100
  shellArgs = ["--rcfile", path.join(this.tmpDir, ".bashrc")];
90
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
+ }
91
114
  this.ptyProcess = pty.spawn(shellBin, shellArgs, {
92
115
  name: "xterm-256color",
93
116
  cols: opts.cols,
@@ -95,6 +118,18 @@ export class Shell {
95
118
  cwd: opts.cwd,
96
119
  env,
97
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
+ }
98
133
  this.bus = opts.bus;
99
134
  this.outputParser = new OutputParser(opts.bus, opts.cwd);
100
135
  // Ensure temp dir cleanup on abnormal exit (SIGKILL won't fire this,
@@ -131,10 +166,11 @@ export class Shell {
131
166
  this.ptyProcess.write(data);
132
167
  }
133
168
  /**
134
- * Lightweight redraw: replay just the last line of the shell's prompt
135
- * (e.g. p10k's "❯ "). This works because agent input mode only overwrites
136
- * the final prompt linethe path bar above is still intact. The last
137
- * line is linear text (colors + chars + clear-to-end), no cursor positioning.
169
+ * Lightweight redraw: ask the shell to redraw its own prompt via a hidden
170
+ * ZLE widget (zsh) bound to \e[9999~. The shell knows how to draw its
171
+ * prompt correctlywe don't try to replay captured bytes.
172
+ *
173
+ * For bash, falls back to sending \n for a fresh prompt cycle.
138
174
  */
139
175
  redrawPrompt() {
140
176
  const result = this.bus.emitPipe("shell:redraw-prompt", {
@@ -142,12 +178,12 @@ export class Shell {
142
178
  handled: false,
143
179
  });
144
180
  if (!result.handled) {
145
- const lastLine = this.outputParser.getLastPromptLine();
146
- if (lastLine) {
147
- process.stdout.write("\r" + lastLine);
181
+ if (this.isZsh) {
182
+ // Trigger the hidden ZLE widget — zle reset-prompt redraws cleanly
183
+ this.ptyProcess.write("\x1b[9999~");
148
184
  }
149
185
  else {
150
- // Fallback: send \n for a fresh prompt cycle
186
+ // Bash: no zle reset-prompt equivalent, use fresh prompt cycle
151
187
  this.ptyProcess.write("\n");
152
188
  }
153
189
  }
@@ -167,9 +203,20 @@ export class Shell {
167
203
  setupOutput() {
168
204
  this.ptyProcess.onData((data) => {
169
205
  this.outputParser.processData(data);
170
- if (!this.paused) {
171
- 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;
172
218
  }
219
+ process.stdout.write(data);
173
220
  });
174
221
  }
175
222
  setupInput() {
@@ -191,6 +238,7 @@ export class Shell {
191
238
  this.bus.on("agent:processing-done", () => {
192
239
  this.paused = false;
193
240
  this.agentActive = false;
241
+ this.echoSkip = true;
194
242
  this.freshPrompt();
195
243
  });
196
244
  // Permission prompts need stdout unpaused so the interactive UI renders,
@@ -202,8 +250,38 @@ export class Shell {
202
250
  this.paused = true;
203
251
  return payload;
204
252
  });
253
+ // Shell exec: write a command to the live PTY and capture its output.
254
+ // stdout is paused during agent processing, so PTY output flows through
255
+ // OutputParser (for OSC detection) but never reaches the terminal.
256
+ this.bus.onPipeAsync("shell:exec-request", async (payload) => {
257
+ this.echoSkip = true;
258
+ this.paused = false;
259
+ process.stdout.write("\n");
260
+ const output = await new Promise((resolve, reject) => {
261
+ const timeout = setTimeout(() => {
262
+ this.bus.off("shell:command-done", handler);
263
+ this.ptyProcess.write("\x03");
264
+ reject(new Error("Shell exec timed out after 30s"));
265
+ }, 30_000);
266
+ const handler = (e) => {
267
+ clearTimeout(timeout);
268
+ this.bus.off("shell:command-done", handler);
269
+ resolve({ output: e.output, cwd: e.cwd });
270
+ };
271
+ this.bus.on("shell:command-done", handler);
272
+ this.outputParser.onCommandEntered(payload.command, this.outputParser.getCwd());
273
+ this.ptyProcess.write(payload.command + "\r");
274
+ });
275
+ this.paused = true;
276
+ this.echoSkip = false;
277
+ return { ...payload, output: output.output, cwd: output.cwd, done: true };
278
+ });
205
279
  }
206
280
  // ── Public API (used by index.ts) ──
281
+ /** Temp directory used for shell config and sockets. */
282
+ getTmpDir() {
283
+ return this.tmpDir;
284
+ }
207
285
  resize(cols, rows) {
208
286
  this.ptyProcess.resize(cols, rows);
209
287
  }
package/dist/types.d.ts CHANGED
@@ -8,6 +8,8 @@ export interface AgentShellConfig {
8
8
  shell?: string;
9
9
  model?: string;
10
10
  extensions?: string[];
11
+ /** Full shell environment (from user's rc files) for agent subprocess. */
12
+ shellEnv?: Record<string, string>;
11
13
  }
12
14
  /**
13
15
  * Context passed to user/third-party extensions.
@@ -47,6 +49,8 @@ export type Exchange = {
47
49
  exitCode: number | null;
48
50
  outputLines: number;
49
51
  outputBytes: number;
52
+ /** Who initiated this command: "user" (typed) or "agent" (via user_shell). */
53
+ source: "user" | "agent";
50
54
  } | {
51
55
  type: "agent_query";
52
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) {
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Minimal line editor with readline-style keybindings.
3
+ *
4
+ * Pure logic — no I/O, no rendering, no event bus. Consumers feed raw
5
+ * terminal input bytes and receive high-level actions back. Buffer and
6
+ * cursor state are public for rendering.
7
+ */
8
+ export type LineEditAction = {
9
+ action: "changed";
10
+ } | {
11
+ action: "submit";
12
+ buffer: string;
13
+ } | {
14
+ action: "cancel";
15
+ } | {
16
+ action: "delete-empty";
17
+ } | {
18
+ action: "tab";
19
+ } | {
20
+ action: "shift+tab";
21
+ } | {
22
+ action: "arrow-up";
23
+ } | {
24
+ action: "arrow-down";
25
+ };
26
+ export declare class LineEditor {
27
+ buffer: string;
28
+ cursor: number;
29
+ private pendingSeq;
30
+ /** Process raw terminal input, return actions for the consumer. */
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[];
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;
50
+ /**
51
+ * Parse and handle a CSI sequence (\x1b[...) starting at `start`.
52
+ * Returns the number of bytes consumed and whether the sequence was incomplete.
53
+ */
54
+ private handleCSI;
55
+ private wordBackward;
56
+ private wordForward;
57
+ private deleteWordBackward;
58
+ private deleteWordForward;
59
+ }