agent-sh 0.12.0 → 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,8 +54,18 @@ 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[];
62
+ /**
63
+ * If a stream was interrupted mid-tool-execution, an assistant message
64
+ * with tool_calls can land in history without matching tool results.
65
+ * Strict providers (DeepSeek) 400 on this. Stub each missing result
66
+ * with a [cancelled] marker so the protocol stays valid.
67
+ */
68
+ private stubDanglingToolCalls;
58
69
  /**
59
70
  * DeepSeek 400s if any assistant in a thinking-mode conversation is
60
71
  * missing reasoning_content. Cross-alias here (OpenRouter streams as
@@ -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,18 +104,86 @@ 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
- return this.normalizeReasoningConsistency(this.messages);
156
+ return this.normalizeReasoningConsistency(this.stubDanglingToolCalls(this.messages));
157
+ }
158
+ /**
159
+ * If a stream was interrupted mid-tool-execution, an assistant message
160
+ * with tool_calls can land in history without matching tool results.
161
+ * Strict providers (DeepSeek) 400 on this. Stub each missing result
162
+ * with a [cancelled] marker so the protocol stays valid.
163
+ */
164
+ stubDanglingToolCalls(messages) {
165
+ const result = [];
166
+ let i = 0;
167
+ while (i < messages.length) {
168
+ const msg = messages[i];
169
+ result.push(msg);
170
+ i++;
171
+ if (msg.role !== "assistant" || !("tool_calls" in msg) || !msg.tool_calls)
172
+ continue;
173
+ const seen = new Set();
174
+ while (i < messages.length && messages[i].role === "tool") {
175
+ const t = messages[i];
176
+ seen.add(t.tool_call_id);
177
+ result.push(t);
178
+ i++;
179
+ }
180
+ for (const tc of msg.tool_calls) {
181
+ if (!seen.has(tc.id)) {
182
+ result.push({ role: "tool", tool_call_id: tc.id, content: "[cancelled]" });
183
+ }
184
+ }
185
+ }
186
+ return result;
115
187
  }
116
188
  /**
117
189
  * DeepSeek 400s if any assistant in a thinking-mode conversation is
@@ -145,6 +217,7 @@ export class ConversationState {
145
217
  this.invalidateMessagesCache();
146
218
  this.lastApiTokenCount = null;
147
219
  this.lastApiMessageCount = 0;
220
+ this.flushPendingNotes();
148
221
  }
149
222
  pruneToolErrors() {
150
223
  if (this.toolErrors.size === 0)
@@ -444,6 +517,7 @@ export class ConversationState {
444
517
  this.nuclearEntries = [];
445
518
  this.nuclearBySeq.clear();
446
519
  this.recallArchive.clear();
520
+ this.pendingNotes = [];
447
521
  this.invalidateMessagesCache();
448
522
  this.lastApiTokenCount = null;
449
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/event-bus.js CHANGED
@@ -6,7 +6,7 @@ import { EventEmitter } from "node:events";
6
6
  * can modify the payload before passing to the next
7
7
  */
8
8
  export class EventBus {
9
- emitter = new EventEmitter();
9
+ emitter = new EventEmitter().setMaxListeners(0);
10
10
  pipeListeners = new Map();
11
11
  asyncPipeListeners = new Map();
12
12
  /** Subscribe to a fire-and-forget event. */
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.0",
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",
@@ -89,7 +89,13 @@
89
89
  },
90
90
  "files": [
91
91
  "dist",
92
- "examples"
92
+ "examples/extensions/*.ts",
93
+ "examples/extensions/*/package.json",
94
+ "examples/extensions/*/tsconfig.json",
95
+ "examples/extensions/*/README.md",
96
+ "examples/extensions/*/src",
97
+ "examples/extensions/*/index.ts",
98
+ "examples/extensions/*/index.js"
93
99
  ],
94
100
  "scripts": {
95
101
  "dev": "tsx src/index.ts",
@@ -121,6 +127,8 @@
121
127
  "node": ">=18"
122
128
  },
