agent-sh 0.12.1 → 0.12.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.
package/README.md CHANGED
@@ -23,16 +23,22 @@ I still use Claude Code and pi for serious coding work — this doesn't replace
23
23
 
24
24
  ## Quick Start
25
25
 
26
- Install the latest from GitHub (recommended — development moves faster than npm releases):
26
+ Install from npm:
27
27
 
28
28
  ```bash
29
- npm install -g github:guanyilun/agent-sh
29
+ npm install -g agent-sh
30
30
  ```
31
31
 
32
- Or the last published npm release:
32
+ Re-run the same command to update. Patch releases ship frequently; `npm update -g agent-sh` works too.
33
+
34
+ For unreleased changes on `main`, clone and link locally — this avoids `npm install -g github:...`, which builds on your machine and requires a working TypeScript toolchain:
33
35
 
34
36
  ```bash
35
- npm install -g agent-sh
37
+ git clone https://github.com/guanyilun/agent-sh.git
38
+ cd agent-sh
39
+ npm install # installs devDependencies (typescript, etc.)
40
+ npm run build # produces dist/
41
+ npm link # exposes `agent-sh` globally
36
42
  ```
37
43
 
38
44
  Pick one of the zero-config paths below — no settings file needed. agent-sh auto-activates a built-in provider when it sees a known key.
@@ -29,6 +29,16 @@ import { discoverGlobalSkills, discoverProjectSkills } from "./skills.js";
29
29
  * the LLM via the API `tools` param (or via load_tool in deferred-
30
30
  * lookup mode) — this only trims the always-visible catalog.
31
31
  */
32
+ /** Reject on abort; orphaned `p` keeps running but its result is dropped. */
33
+ function raceAbort(p, signal) {
34
+ if (signal.aborted)
35
+ return Promise.reject(new Error("cancelled"));
36
+ return new Promise((resolve, reject) => {
37
+ const onAbort = () => reject(new Error("cancelled"));
38
+ signal.addEventListener("abort", onAbort, { once: true });
39
+ p.then((v) => { signal.removeEventListener("abort", onAbort); resolve(v); }, (e) => { signal.removeEventListener("abort", onAbort); reject(e); });
40
+ });
41
+ }
32
42
  function summarizeDescription(desc) {
33
43
  const firstLine = desc.split("\n", 1)[0];
34
44
  const sentenceEnd = firstLine.search(/[.!?](\s|$)/);
@@ -817,12 +827,11 @@ export class AgentLoop {
817
827
  this.conversation.addSystemNote(text);
818
828
  this.bus.emit("conversation:message-appended", { role: "system", content: text });
819
829
  });
830
+ // Fires on user-abort; extensions advise per tool name for cleanup.
831
+ h.define("tool:cancel", (_ctx) => { });
820
832
  // Wraps each tool call: permission → execute → emit events.
