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 +10 -4
- package/dist/agent/agent-loop.js +26 -11
- package/dist/agent/conversation-state.d.ts +11 -0
- package/dist/agent/conversation-state.js +75 -1
- package/dist/agent/tools/bash.js +10 -3
- package/dist/agent/types.d.ts +3 -1
- package/dist/core.d.ts +2 -0
- package/dist/core.js +1 -0
- package/dist/event-bus.js +1 -1
- package/dist/index.js +1 -0
- package/dist/shell/output-parser.d.ts +2 -1
- package/dist/shell/output-parser.js +33 -18
- package/dist/shell/shell.d.ts +1 -0
- package/dist/shell/shell.js +8 -6
- package/package.json +10 -2
- package/examples/extensions/ash-acp-bridge/src/index.ts +0 -574
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
|
|
26
|
+
Install from npm:
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
npm install -g
|
|
29
|
+
npm install -g agent-sh
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
|
|
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
|
-
|
|
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.
|
package/dist/agent/agent-loop.js
CHANGED
|
@@ -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 =
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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;
|
package/dist/agent/tools/bash.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/agent/types.d.ts
CHANGED
|
@@ -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
|
|
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
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
|
|
53
|
-
|
|
54
|
-
if (idx === -1)
|
|
60
|
+
const match = PREEXEC_RE.exec(data);
|
|
61
|
+
if (!match)
|
|
55
62
|
return data;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return data
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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");
|
package/dist/shell/shell.d.ts
CHANGED
package/dist/shell/shell.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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.
|
|
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)
|