agent-sh 0.12.21 → 0.12.23

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.
@@ -4,16 +4,25 @@ export interface ShellHandlers {
4
4
  define: (name: string, fn: (...args: any[]) => any) => void;
5
5
  call: (name: string, ...args: any[]) => any;
6
6
  }
7
+ /**
8
+ * A claim on the shell's stdout-mute state. Acquire from shell.acquire*,
9
+ * pair with release() in a try/finally. Token-shape forces symmetry —
10
+ * the only way to influence the gate is to hold and release a scope.
11
+ */
12
+ export interface ShellScope {
13
+ readonly reason: string;
14
+ release(): void;
15
+ }
7
16
  export declare class Shell implements InputContext {
8
17
  private ptyProcess;
9
18
  private bus;
10
19
  private handlers;
11
20
  private inputHandler;
12
21
  private outputParser;
13
- private paused;
14
- private stdoutHold;
15
- private stdoutShow;
16
- private echoSkip;
22
+ private hardMuteScopes;
23
+ private softMuteScopes;
24
+ private unmuteScopes;
25
+ private pendingEchoSkips;
17
26
  private agentActive;
18
27
  private isZsh;
19
28
  private tmpDir?;
@@ -30,6 +39,15 @@ export declare class Shell implements InputContext {
30
39
  cwd: string;
31
40
  instanceId: string;
32
41
  });
42
+ /** Compositing-layer claim — overrides any unmute. */
43
+ acquireHardMute(reason: string): ShellScope;
44
+ /** Agent-turn / exec-style mute — overridable by unmute. */
45
+ acquireMute(reason: string): ShellScope;
46
+ /** Force visible while held; overrides soft mutes only. */
47
+ acquireUnmute(reason: string): ShellScope;
48
+ /** Swallow the next \n-terminated chunk from PTY (one per call). */
49
+ skipNextLine(): void;
50
+ private isHostMuted;
33
51
  isForegroundBusy(): boolean;
34
52
  getCwd(): string;
35
53
  isAgentActive(): boolean;