123
129
  "dependencies": {
130
+ "@xterm/addon-serialize": "^0.13.0",
131
+ "@xterm/headless": "^5.5.0",
124
132
  "cli-highlight": "^2.1.11",
125
133
  "diff": "^9.0.0",
126
134
  "marked": "^17.0.6",
@@ -1,574 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * agent-sh-acp — ACP (Agent Client Protocol) server wrapping agent-sh's
4
- * headless core. Speaks JSON-RPC 2.0 over stdin/stdout so agent-shell
5
- * (Emacs) can drive it as a backend.
6
- *
7
- * Usage:
8
- * agent-sh-acp # uses settings from ~/.agent-sh/settings.json
9
- * agent-sh-acp --model gpt-4o # override model
10
- *
11
- * In agent-shell (Emacs):
12
- * (setq agent-shell-agentsh-acp-command '("agent-sh-acp"))
13
- */
14
- import { createCore, type AgentShellCore } from "agent-sh";
15
- import { loadExtensions } from "agent-sh/extension-loader";
16
- import { loadBuiltinExtensions } from "agent-sh/extensions";
17
- import { getSettings } from "agent-sh/settings";
18
- import type { ContentBlock } from "agent-sh/types";
19
-
20
- // ── JSON-RPC types ──────────────────────────────────────────────────
21
-
22
- interface JsonRpcRequest {
23
- jsonrpc: "2.0";
24
- method: string;
25
- params?: Record<string, unknown>;
26
- id?: number | string;
27
- }
28
-
29
- interface JsonRpcResponse {
30
- jsonrpc: "2.0";
31
- id: number | string;
32
- result?: unknown;
33
- error?: { code: number; message: string; data?: unknown };
34
- }
35
-
36
- interface JsonRpcNotification {
37
- jsonrpc: "2.0";
38
- method: string;
39
- params?: Record<string, unknown>;
40
- }
41
-
42
- // ── ACP content block ───────────────────────────────────────────────
43
-
44
- interface AcpContentBlock {
45
- type: string;
46
- text?: string;
47
- data?: string;
48
- mimeType?: string;
49
- }
50
-
51
- // ── Stdio transport ─────────────────────────────────────────────────
52
-
53
- function send(msg: JsonRpcResponse | JsonRpcNotification): void {
54
- const line = JSON.stringify(msg) + "\n";
55
- process.stdout.write(line);
56
- }
57
-
58
- function sendResult(id: number | string, result: unknown): void {
59
- send({ jsonrpc: "2.0", id, result });
60
- }
61
-
62
- function sendError(id: number | string, code: number, message: string, data?: unknown): void {
63
- send({ jsonrpc: "2.0", id, error: { code, message, data } });
64
- }
65
-
66
- function sendNotification(method: string, params: Record<string, unknown>): void {
67
- send({ jsonrpc: "2.0", method, params });
68
- }
69
-
70
- // ── ACP session/update helpers ──────────────────────────────────────
71
-
72
- function sendSessionUpdate(update: Record<string, unknown>): void {
73
- sendNotification("session/update", { update });
74
- }
75
-
76
- function sendTextChunk(text: string): void {
77
- sendSessionUpdate({
78
- sessionUpdate: "agent_message_chunk",
79
- content: { type: "text", text },
80
- });
81
- }
82
-
83
- function sendThinkingChunk(text: string): void {
84
- sendSessionUpdate({
85
- sessionUpdate: "agent_thought_chunk",
86
- content: { type: "text", text },
87
- });
88
- }
89
-
90
- function sendToolCall(
91
- toolCallId: string,
92
- title: string,
93
- kind: string,
94
- rawInput?: unknown,
95
- ): void {
96
- sendSessionUpdate({
97
- sessionUpdate: "tool_call",
98
- toolCallId,
99
- title,
100
- status: "pending",
101
- kind,
102
- content: [],
103
- rawInput,
104
- });
105
- }
106
-
107
- function sendToolCallUpdate(
108
- toolCallId: string,
109
- status: string,
110
- content: AcpContentBlock[],
111
- kind?: string,
112
- ): void {
113
- sendSessionUpdate({
114
- sessionUpdate: "tool_call_update",
115
- toolCallId,
116
- status,
117
- content,
118
- kind,
119
- });
120
- }
121
-
122
- function sendUsageUpdate(
123
- inputTokens: number,
124
- outputTokens: number,
125
- ): void {
126
- sendSessionUpdate({
127
- sessionUpdate: "usage_update",
128
- inputTokens,
129
- outputTokens,
130
- cacheCreationInputTokens: 0,
131
- cacheReadInputTokens: 0,
132
- });
133
- }
134
-
135
- // ── Permission bridge ───────────────────────────────────────────────
136
-
137
- let nextPermissionId = 1;
138
- const pendingPermissions = new Map<
139
- number,
140
- { resolve: (outcome: string) => void }
141
- >();
142
-
143
- function buildPermissionToolCall(
144
- title: string,
145
- kind: string,
146
- metadata: Record<string, unknown>,
147
- toolCallId: string,
148
- ): { toolCall: Record<string, unknown> } {
149
- const args = (metadata.args ?? {}) as Record<string, unknown>;
150
-
151
- // Map agent-sh permission kinds → ACP tool call shapes
152
- if (kind === "file-write") {
153
- // File edit/write — send diff content block + rawInput for agent-shell
154
- const content: unknown[] = [];
155
- const rawInput: Record<string, unknown> = {};
156
-
157
- // Set path for title display
158
- const filePath = (args.path as string) ?? "";
159
- rawInput.path = filePath;
160
- rawInput.file_path = filePath;
161
-
162
- // For edit_file: old_str/new_str so agent-shell can render a diff
163
- if (typeof args.old_text === "string") {
164
- rawInput.old_str = args.old_text;
165
- rawInput.new_str = args.new_text ?? "";
166
- content.push({
167
- type: "diff",
168
- oldText: args.old_text,
169
- newText: args.new_text ?? "",
170
- path: filePath,
171
- });
172
- } else if (typeof args.content === "string") {
173
- // write_file (new file or full overwrite)
174
- rawInput.new_str = args.content;
175
- rawInput.old_str = "";
176
- content.push({
177
- type: "diff",
178
- oldText: "",
179
- newText: args.content,
180
- path: filePath,
181
- });
182
- }
183
-
184
- if (typeof args.description === "string") {
185
- rawInput.description = args.description;
186
- }
187
-
188
- return {
189
- toolCall: {
190
- toolCallId,
191
- title,
192
- status: "pending",
193
- kind: "diff",
194
- content,
195
- rawInput,
196
- },
197
- };
198
- }
199
-
200
- // Generic tool call (bash, etc.)
201
- const rawInput: Record<string, unknown> = {};
202
- if (typeof args.command === "string") {
203
- rawInput.command = args.command;
204
- }
205
- if (typeof args.description === "string") {
206
- rawInput.description = args.description;
207
- }
208
-
209
- return {
210
- toolCall: {
211
- toolCallId,
212
- title,
213
- status: "pending",
214
- kind: kind === "tool-call" ? "execute" : kind,
215
- content: [],
216
- rawInput,
217
- },
218
- };
219
- }
220
-
221
- function requestPermission(
222
- title: string,
223
- kind: string,
224
- metadata: Record<string, unknown>,
225
- toolCallId?: string,
226
- ): Promise<string> {
227
- const id = nextPermissionId++;
228
- const tcId = toolCallId ?? `perm-${id}`;
229
- return new Promise((resolve) => {
230
- pendingPermissions.set(id, { resolve });
231
- const { toolCall } = buildPermissionToolCall(title, kind, metadata, tcId);
232
- send({
233
- jsonrpc: "2.0",
234
- method: "session/request_permission",
235
- id,
236
- params: {
237
- toolCall,
238
- options: [
239
- { id: "accepted", name: "Accept", description: "Accept this action" },
240
- { id: "rejected", name: "Reject", description: "Reject this action" },
241
- { id: "always", name: "Always allow", description: "Always allow for this session" },
242
- ],
243
- },
244
- } as any);
245
- });
246
- }
247
-
248
- // ── Core setup ──────────────────────────────────────────────────────
249
-
250
- function parseArgs(): { model?: string; provider?: string } {
251
- const args = process.argv.slice(2);
252
- const result: Record<string, string> = {};
253
- for (let i = 0; i < args.length; i++) {
254
- if (args[i] === "--model" && args[i + 1]) result.model = args[++i];
255
- if (args[i] === "--provider" && args[i + 1]) result.provider = args[++i];
256
- }
257
- return result;
258
- }
259
-
260
- const cliArgs = parseArgs();
261
- let core: AgentShellCore | null = null;
262
- let sessionId: string | null = null;
263
- let sessionCwd: string = process.cwd();
264
-
265
- // Track tool output chunks per toolCallId so we can send accumulated content
266
- const toolOutputBuffers = new Map<string, string>();
267
-
268
- // Track the active prompt request id so we can respond when processing is done
269
- let activePromptRequestId: number | string | null = null;
270
-
271
- // Track always-allowed permission kinds
272
- const alwaysAllowed = new Set<string>();
273
-
274
- // Track in-flight async operations so stdin end can wait
275
- let pendingOp: Promise<void> = Promise.resolve();
276
-
277
- // ── Wire agent-sh events → ACP notifications ───────────────────────
278
-
279
- function wireEvents(core: AgentShellCore): void {
280
- const { bus } = core;
281
-
282
- bus.on("agent:response-chunk", ({ blocks }) => {
283
- for (const block of blocks) {
284
- if (block.type === "text") {
285
- sendTextChunk(block.text);
286
- }
287
- // code-block blocks are sent as text (agent-shell renders markdown)
288
- if (block.type === "code-block") {
289
- sendTextChunk("```" + block.language + "\n" + block.code + "\n```");
290
- }
291
- }
292
- });
293
-
294
- bus.on("agent:thinking-chunk", ({ text }) => {
295
- sendThinkingChunk(text);
296
- });
297
-
298
- bus.on("agent:tool-started", (e) => {
299
- const id = e.toolCallId ?? `tool-${Date.now()}`;
300
- toolOutputBuffers.set(id, "");
301
- sendToolCall(id, e.title, e.kind ?? "tool", e.rawInput);
302
- });
303
-
304
- bus.on("agent:tool-output-chunk", ({ chunk }) => {
305
- // Accumulate — we don't know toolCallId here, but only one tool runs at a time
306
- // in sequential mode. For parallel tools this is best-effort.
307
- for (const [id, buf] of toolOutputBuffers) {
308
- toolOutputBuffers.set(id, buf + chunk);
309
- }
310
- });
311
-
312
- bus.on("agent:tool-completed", (e) => {
313
- const id = e.toolCallId ?? [...toolOutputBuffers.keys()].pop() ?? "unknown";
314
- const output = toolOutputBuffers.get(id) ?? "";
315
- toolOutputBuffers.delete(id);
316
-
317
- const status = e.exitCode === 0 || e.exitCode === null ? "completed" : "failed";
318
- const content: AcpContentBlock[] = output
319
- ? [{ type: "text", text: output }]
320
- : [];
321
- sendToolCallUpdate(id, status, content, e.kind);
322
- });
323
-
324
- bus.on("agent:usage", ({ prompt_tokens, completion_tokens }) => {
325
- sendUsageUpdate(prompt_tokens, completion_tokens);
326
- });
327
-
328
- bus.on("agent:processing-done", () => {
329
- if (activePromptRequestId !== null) {
330
- sendResult(activePromptRequestId, { stopReason: "end_turn" });
331
- activePromptRequestId = null;
332
- }
333
- });
334
-
335
- bus.on("agent:error", ({ message }) => {
336
- if (activePromptRequestId !== null) {
337
- sendError(activePromptRequestId, -32603, message);
338
- activePromptRequestId = null;
339
- }
340
- });
341
-
342
- bus.on("agent:cancelled", () => {
343
- if (activePromptRequestId !== null) {
344
- sendResult(activePromptRequestId, { stopReason: "cancelled" });
345
- activePromptRequestId = null;
346
- }
347
- });
348
-
349
- // Permission gating — auto-approve all tool calls.
350
- // agent-sh's built-in tools handle their own safety; the ACP layer
351
- // doesn't add a second permission gate. If you want to bridge
352
- // permissions to agent-shell's UI, replace this with the
353
- // requestPermission() flow.
354
- bus.onPipeAsync("permission:request", async (payload) => {
355
- payload.decision = { outcome: "approved" };
356
- return payload;
357
- });
358
- }
359
-
360
- // ── ACP method handlers ─────────────────────────────────────────────
361
-
362
- function getModelsPayload(): Record<string, unknown> | undefined {
363
- if (!core) return undefined;
364
- const info = core.bus.emitPipe("config:get-models", { models: [], active: null });
365
- if (!info.models.length) return undefined;
366
- return {
367
- currentModelId: info.active ?? info.models[0]?.model,
368
- availableModels: info.models.map((m) => ({
369
- modelId: m.model,
370
- name: m.provider ? `${m.provider}/${m.model}` : m.model,
371
- description: m.provider ? `Provider: ${m.provider}` : "",
372
- })),
373
- };
374
- }
375
-
376
- function handleInitialize(id: number | string): void {
377
- sendResult(id, {
378
- agentCapabilities: {
379
- promptCapabilities: {
380
- image: false,
381
- embeddedContext: true,
382
- },
383
- sessionCapabilities: {},
384
- },
385
- modes: {
386
- currentModeId: "default",
387
- availableModes: [
388
- { id: "default", name: "Default", description: "Standard mode" },
389
- ],
390
- },
391
- });
392
- }
393
-
394
- async function handleSessionNew(id: number | string, params: Record<string, unknown>): Promise<void> {
395
- sessionCwd = (params.cwd as string) ?? process.cwd();
396
- process.chdir(sessionCwd);
397
-
398
- // Create core lazily on first session
399
- if (!core) {
400
- core = createCore({
401
- model: cliArgs.model,
402
- provider: cliArgs.provider,
403
- });
404
- wireEvents(core);
405
-
406
- const extCtx = core.extensionContext({ quit: () => process.exit(0) });
407
- const settings = getSettings();
408
-
409
- // Load built-in extensions first (agent-backend, slash-commands, etc.)
410
- // Skip TUI-only extensions that don't apply in headless mode
411
- const headlessDisabled = [
412
- "tui-renderer",
413
- "file-autocomplete",
414
- "overlay-agent",
415
- ...(settings.disabledBuiltins ?? []),
416
- ];
417
- await loadBuiltinExtensions(extCtx, headlessDisabled);
418
-
419
- // Load user extensions with a timeout (some may hang in headless mode)
420
- const TIMEOUT_MS = 10000;
421
- await Promise.race([
422
- loadExtensions(extCtx),
423
- new Promise<void>((_, reject) =>
424
- setTimeout(() => reject(new Error(`Extension loading timeout after ${TIMEOUT_MS}ms`)), TIMEOUT_MS),
425
- ),
426
- ]).catch((err) => {
427
- process.stderr.write(`Warning: ${err instanceof Error ? err.message : err}\n`);
428
- });
429
-
430
- // Signal deferred-init listeners (agent-backend) that the provider
431
- // registry is complete — they resolve their LLM config on this event.
432
- core.bus.emit("core:extensions-loaded", {});
433
-
434
- core.activateBackend();
435
- }
436
-
437
- sessionId = `session-${Date.now()}`;
438
- const result: Record<string, unknown> = {
439
- sessionId,
440
- modes: {
441
- currentModeId: "default",
442
- availableModes: [
443
- { id: "default", name: "Default", description: "Standard mode" },
444
- ],
445
- },
446
- };
447
- const models = getModelsPayload();
448
- if (models) result.models = models;
449
- sendResult(id, result);
450
- }
451
-
452
- function handleSessionPrompt(id: number | string, params: Record<string, unknown>): void {
453
- if (!core) {
454
- sendError(id, -32603, "No active session");
455
- return;
456
- }
457
-
458
- // Extract text from prompt content blocks
459
- const prompt = params.prompt as Array<{ type: string; text?: string; resource?: { text?: string } }>;
460
- const parts: string[] = [];
461
- for (const block of prompt) {
462
- if (block.type === "text" && block.text) {
463
- parts.push(block.text);
464
- } else if (block.type === "resource" && block.resource?.text) {
465
- parts.push(block.resource.text);
466
- }
467
- }
468
-
469
- const query = parts.join("\n");
470
- if (!query) {
471
- sendResult(id, { stopReason: "end_turn" });
472
- return;
473
- }
474
-
475
- // Store the request id — we'll respond when agent:processing-done fires
476
- activePromptRequestId = id;
477
- core.bus.emit("agent:submit", { query });
478
- }
479
-
480
- function handleSessionSetMode(id: number | string, _params: Record<string, unknown>): void {
481
- // Acknowledge — agent-sh doesn't have distinct modes yet
482
- sendResult(id, {});
483
- }
484
-
485
- // ── Message dispatcher ──────────────────────────────────────────────
486
-
487
- function dispatch(msg: JsonRpcRequest): void {
488
- const { method, params, id } = msg;
489
-
490
- // Handle responses to our outgoing requests (permission responses)
491
- if (!method && id !== undefined && (msg as any).result !== undefined) {
492
- const pending = pendingPermissions.get(id as number);
493
- if (pending) {
494
- pendingPermissions.delete(id as number);
495
- const result = (msg as any).result;
496
- const outcome = result?.outcome?.optionId ?? result?.outcome?.outcome ?? "rejected";
497
- pending.resolve(outcome);
498
- }
499
- return;
500
- }
501
-
502
- if (!id && !method) return; // ignore malformed
503
-
504
- switch (method) {
505
- case "initialize":
506
- handleInitialize(id!);
507
- break;
508
- case "session/new":
509
- pendingOp = handleSessionNew(id!, params ?? {}).catch((err) => {
510
- sendError(id!, -32603, err instanceof Error ? err.message : String(err));
511
- });
512
- break;
513
- case "session/prompt":
514
- handleSessionPrompt(id!, params ?? {});
515
- break;
516
- case "session/set_mode":
517
- handleSessionSetMode(id!, params ?? {});
518
- break;
519
- case "session/set_model":
520
- if (core && params?.modelId) {
521
- core.bus.emit("config:switch-model", { model: params.modelId as string });
522
- }
523
- sendResult(id!, {
524
- models: getModelsPayload() ?? {},
525
- });
526
- break;
527
- case "session/cancel":
528
- if (core) {
529
- core.bus.emit("agent:cancel-request", {});
530
- }
531
- // Notification — no response needed
532
- break;
533
- default:
534
- if (id !== undefined) {
535
- sendError(id, -32601, `Method not found: ${method}`);
536
- }
537
- }
538
- }
539
-
540
- // ── Stdin line reader ───────────────────────────────────────────────
541
-
542
- let buffer = "";
543
-
544
- process.stdin.setEncoding("utf-8");
545
- process.stdin.on("data", (chunk: string) => {
546
- buffer += chunk;
547
- let newlineIdx: number;
548
- while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
549
- const line = buffer.slice(0, newlineIdx).trim();
550
- buffer = buffer.slice(newlineIdx + 1);
551
- if (!line) continue;
552
- try {
553
- const msg = JSON.parse(line) as JsonRpcRequest;
554
- dispatch(msg);
555
- } catch {
556
- // Skip malformed JSON
557
- }
558
- }
559
- });
560
-
561
- process.stdin.on("end", async () => {
562
- // Wait for any in-flight async operations (e.g. session/new) to settle
563
- await pendingOp;
564
- core?.kill();
565
- process.exit(0);
566
- });
567
-
568
- // Log unhandled rejections to stderr (don't crash, but don't swallow silently)
569
- process.on("unhandledRejection", (err) => {
570
- process.stderr.write(`[ash-acp-bridge] unhandled rejection: ${err instanceof Error ? err.message : err}\n`);
571
- });
572
-
573
- // Redirect stderr from agent-sh internals so it doesn't pollute the protocol
574
- // (agent-shell reads stdout only; stderr goes to its log)