821
- // Extensions advise to add safe-mode, logging, metrics, custom policies.
822
- // The ctx.onChunk callback is exposed so advisors can wrap it to
823
- // intercept/transform streamed tool output (e.g. secret redaction).
824
833
  h.define("tool:execute", async (ctx) => {
825
- const { name, id, args, tool } = ctx;
834
+ const { name, id, args, tool, signal } = ctx;
826
835
  // Validate required input fields before display/permission/execute.
827
836
  // Some models emit wrong arg names (e.g. `file_path` instead of `path`),
828
837
  // and downstream helpers assume required strings are present.
@@ -918,16 +927,21 @@ export class AgentLoop {
918
927
  const onChunk = (tool.showOutput !== false && !diffShown)
919
928
  ? ctx.onChunk
920
929
  : undefined;
921
- const toolCtx = this.compositor
922
- ? { ui: createToolUI(this.bus, this.compositor.surface("agent")) }
923
- : undefined;
924
- // Surface thrown errors as tool results so the agent can self-correct
925
- // instead of the throw killing the whole turn.
930
+ const toolCtx = { signal };
931
+ if (this.compositor) {
932
+ toolCtx.ui = createToolUI(this.bus, this.compositor.surface("agent"));
933
+ }
926
934
  let result;
927
935
  try {
928
- result = await tool.execute(args, onChunk, toolCtx);
936
+ result = await raceAbort(tool.execute(args, onChunk, toolCtx), signal);
929
937
  }
930
938
  catch (err) {
939
+ if (signal.aborted) {
940
+ try {
941
+ this.handlers.call("tool:cancel", { name, args, reason: "user-aborted" });
942
+ }
943
+ catch { }
944
+ }
931
945
  const message = err instanceof Error ? err.message : String(err);
932
946
  result = { content: message, exitCode: 1, isError: true };
933
947
  }
@@ -1169,7 +1183,8 @@ export class AgentLoop {
1169
1183
  this.bus.emit("agent:tool-output-chunk", { chunk });
1170
1184
  };
1171
1185
  const result = await this.handlers.call("tool:execute", { name: tc.name, id: tc.id, args, tool, onChunk: defaultOnChunk,
1172
- batchIndex, batchTotal: batchTotal > 1 ? batchTotal : undefined });
1186
+ batchIndex, batchTotal: batchTotal > 1 ? batchTotal : undefined,
1187
+ signal });
1173
1188
  // Truncate large outputs to avoid blowing context
1174
1189
  let content = result.content;
1175
1190
  const maxBytes = 16_384; // ~4k tokens
@@ -38,6 +38,7 @@ export declare class ConversationState {
38
38
  private nextSeq;
39
39
  private lastApiTokenCount;
40
40
  private lastApiMessageCount;
41
+ private pendingNotes;
41
42
  constructor(handlers?: HandlerFunctions, instanceId?: string);
42
43
  /** Get JSON.stringify of messages, cached until next mutation. */
43
44
  private getMessagesJson;
@@ -53,7 +54,10 @@ export declare class ConversationState {
53
54
  addToolResult(toolCallId: string, content: string, isError?: boolean): void;
54
55
  /** Add tool results as a user message (for inline tool protocol). */
55
56
  addToolResultInline(content: string): void;
57
+ /** Safe from any context: queues if mid-tool-pair, appends otherwise. */
56
58
  addSystemNote(text: string): void;
59
+ private hasOpenToolCalls;
60
+ private flushPendingNotes;
57
61
  getMessages(): ChatCompletionMessageParam[];
58
62
  /**
59
63
  * If a stream was interrupted mid-tool-execution, an assistant message
@@ -56,6 +56,10 @@ export class ConversationState {
56
56
  nextSeq = 1;
57
57
  lastApiTokenCount = null;
58
58
  lastApiMessageCount = 0;
59
+ // Notes queued when addSystemNote fires mid-tool-pair; flushed once
60
+ // the trailing tool_result lands. Splicing into the gap breaks
61
+ // reasoning_content pairing and is rejected by strict providers.
62
+ pendingNotes = [];
59
63
  constructor(handlers, instanceId = "0000") {
60
64
  this.handlers = handlers ?? null;
61
65
  this.instanceId = instanceId;
@@ -100,16 +104,54 @@ export class ConversationState {
100
104
  if (isError)
101
105
  this.toolErrors.add(toolCallId);
102
106
  this.invalidateMessagesCache();
107
+ this.flushPendingNotes();
103
108
  }
104
109
  /** Add tool results as a user message (for inline tool protocol). */
105
110
  addToolResultInline(content) {
106
111
  this.messages.push({ role: "user", content });
107
112
  this.invalidateMessagesCache();
113
+ this.flushPendingNotes();
108
114
  }
115
+ /** Safe from any context: queues if mid-tool-pair, appends otherwise. */
109
116
  addSystemNote(text) {
117
+ if (this.hasOpenToolCalls()) {
118
+ this.pendingNotes.push(text);
119
+ return;
120
+ }
110
121
  this.messages.push({ role: "user", content: text });
111
122
  this.invalidateMessagesCache();
112
123
  }
124
+ hasOpenToolCalls() {
125
+ for (let i = this.messages.length - 1; i >= 0; i--) {
126
+ const msg = this.messages[i];
127
+ if (msg.role === "tool")
128
+ continue;
129
+ if (msg.role !== "assistant")
130
+ return false;
131
+ if (!("tool_calls" in msg) || !msg.tool_calls)
132
+ return false;
133
+ const answered = new Set();
134
+ for (let j = i + 1; j < this.messages.length; j++) {
135
+ const m = this.messages[j];
136
+ if (m.role !== "tool")
137
+ break;
138
+ answered.add(m.tool_call_id);
139
+ }
140
+ return msg.tool_calls.some((tc) => !answered.has(tc.id));
141
+ }
142
+ return false;
143
+ }
144
+ flushPendingNotes() {
145
+ if (this.pendingNotes.length === 0)
146
+ return;
147
+ if (this.hasOpenToolCalls())
148
+ return;
149
+ for (const text of this.pendingNotes) {
150
+ this.messages.push({ role: "user", content: text });
151
+ }
152
+ this.pendingNotes = [];
153
+ this.invalidateMessagesCache();
154
+ }
113
155
  getMessages() {
114
156
  return this.normalizeReasoningConsistency(this.stubDanglingToolCalls(this.messages));
115
157
  }
@@ -175,6 +217,7 @@ export class ConversationState {
175
217
  this.invalidateMessagesCache();
176
218
  this.lastApiTokenCount = null;
177
219
  this.lastApiMessageCount = 0;
220
+ this.flushPendingNotes();
178
221
  }
179
222
  pruneToolErrors() {
180
223
  if (this.toolErrors.size === 0)
@@ -474,6 +517,7 @@ export class ConversationState {
474
517
  this.nuclearEntries = [];
475
518
  this.nuclearBySeq.clear();
476
519
  this.recallArchive.clear();
520
+ this.pendingNotes = [];
477
521
  this.invalidateMessagesCache();
478
522
  this.lastApiTokenCount = null;
479
523
  this.lastApiMessageCount = 0;
@@ -1,4 +1,4 @@
1
- import { executeCommand } from "../../executor.js";
1
+ import { executeCommand, killSession } from "../../executor.js";
2
2
  export function createBashTool(opts) {
3
3
  return {
4
4
  name: "bash",
@@ -33,7 +33,7 @@ export function createBashTool(opts) {
33
33
  icon: "▶",
34
34
  locations: [],
35
35
  }),
36
- async execute(args, onChunk) {
36
+ async execute(args, onChunk, ctx) {
37
37
  const command = args.command;
38
38
  const timeout = (args.timeout ?? 60) * 1000;
39
39
  // Let extensions intercept before execution
@@ -57,7 +57,14 @@ export function createBashTool(opts) {
57
57
  timeout,
58
58
  onOutput: onChunk,
59
59
  });
60
- await done;
60
+ const onAbort = () => killSession(session);
61
+ ctx?.signal?.addEventListener("abort", onAbort, { once: true });
62
+ try {
63
+ await done;
64
+ }
65
+ finally {
66
+ ctx?.signal?.removeEventListener("abort", onAbort);
67
+ }
61
68
  const content = session.truncated
62
69
  ? `[output truncated, showing last portion]\n${session.output}`
63
70
  : session.output;
@@ -64,7 +64,9 @@ export interface ToolUI {
64
64
  }
65
65
  /** Context passed to tool execute() as optional third parameter. */
66
66
  export interface ToolExecutionContext {
67
- ui: ToolUI;
67
+ ui?: ToolUI;
68
+ /** Aborted on Ctrl-C — tools with subprocess work should listen and clean up. */
69
+ signal?: AbortSignal;
68
70
  }
69
71
  export interface ToolDefinition {
70
72
  name: string;
package/dist/core.d.ts CHANGED
@@ -33,6 +33,8 @@ export interface AgentShellCore {
33
33
  contextManager: ContextManager;
34
34
  /** Handler registry for define/advise/call. */
35
35
  handlers: HandlerRegistry;
36
+ /** Unique id for this agent process; used for shell-marker tagging and lineage tracking. */
37
+ instanceId: string;
36
38
  /** Activate the agent backend (call after extensions load). */
37
39
  activateBackend(): void;
38
40
  /** Convenience: emit agent:submit and await the response. */
package/dist/core.js CHANGED
@@ -107,6 +107,7 @@ export function createCore(config) {
107
107
  bus,
108
108
  contextManager,
109
109
  handlers,
110
+ instanceId,
110
111
  activateBackend() {
111
112
  // Silent — backend info is shown in the startup banner.
112
113
  // Runtime switches (config:switch-backend) still emit ui:info.
package/dist/index.js CHANGED
@@ -218,6 +218,7 @@ async function main() {
218
218
  rows,
219
219
  shell: config.shell || process.env.SHELL || "/bin/bash",
220
220
  cwd: process.cwd(),
221
+ instanceId: core.instanceId,
221
222
  onShowAgentInfo: () => {
222
223
  if (agentInfo) {
223
224
  return { info: `${p.dim}${agentInfo.name}${agentInfo.model ? ` (${agentInfo.model})` : ""}${p.reset}` };
@@ -6,11 +6,12 @@ import type { EventBus } from "../event-bus.js";
6
6
  export declare class OutputParser {
7
7
  private bus;
8
8
  private cwd;
9
+ private ownTag;
9
10
  private currentOutputCapture;
10
11
  private lastCommand;
11
12
  private foregroundBusy;
12
13
  private promptReady;
13
- constructor(bus: EventBus, initialCwd: string);
14
+ constructor(bus: EventBus, initialCwd: string, ownTag: string);
14
15
  /** Process a chunk of PTY output data. */
15
16
  processData(data: string): void;
16
17
  /** Called when user presses Enter on a non-empty line. */
@@ -1,4 +1,9 @@
1
1
  import { stripAnsi } from "../utils/ansi.js";
2
+ // Self-emitted form: \e]<num>;id=<own>;<body>\a — only this is honored.
3
+ // Anything else (mismatched tag, untagged) is ignored as opaque foreground output.
4
+ const PROMPT_RE = /\x1b\]9999;(?:id=([a-f0-9]+);)?PROMPT\x07/;
5
+ const PREEXEC_RE = /\x1b\]9997;(?:id=([a-f0-9]+);)?([^\x07]*)\x07/;
6
+ const READY_RE = /\x1b\]9998;(?:id=([a-f0-9]+);)?READY\x07/;
2
7
  /**
3
8
  * Parses PTY output to detect command boundaries, track cwd,
4
9
  * and emit shell events. Owns the command lifecycle state.
@@ -6,13 +11,16 @@ import { stripAnsi } from "../utils/ansi.js";
6
11
  export class OutputParser {
7
12
  bus;
8
13
  cwd;
14
+ ownTag;
9
15
  currentOutputCapture = "";
10
16
  lastCommand = "";
11
17
  foregroundBusy = false;
12
18
  promptReady = false;
13
- constructor(bus, initialCwd) {
19
+ constructor(bus, initialCwd, ownTag) {
14
20
  this.bus = bus;
15
21
  this.cwd = initialCwd;
22
+ // Strip the "id=" prefix; we compare the value alone.
23
+ this.ownTag = ownTag.startsWith("id=") ? ownTag.slice(3) : ownTag;
16
24
  }
17
25
  /** Process a chunk of PTY output data. */
18
26
  processData(data) {
@@ -49,24 +57,22 @@ export class OutputParser {
49
57
  * completion. Returns data with the OSC stripped out.
50
58
  */
51
59
  handlePreexec(data) {
52
- const marker = "\x1b]9997;";
53
- const idx = data.indexOf(marker);
54
- if (idx === -1)
60
+ const match = PREEXEC_RE.exec(data);
61
+ if (!match)
55
62
  return data;
56
- const endIdx = data.indexOf("\x07", idx + marker.length);
57
- if (endIdx === -1)
58
- return data; // incomplete OSC, wait for next chunk
59
- const command = data.slice(idx + marker.length, endIdx);
60
- // Authoritative command from the shell — override any lineBuffer guess
63
+ if (match[1] !== this.ownTag) {
64
+ // Nested instance or untagged foreign emission — strip and ignore.
65
+ return data.slice(0, match.index) + data.slice(match.index + match[0].length);
66
+ }
67
+ const command = match[2];
61
68
  this.lastCommand = command;
62
- this.currentOutputCapture = ""; // discard echoed text accumulated before preexec
69
+ this.currentOutputCapture = ""; // discard echo accumulated before preexec
63
70
  if (!this.foregroundBusy) {
64
71
  this.foregroundBusy = true;
65
72
  this.bus.emit("shell:foreground-busy", { busy: true });
66
73
  }
67
74
  this.bus.emit("shell:command-start", { command, cwd: this.cwd });
68
- // Return only data after the OSC — everything before was the echo
69
- return data.slice(endIdx + 1);
75
+ return data.slice(match.index + match[0].length);
70
76
  }
71
77
  parseOSC7(data) {
72
78
  const match = data.match(/\x1b\]7;file:\/\/[^/]*(\/[^\x07\x1b]*)/);
@@ -83,9 +89,15 @@ export class OutputParser {
83
89
  * Each time a prompt appears, we finalize the previous command's output.
84
90
  */
85
91
  parsePromptMarker(data) {
86
- const marker = "\x1b]9999;PROMPT\x07";
87
- const markerIdx = data.indexOf(marker);
88
- if (markerIdx !== -1) {
92
+ const match = PROMPT_RE.exec(data);
93
+ if (match) {
94
+ if (match[1] !== this.ownTag) {
95
+ // Nested instance or untagged foreign emission — treat as opaque
96
+ // foreground output, do not finalize our own command.
97
+ this.currentOutputCapture += data;
98
+ return;
99
+ }
100
+ const markerIdx = match.index;
89
101
  // Capture any output that arrived in the same chunk before the marker
90
102
  if (markerIdx > 0) {
91
103
  this.currentOutputCapture += data.slice(0, markerIdx);
@@ -125,9 +137,12 @@ export class OutputParser {
125
137
  * and the shell is ready for input.
126
138
  */
127
139
  parsePromptEnd(data) {
128
- if (data.includes("\x1b]9998;READY\x07")) {
129
- this.promptReady = true;
130
- }
140
+ const match = READY_RE.exec(data);
141
+ if (!match)
142
+ return;
143
+ if (match[1] !== this.ownTag)
144
+ return;
145
+ this.promptReady = true;
131
146
  }
132
147
  removeEchoedCommand(output, command) {
133
148
  const lines = output.split("\n");
@@ -28,6 +28,7 @@ export declare class Shell implements InputContext {
28
28
  rows: number;
29
29
  shell: string;
30
30
  cwd: string;
31
+ instanceId: string;
31
32
  });
32
33
  isForegroundBusy(): boolean;
33
34
  getCwd(): string;
@@ -43,8 +43,10 @@ export class Shell {
43
43
  }
44
44
  const shellBin = (isZsh || isBash) ? opts.shell : "/bin/bash";
45
45
  let shellArgs;
46
+ // Per-instance tag so nested agent-sh hooks don't cross-trigger.
47
+ const instanceTag = `id=${opts.instanceId}`;
46
48
  const osc7Cmd = 'printf "\\e]7;file://%s%s\\a" "$(hostname)" "$PWD"';
47
- const promptMarker = 'printf "\\e]9999;PROMPT\\a"';
49
+ const promptMarker = `printf "\\e]9999;${instanceTag};PROMPT\\a"`;
48
50
  const titleCmd = 'printf "\\e]0;⚡ agent-sh: %s\\a" "${PWD/#$HOME/~}"';
49
51
  this.isZsh = isZsh;
50
52
  const settings = getSettings();
@@ -69,11 +71,11 @@ export class Shell {
69
71
  "# Preexec hook: emit actual command text so agent-sh can track",
70
72
  "# history-recalled and tab-completed commands accurately",
71
73
  "__agent_sh_preexec() {",
72
- ' printf "\\e]9997;%s\\a" "$1"',
74
+ ` printf "\\e]9997;${instanceTag};%s\\a" "$1"`,
73
75
  "}",
74
76
  "preexec_functions+=(__agent_sh_preexec)",
75
77
  ];
76
- zshrcLines.push("", "# End-of-prompt marker via zle-line-init (fires after prompt is rendered)", "# Chain onto existing widget (p10k uses zle-line-init) rather than clobbering", 'if (( ${+widgets[zle-line-init]} )); then', " zle -A zle-line-init __agent_sh_orig_line_init", " __agent_sh_line_init() {", " zle __agent_sh_orig_line_init", ' printf "\\e]9998;READY\\a"', " }", "else", " __agent_sh_line_init() {", ' printf "\\e]9998;READY\\a"', " }", "fi", "zle -N zle-line-init __agent_sh_line_init", "", "# Hidden widget to trigger prompt redraw from Node.js side", "# Bound to an unused escape sequence that no real key produces", "__agent_sh_redraw() {", " zle reset-prompt", "}", "zle -N __agent_sh_redraw", "bindkey '\\e[9999~' __agent_sh_redraw");
78
+ zshrcLines.push("", "# End-of-prompt marker via zle-line-init (fires after prompt is rendered)", "# Chain onto existing widget (p10k uses zle-line-init) rather than clobbering", 'if (( ${+widgets[zle-line-init]} )); then', " zle -A zle-line-init __agent_sh_orig_line_init", " __agent_sh_line_init() {", " zle __agent_sh_orig_line_init", ` printf "\\e]9998;${instanceTag};READY\\a"`, " }", "else", " __agent_sh_line_init() {", ` printf "\\e]9998;${instanceTag};READY\\a"`, " }", "fi", "zle -N zle-line-init __agent_sh_line_init", "", "# Hidden widget to trigger prompt redraw from Node.js side", "# Bound to an unused escape sequence that no real key produces", "__agent_sh_redraw() {", " zle reset-prompt", "}", "zle -N __agent_sh_redraw", "bindkey '\\e[9999~' __agent_sh_redraw");
77
79
  fs.writeFileSync(path.join(this.tmpDir, ".zshrc"), zshrcLines.join("\n") + "\n");
78
80
  env.ZDOTDIR = this.tmpDir;
79
81
  shellArgs = ["--no-globalrcs"];
@@ -106,12 +108,12 @@ export class Shell {
106
108
  " __agent_sh_preexec_ran=1",
107
109
  " local this_cmd",
108
110
  ` this_cmd=$(HISTTIMEFORMAT='' builtin history 1 | command sed 's/^ *[0-9]* *//')`,
109
- ` printf '\\e]9997;%s\\a' "$this_cmd"`,
111
+ ` printf '\\e]9997;${instanceTag};%s\\a' "$this_cmd"`,
110
112
  "}",
111
113
  "trap '__agent_sh_emit_preexec' DEBUG",
112
114
  "",
113
115
  "# End-of-prompt marker: append to PS1 (\\[...\\] marks it zero-width)",
114
- 'case "$PS1" in *9998*) ;; *) PS1="${PS1}\\[\\e]9998;READY\\a\\]";; esac',
116
+ `case "$PS1" in *9998*) ;; *) PS1="\${PS1}\\[\\e]9998;${instanceTag};READY\\a\\]";; esac`,
115
117
  "",
116
118
  "# Mirrors the zsh \\e[9999~ reset-prompt widget — used by agent-sh",
117
119
  "# to repaint the prompt in place. All keymaps so `set -o vi` works.",
@@ -155,7 +157,7 @@ export class Shell {
155
157
  }
156
158
  this.bus = opts.bus;
157
159
  this.handlers = opts.handlers;
158
- this.outputParser = new OutputParser(opts.bus, opts.cwd);
160
+ this.outputParser = new OutputParser(opts.bus, opts.cwd, instanceTag);
159
161
  // Ensure temp dir cleanup on abnormal exit (SIGKILL won't fire this,
160
162
  // but it covers uncaught exceptions and normal process.exit paths)
161
163
  if (this.tmpDir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",
@@ -127,6 +127,8 @@
127
127
  "node": ">=18"
128
128
  },
129
129
  "dependencies": {
130
+ "@xterm/addon-serialize": "^0.13.0",
131
+ "@xterm/headless": "^5.5.0",
130
132
  "cli-highlight": "^2.1.11",
131
133
  "diff": "^9.0.0",
132
134
  "marked": "^17.0.6",