@@ -52,9 +70,10 @@ export declare class Shell implements InputContext {
52
70
  private setupOutput;
53
71
  private setupInput;
54
72
  /**
55
- * React to agent lifecycle events — Shell manages its own state
56
- * rather than being driven by AcpClient. This means AcpClient has
57
- * zero frontend knowledge; any frontend can subscribe to the same events.
73
+ * shell:on-processing-done splits into unconditional state cleanup
74
+ * (release agent-turn scope) and an advisable redraw (freshPrompt).
75
+ * RemoteSession suppresses the redraw, never the cleanup, so soft-mute
76
+ * can't leak past the end of a turn even when overlays are involved.
58
77
  */
59
78
  private setupAgentLifecycle;
60
79
  /** Temp directory used for shell config and sockets. */
@@ -5,17 +5,19 @@ import * as pty from "node-pty";
5
5
  import { InputHandler } from "./input-handler.js";
6
6
  import { OutputParser } from "./output-parser.js";
7
7
  import { getSettings } from "../settings.js";
8
- import { RefCounter } from "../utils/ref-counter.js";
9
8
  export class Shell {
10
9
  ptyProcess;
11
10
  bus;
12
11
  handlers;
13
12
  inputHandler;
14
13
  outputParser;
15
- paused = false;
16
- stdoutHold = new RefCounter();
17
- stdoutShow = new RefCounter();
18
- echoSkip = false;
14
+ // hardMute is unconditional (overlay compositing); softMute is overridable
15
+ // by unmute (terminal_keys, permission UI). Gate: hard wins; otherwise
16
+ // muted iff softMute held without an unmute.
17
+ hardMuteScopes = new Set();
18
+ softMuteScopes = new Set();
19
+ unmuteScopes = new Set();
20
+ pendingEchoSkips = 0;
19
21
  agentActive = false;
20
22
  isZsh = false;
21
23
  tmpDir;
@@ -186,12 +188,75 @@ export class Shell {
186
188
  this.bus.on("shell:pty-resize", ({ cols, rows }) => {
187
189
  this.ptyProcess.resize(cols, rows);
188
190
  });
189
- // Ref-counted stdout hold overlay extensions suppress PTY output
190
- this.bus.on("shell:stdout-hold", () => { this.stdoutHold.increment(); });
191
- this.bus.on("shell:stdout-release", () => { this.stdoutHold.decrement(); });
192
- // Ref-counted stdout show — tools temporarily force output visible during agent processing
193
- this.bus.on("shell:stdout-show", () => { this.stdoutShow.increment(); });
194
- this.bus.on("shell:stdout-hide", () => { this.stdoutShow.decrement(); });
191
+ // Compat shims for the bus-event API. shell:stdout-hold maps to hard
192
+ // mute so terminal_keys' stdout-show can't paint through the overlay.
193
+ let holdRefcount = 0;
194
+ let holdScope = null;
195
+ this.bus.on("shell:stdout-hold", () => {
196
+ if (holdRefcount === 0)
197
+ holdScope = this.acquireHardMute("bus:stdout-hold");
198
+ holdRefcount++;
199
+ });
200
+ this.bus.on("shell:stdout-release", () => {
201
+ if (holdRefcount === 0)
202
+ return;
203
+ holdRefcount--;
204
+ if (holdRefcount === 0) {
205
+ holdScope?.release();
206
+ holdScope = null;
207
+ }
208
+ });
209
+ let showRefcount = 0;
210
+ let showScope = null;
211
+ this.bus.on("shell:stdout-show", () => {
212
+ if (showRefcount === 0)
213
+ showScope = this.acquireUnmute("bus:stdout-show");
214
+ showRefcount++;
215
+ });
216
+ this.bus.on("shell:stdout-hide", () => {
217
+ if (showRefcount === 0)
218
+ return;
219
+ showRefcount--;
220
+ if (showRefcount === 0) {
221
+ showScope?.release();
222
+ showScope = null;
223
+ }
224
+ });
225
+ }
226
+ // ── Scope-based gating ─────────────────────────────────────
227
+ /** Compositing-layer claim — overrides any unmute. */
228
+ acquireHardMute(reason) {
229
+ const scope = {
230
+ reason,
231
+ release: () => { this.hardMuteScopes.delete(scope); },
232
+ };
233
+ this.hardMuteScopes.add(scope);
234
+ return scope;
235
+ }
236
+ /** Agent-turn / exec-style mute — overridable by unmute. */
237
+ acquireMute(reason) {
238
+ const scope = {
239
+ reason,
240
+ release: () => { this.softMuteScopes.delete(scope); },
241
+ };
242
+ this.softMuteScopes.add(scope);
243
+ return scope;
244
+ }
245
+ /** Force visible while held; overrides soft mutes only. */
246
+ acquireUnmute(reason) {
247
+ const scope = {
248
+ reason,
249
+ release: () => { this.unmuteScopes.delete(scope); },
250
+ };
251
+ this.unmuteScopes.add(scope);
252
+ return scope;
253
+ }
254
+ /** Swallow the next \n-terminated chunk from PTY (one per call). */
255
+ skipNextLine() { this.pendingEchoSkips++; }
256
+ isHostMuted() {
257
+ if (this.hardMuteScopes.size > 0)
258
+ return true;
259
+ return this.softMuteScopes.size > 0 && this.unmuteScopes.size === 0;
195
260
  }
196
261
  // ── InputContext implementation (delegates to OutputParser) ──
197
262
  isForegroundBusy() {
@@ -211,12 +276,9 @@ export class Shell {
211
276
  * zsh (ZLE widget) and bash (readline redraw-current-line) bind to repaint.
212
277
  */
213
278
  redrawPrompt() {
214
- // Stale echoSkip/paused from handleProcessingDone re-entering a mode
215
- // would swallow the redraw and freeze the terminal visually.
216
- this.echoSkip = false;
217
- this.paused = false;
218
279
  const result = this.bus.emitPipe("shell:redraw-prompt", {
219
280
  cwd: this.outputParser.getCwd(),
281
+ kind: "redraw",
220
282
  handled: false,
221
283
  });
222
284
  if (!result.handled) {
@@ -234,6 +296,7 @@ export class Shell {
234
296
  freshPrompt() {
235
297
  const result = this.bus.emitPipe("shell:redraw-prompt", {
236
298
  cwd: this.outputParser.getCwd(),
299
+ kind: "fresh",
237
300
  handled: false,
238
301
  });
239
302
  if (!result.handled) {
@@ -250,16 +313,13 @@ export class Shell {
250
313
  this.ptyProcess.onData((data) => {
251
314
  this.bus.emit("shell:pty-data", { raw: data });
252
315
  this.outputParser.processData(data);
253
- if (this.stdoutHold.active)
254
- return;
255
- if (this.paused && !this.stdoutShow.active)
316
+ if (this.isHostMuted())
256
317
  return;
257
- // During user_shell exec, skip the command echo (first line)
258
- if (this.echoSkip) {
318
+ if (this.pendingEchoSkips > 0) {
259
319
  const nlIdx = data.indexOf("\n");
260
320
  if (nlIdx === -1)
261
321
  return;
262
- this.echoSkip = false;
322
+ this.pendingEchoSkips--;
263
323
  const rest = data.slice(nlIdx + 1);
264
324
  if (rest)
265
325
  process.stdout.write(rest);
@@ -275,81 +335,80 @@ export class Shell {
275
335
  });
276
336
  }
277
337
  /**
278
- * React to agent lifecycle events — Shell manages its own state
279
- * rather than being driven by AcpClient. This means AcpClient has
280
- * zero frontend knowledge; any frontend can subscribe to the same events.
338
+ * shell:on-processing-done splits into unconditional state cleanup
339
+ * (release agent-turn scope) and an advisable redraw (freshPrompt).
340
+ * RemoteSession suppresses the redraw, never the cleanup, so soft-mute
341
+ * can't leak past the end of a turn even when overlays are involved.
281
342
  */
282
343
  setupAgentLifecycle() {
283
- // Default agent lifecycle: pause the shell while the agent works,
284
- // then redraw the prompt when done. Extensions advise these handlers
285
- // to change behavior (e.g. tmux split keeps the shell interactive).
344
+ let agentTurnScope = null;
286
345
  this.handlers.define("shell:on-processing-start", () => {
287
346
  this.agentActive = true;
288
- this.paused = true;
347
+ agentTurnScope = this.acquireMute("agent-turn");
289
348
  });
290
- this.handlers.define("shell:on-processing-done", () => {
291
- this.agentActive = false;
292
- // If handleProcessingDone re-entered a mode, leave stdout paused so
293
- // stale PTY output doesn't overwrite the mode prompt (exitMode →
294
- // redrawPrompt will unpause). Setting echoSkip here would swallow
295
- // that PTY output since no \n was sent.
349
+ this.handlers.define("shell:on-processing-redraw", () => {
296
350
  if (!this.inputHandler.handleProcessingDone()) {
297
- this.paused = false;
298
- if (this.freshPrompt()) {
299
- this.echoSkip = true;
300
- }
351
+ if (this.freshPrompt())
352
+ this.skipNextLine();
301
353
  }
302
354
  });
355
+ this.handlers.define("shell:on-processing-done", () => {
356
+ this.agentActive = false;
357
+ agentTurnScope?.release();
358
+ agentTurnScope = null;
359
+ this.handlers.call("shell:on-processing-redraw");
360
+ });
303
361
  this.bus.on("agent:processing-start", () => {
304
362
  this.handlers.call("shell:on-processing-start");
305
363
  });
306
364
  this.bus.on("agent:processing-done", () => {
307
365
  this.handlers.call("shell:on-processing-done");
308
366
  });
309
- // Permission prompts need stdout unpaused so the interactive UI renders,
310
- // then re-paused after the decision.
367
+ // Permission UI is briefly visible during the prompt; an unmute scope
368
+ // overrides whatever mute is currently held, then releases cleanly.
369
+ // Doesn't touch agent-turn state, so suppressed handlers can't leak.
370
+ let permissionVisible = null;
311
371
  this.bus.on("permission:request", () => {
312
- this.paused = false;
372
+ permissionVisible?.release();
373
+ permissionVisible = this.acquireUnmute("permission-ui");
313
374
  });
314
375
  this.bus.onPipeAsync("permission:request", async (payload) => {
315
- this.paused = true;
376
+ permissionVisible?.release();
377
+ permissionVisible = null;
316
378
  return payload;
317
379
  });
318
- // Shell exec: write a command to the live PTY and capture its output.
319
- // stdout is paused during agent processing, so PTY output flows through
320
- // OutputParser (for OSC detection) but never reaches the terminal.
321
380
  this.bus.onPipeAsync("shell:exec-request", async (payload) => {
322
- this.echoSkip = true;
323
- this.paused = false;
381
+ const visible = this.acquireUnmute("exec-request");
382
+ this.skipNextLine();
324
383
  process.stdout.write("\n");
325
384
  this.bus.emit("shell:agent-exec-start", {});
326
- const output = await new Promise((resolve, reject) => {
327
- const timeout = setTimeout(() => {
328
- this.bus.off("shell:command-done", handler);
329
- this.ptyProcess.write("\x03");
330
- reject(new Error("Shell exec timed out after 30s"));
331
- }, 30_000);
332
- const handler = (e) => {
333
- clearTimeout(timeout);
334
- this.bus.off("shell:command-done", handler);
335
- // Re-pause stdout so the prompt text following the marker doesn't
336
- // leak to the terminal while the agent is still processing.
337
- this.paused = true;
338
- resolve({ output: e.output, cwd: e.cwd, exitCode: e.exitCode });
339
- };
340
- this.bus.on("shell:command-done", handler);
341
- this.outputParser.onCommandEntered(payload.command, this.outputParser.getCwd());
342
- // Collapse literal newlines to spaces so the PTY receives a single-line
343
- // command. Multi-line commands (e.g. git commit -m "...\n...") would
344
- // cause the shell to execute prematurely, producing garbled output from
345
- // syntax highlighting plugins (zsh syntax highlighting, etc).
346
- const oneLine = payload.command.replace(/\n/g, " ");
347
- this.ptyProcess.write(oneLine + "\r");
348
- });
349
- this.paused = true;
350
- this.echoSkip = false;
351
- this.bus.emit("shell:agent-exec-done", {});
352
- return { ...payload, output: output.output, cwd: output.cwd, exitCode: output.exitCode, done: true };
385
+ try {
386
+ const output = await new Promise((resolve, reject) => {
387
+ const timeout = setTimeout(() => {
388
+ this.bus.off("shell:command-done", handler);
389
+ this.ptyProcess.write("\x03");
390
+ reject(new Error("Shell exec timed out after 30s"));
391
+ }, 30_000);
392
+ const handler = (e) => {
393
+ clearTimeout(timeout);
394
+ this.bus.off("shell:command-done", handler);
395
+ resolve({ output: e.output, cwd: e.cwd, exitCode: e.exitCode });
396
+ };
397
+ this.bus.on("shell:command-done", handler);
398
+ this.outputParser.onCommandEntered(payload.command, this.outputParser.getCwd());
399
+ // Collapse literal newlines to spaces so the PTY receives a single-line
400
+ // command. Multi-line commands (e.g. git commit -m "...\n...") would
401
+ // cause the shell to execute prematurely, producing garbled output from
402
+ // syntax highlighting plugins (zsh syntax highlighting, etc).
403
+ const oneLine = payload.command.replace(/\n/g, " ");
404
+ this.ptyProcess.write(oneLine + "\r");
405
+ });
406
+ return { ...payload, output: output.output, cwd: output.cwd, exitCode: output.exitCode, done: true };
407
+ }
408
+ finally {
409
+ visible.release();
410
+ this.bus.emit("shell:agent-exec-done", {});
411
+ }
353
412
  });
354
413
  }
355
414
  // ── Public API (used by index.ts) ──
@@ -172,7 +172,6 @@ export declare class FloatingPanel {
172
172
  private prevFrame;
173
173
  private suppressNextRedraw;
174
174
  private autoDismissTimer;
175
- private ptyBuffer;
176
175
  private usedAltScreen;
177
176
  private wrapCache;
178
177
  private wrapCacheWidth;
@@ -204,6 +203,7 @@ export declare class FloatingPanel {
204
203
  appendText(text: string): void;
205
204
  appendLine(line: string): void;
206
205
  updateLastLine(fn: (line: string) => string): void;
206
+ popLastLine(): void;
207
207
  clearContent(): void;
208
208
  setTitle(title: string): void;
209
209
  setFooter(footer: string): void;
@@ -222,7 +222,6 @@ export declare class FloatingPanel {
222
222
  private buildFrame;
223
223
  private scheduleRender;
224
224
  private render;
225
- /** Full screen teardown: exit alt screen, release stdout, force redraw. */
226
225
  private teardownScreen;
227
226
  /** Start rendering TerminalBuffer directly (no overlay box). */
228
227
  private startPassthrough;
@@ -118,7 +118,6 @@ export class FloatingPanel {
118
118
  prevFrame = [];
119
119
  suppressNextRedraw = false;
120
120
  autoDismissTimer = null;
121
- ptyBuffer = ""; // PTY output accumulated while overlay is open
122
121
  usedAltScreen = false; // whether we entered our own alt screen
123
122
  wrapCache = new Map(); // line → wrapped lines (invalidated on width change)
124
123
  wrapCacheWidth = 0;
@@ -287,20 +286,14 @@ export class FloatingPanel {
287
286
  }
288
287
  // ── Bus event wiring ───────────────────────────────────────
289
288
  wireEvents() {
290
- // Buffer PTY output while overlay is visible (alt screen discards it).
291
- // Don't buffer when hidden — PTY flows to terminal directly via stdout-show.
292
- this.bus.on("shell:pty-data", ({ raw }) => {
293
- if (this._visible)
294
- this.ptyBuffer += raw;
295
- });
296
289
  this.bus.onPipe("input:intercept", (payload) => this.handleIntercept(payload));
297
290
  this.bus.onPipe("shell:redraw-prompt", (payload) => {
298
291
  if (this._visible || this._passthrough) {
299
292
  return { ...payload, handled: true };
300
293
  }
301
- // After dismiss, suppress one redraw restoreScreen already
302
- // restored the terminal content, so freshPrompt's \n is unwanted.
303
- if (this.suppressNextRedraw) {
294
+ // Suppress only freshPrompt's \n — an in-place redraw must not
295
+ // consume the slot, or unrelated mode-exit redraws go missing.
296
+ if (this.suppressNextRedraw && payload.kind === "fresh") {
304
297
  this.suppressNextRedraw = false;
305
298
  return { ...payload, handled: true };
306
299
  }
@@ -374,7 +367,6 @@ export class FloatingPanel {
374
367
  // so the background program's screen stays correct without
375
368
  // handing rendering control back to ncurses.
376
369
  this._passthrough = true;
377
- this.ptyBuffer = "";
378
370
  this.startPassthrough();
379
371
  }
380
372
  else {
@@ -435,7 +427,6 @@ export class FloatingPanel {
435
427
  /** Common screen enter logic shared by open() and show(). */
436
428
  enterScreen() {
437
429
  this._visible = true;
438
- this.ptyBuffer = "";
439
430
  this.bus.emit("shell:stdout-hold", {});
440
431
  this.usedAltScreen = !(this.buffer?.altScreen);
441
432
  if (this.usedAltScreen) {
@@ -471,6 +462,15 @@ export class FloatingPanel {
471
462
  }
472
463
  this.scheduleRender();
473
464
  }
465
+ popLastLine() {
466
+ if (this.currentPartialLine) {
467
+ this.currentPartialLine = "";
468
+ }
469
+ else if (this.contentLines.length > 0) {
470
+ this.contentLines.pop();
471
+ }
472
+ this.scheduleRender();
473
+ }
474
474
  clearContent() {
475
475
  this.contentLines = [];
476
476
  this.currentPartialLine = "";
@@ -751,39 +751,38 @@ export class FloatingPanel {
751
751
  this.prevFrame = frame;
752
752
  }
753
753
  // ── Screen helpers ────────────────────────────────────────
754
- /** Full screen teardown: exit alt screen, release stdout, force redraw. */
755
754
  teardownScreen() {
756
755
  this.resizeUnsub?.();
757
756
  this.resizeUnsub = null;
758
757
  this.suppressNextRedraw = true;
759
- // Re-check alt screen state: the program we overlaid may have exited
760
- // (e.g. agent quit vim via terminal_keys) while the panel was active.
761
- const stillInAltScreen = !this.usedAltScreen && !!this.buffer?.altScreen;
762
- const programExited = !this.usedAltScreen && !stillInAltScreen;
763
- if (this.usedAltScreen) {
764
- this.surface.write("\x1b[?1049l");
765
- }
766
- // Replay PTY output that arrived while the overlay was active.
767
- // Without this, commands run by the agent (e.g. user_shell ls)
768
- // would vanish — the alt screen exit restores the saved screen
769
- // from before the overlay opened, losing any shell output produced
770
- // during the session.
771
- if (this.ptyBuffer) {
772
- this.surface.write(this.ptyBuffer);
773
- }
774
- this.ptyBuffer = "";
775
- this.bus.emit("shell:stdout-release", {});
776
- if (stillInAltScreen || programExited) {
777
- // Either a TUI app is still running and needs SIGWINCH to repaint,
778
- // or the overlaid program exited (e.g. agent quit vim) and we
779
- // discarded its stale buffer — SIGWINCH makes the shell redraw
780
- // its prompt cleanly.
758
+ this.buffer?.flush();
759
+ const programInAlt = !!this.buffer?.altScreen;
760
+ if (!this.usedAltScreen && programInAlt) {
761
+ // Program still in its own alt-screen — SIGWINCH so it redraws
762
+ // and re-asserts its modes; replaying from the mirror would
763
+ // freeze modes serialize() doesn't track (modifyOtherKeys, kitty
764
+ // kbd) and leave ctrl-c arriving as \x1b[27;5;99~.
765
+ this.bus.emit("shell:stdout-release", {});
781
766
  const cols = this.surface.columns;
782
767
  const rows = this.surface.rows;
783
768
  this.bus.emit("shell:pty-resize", { cols, rows: rows - 1 });
784
769
  setTimeout(() => {
785
770
  this.bus.emit("shell:pty-resize", { cols, rows });
786
771
  }, 50);
772
+ return;
773
+ }
774
+ this.surface.write("\x1b[?1049l");
775
+ if (!this.usedAltScreen) {
776
+ // Program exited mid-overlay; its reset bytes were eaten by
777
+ // stdout-hold. Reset modes serialize() doesn't track or the
778
+ // host stays in vim's modifyOtherKeys mode.
779
+ this.surface.write("\x1b[>4;0m\x1b[<u\x1b[?2004l\x1b[?1004l" +
780
+ "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l");
781
+ }
782
+ this.bus.emit("shell:stdout-release", {});
783
+ const serialized = this.buffer?.serialize();
784
+ if (serialized) {
785
+ this.surface.write(`${SYNC_START}\x1b[2J\x1b[H${serialized}${SYNC_END}`);
787
786
  }
788
787
  }
789
788
  // ── Passthrough rendering ─────────────────────────────────
@@ -808,7 +807,7 @@ export class FloatingPanel {
808
807
  const serialized = this.buffer.serialize();
809
808
  if (serialized && serialized !== this.prevSerialized) {
810
809
  this.prevSerialized = serialized;
811
- this.surface.write(`${SYNC_START}\x1b[H${serialized}${SYNC_END}`);
810
+ this.surface.write(`${SYNC_START}\x1b[2J\x1b[H${serialized}${SYNC_END}`);
812
811
  }
813
812
  }
814
813
  resolveSize(spec, available) {
@@ -364,6 +364,8 @@ export class MarkdownRenderer {
364
364
  return this.renderInline(line);
365
365
  }
366
366
  renderInline(text) {
367
+ // Links first — later subs inject `\x1b[…m` whose `[` would be eaten here.
368
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, `$1 ${p.muted}${p.underline}($2)${p.reset}`);
367
369
  // Inline code
368
370
  text = text.replace(/`([^`]+)`/g, `${p.accent}$1${p.reset}`);
369
371
  // Bold + italic
@@ -376,8 +378,6 @@ export class MarkdownRenderer {
376
378
  text = text.replace(/(?<!\w)_(.+?)_(?!\w)/g, `${p.italic}$1${p.reset}`);
377
379
  // Strikethrough
378
380
  text = text.replace(/~~(.+?)~~/g, `${p.dim}$1${p.reset}`);
379
- // Links
380
- text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, `$1 ${p.muted}${p.underline}($2)${p.reset}`);
381
381
  return text;
382
382
  }
383
383
  /**