@bike4mind/cli 0.18.5 → 0.20.1
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/LICENSE +1 -1
- package/README.md +204 -35
- package/bin/bike4mind-cli.mjs +137 -24
- package/bin/hearth-hook.mjs +292 -0
- package/dist/AgentHistoryStore-BQiATPsQ.mjs +35755 -0
- package/dist/ApiClient-BPmlalut.mjs +277 -0
- package/dist/{ConfigStore-D39UqFnY.mjs → ConfigStore-CNfbeaJf.mjs} +6702 -4122
- package/dist/{ImageStore-BVmEG1xc.mjs → ImageStore-kVo-oHoS.mjs} +2 -2
- package/dist/PluginStore-DwvOJ-G3.mjs +206 -0
- package/dist/ProxyManager-Bqr7Lmsd.mjs +3 -0
- package/dist/{ProxyManager-CV94yZUW.mjs → ProxyManager-C5H0pUyK.mjs} +2 -2
- package/dist/{SandboxOrchestrator-BS6gALNq.mjs → SandboxOrchestrator-BFPVpmB5.mjs} +1 -1
- package/dist/{SandboxOrchestrator-BoINxbX4.mjs → SandboxOrchestrator-C8uleDn2.mjs} +7 -7
- package/dist/ShellSessionManager-6o8KZzl1-vrbPAUTq.mjs +252 -0
- package/dist/{ViolationLogStore-B-plqJfn.mjs → ViolationLogStore-byEhxa2A.mjs} +1 -1
- package/dist/WorkItemsClient-Cow6nXx7.mjs +382 -0
- package/dist/{bashExecute-B1N1lMOS-TZVDbcQ4.mjs → bashExecute-CrdPpBqk-DCATrE-D.mjs} +116 -16
- package/dist/buildAgent-DwPvcTpz.mjs +824 -0
- package/dist/commands/acpCommand.mjs +798 -0
- package/dist/commands/apiCommand.mjs +14 -16
- package/dist/commands/doctorCommand.mjs +5 -5
- package/dist/commands/envCommand.mjs +1 -1
- package/dist/commands/headlessCommand.mjs +272 -76
- package/dist/commands/mcpCommand.mjs +14 -1
- package/dist/commands/pluginCommand.mjs +232 -0
- package/dist/commands/updateCommand.mjs +10 -9
- package/dist/{grepSearch-DJs-cubo-Bm0Y8oS3.mjs → grepSearch-BaYUfIYs-C-fxWc9G.mjs} +3 -3
- package/dist/index.mjs +3284 -2307
- package/dist/{package-I_v_WFUn.mjs → package-CxHSRXdp.mjs} +1 -1
- package/dist/serve-Du3HiqAH.mjs +772 -0
- package/dist/store-BG3e54c8.mjs +3 -0
- package/dist/{store-DV5s-qni.mjs → store-CvjTpQPs.mjs} +70 -3
- package/dist/{terminalSetup-BbJt04ZG.mjs → terminalSetup-DjXAwpDy.mjs} +2 -3
- package/dist/{treeSitterEngine-BRbQ9b7I.mjs → treeSitterEngine-QBE3YkmG.mjs} +51 -1
- package/dist/{updateChecker-C8xsNY2L.mjs → updateChecker-CQW8bxo6.mjs} +10 -10
- package/package.json +48 -43
- package/dist/BackgroundAgentManager-D-xsWd3C.mjs +0 -27303
- package/dist/ProxyManager-ByuAHFMq.mjs +0 -3
- package/dist/store-DgzCTRkN.mjs +0 -3
- package/dist/utils-Cdktpk_k.mjs +0 -158
- package/dist/utils-DEizxshI.mjs +0 -3
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { $ as RemoteSkillSource, A as FallbackLlmBackend, C as createWriteTodosTool, D as createResumeAgentTool, E as createCoordinateTaskTool, I as PermissionManager, J as setWebSocketToolExecutor, O as createBackgroundAgentTools, Q as isReadOnlyTool, S as createTodoStore, et as CustomCommandStore, k as createAgentDelegateTool, nt as SessionStore, tt as CheckpointStore, w as createSkillTool, x as createFindDefinitionTool, y as createGetFileStructureTool } from "../AgentHistoryStore-BQiATPsQ.mjs";
|
|
3
|
+
import { c as requireApiUrl, n as logger, t as ConfigStore } from "../ConfigStore-CNfbeaJf.mjs";
|
|
4
|
+
import { t as ApiClient } from "../ApiClient-BPmlalut.mjs";
|
|
5
|
+
import { a as createToolSearchTool, i as buildLlmBackend, n as buildSupportingStores, o as deferredToolRegistry, r as buildSandbox, s as NotifyingLlmBackend, t as buildAgent } from "../buildAgent-DwPvcTpz.mjs";
|
|
6
|
+
import { randomUUID } from "crypto";
|
|
7
|
+
import { existsSync, realpathSync, statSync } from "fs";
|
|
8
|
+
import { isAbsolute } from "path";
|
|
9
|
+
import { Readable } from "node:stream";
|
|
10
|
+
import { PROTOCOL_VERSION, RequestError, agent, methods, ndJsonStream } from "@agentclientprotocol/sdk";
|
|
11
|
+
import { Mutex } from "async-mutex";
|
|
12
|
+
//#region src/acp/protocol.ts
|
|
13
|
+
/**
|
|
14
|
+
* Pure mapping layer between the B4M agent core and the Agent Client Protocol
|
|
15
|
+
* (ACP) wire types. Everything here is side-effect free so it can be unit
|
|
16
|
+
* tested without a live connection - the stateful server (AcpServer) composes
|
|
17
|
+
* these helpers.
|
|
18
|
+
*
|
|
19
|
+
* ACP spec: https://agentclientprotocol.com
|
|
20
|
+
*/
|
|
21
|
+
/** Protocol version this agent implements (re-exported for the server). */
|
|
22
|
+
const ACP_PROTOCOL_VERSION = PROTOCOL_VERSION;
|
|
23
|
+
/** Identifies this agent to ACP clients (shown in the editor's agent panel). */
|
|
24
|
+
const AGENT_INFO = {
|
|
25
|
+
name: "bike4mind",
|
|
26
|
+
title: "Bike4Mind"
|
|
27
|
+
};
|
|
28
|
+
const ACP_MODE_PLAN = "plan";
|
|
29
|
+
/** ACP mode ids that a client is permitted to select. Order = display order. */
|
|
30
|
+
const SAFE_ACP_MODES = [{
|
|
31
|
+
id: "ask",
|
|
32
|
+
name: "Ask",
|
|
33
|
+
description: "Prompts for permission before every gated tool call."
|
|
34
|
+
}, {
|
|
35
|
+
id: ACP_MODE_PLAN,
|
|
36
|
+
name: "Plan",
|
|
37
|
+
description: "Planning-oriented; still prompts for permission on gated tools."
|
|
38
|
+
}];
|
|
39
|
+
/** The mode state advertised on session/new and session/load. */
|
|
40
|
+
function buildSessionModeState(currentModeId = "ask") {
|
|
41
|
+
return {
|
|
42
|
+
currentModeId,
|
|
43
|
+
availableModes: SAFE_ACP_MODES
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Map a client-selected ACP mode id to a CLI interaction mode. Returns null for
|
|
48
|
+
* any id outside the safe allowlist so the caller can reject it (fail closed) -
|
|
49
|
+
* this is what keeps the unsafe 'auto-accept' no-prompt mode off the wire.
|
|
50
|
+
*/
|
|
51
|
+
function acpModeToInteraction(modeId) {
|
|
52
|
+
switch (modeId) {
|
|
53
|
+
case "ask": return "normal";
|
|
54
|
+
case ACP_MODE_PLAN: return "plan";
|
|
55
|
+
default: return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Classify a B4M tool into an ACP ToolKind so the editor can pick an icon and
|
|
60
|
+
* UI treatment. Driven by tool-name heuristics (mutation verbs win over read
|
|
61
|
+
* verbs); an unclassifiable tool maps to 'other' rather than being assumed
|
|
62
|
+
* read-only.
|
|
63
|
+
*/
|
|
64
|
+
function toolKind(toolName) {
|
|
65
|
+
const name = toolName.toLowerCase();
|
|
66
|
+
if (/delete|remove|(^|_)rm(_|$)/.test(name)) return "delete";
|
|
67
|
+
if (/move|rename|(^|_)mv(_|$)/.test(name)) return "move";
|
|
68
|
+
if (/write|edit|patch|apply|create|update|append/.test(name)) return "edit";
|
|
69
|
+
if (/exec|run|shell|bash|command|terminal/.test(name)) return "execute";
|
|
70
|
+
if (/search|grep|find|glob|list/.test(name)) return "search";
|
|
71
|
+
if (/fetch|web|http|url|download|crawl/.test(name)) return "fetch";
|
|
72
|
+
if (/read|cat|view|show|open|get_file|structure|definition/.test(name)) return "read";
|
|
73
|
+
return "other";
|
|
74
|
+
}
|
|
75
|
+
/** Compact a tool's input into a one-line human-readable title. */
|
|
76
|
+
function toolCallTitle(toolName, toolInput) {
|
|
77
|
+
const summary = summarizeInput(toolInput);
|
|
78
|
+
return summary ? `${toolName}(${summary})` : toolName;
|
|
79
|
+
}
|
|
80
|
+
function summarizeInput(input) {
|
|
81
|
+
if (input == null) return void 0;
|
|
82
|
+
if (typeof input === "string") return truncate(input, 80);
|
|
83
|
+
try {
|
|
84
|
+
return truncate(JSON.stringify(input), 80);
|
|
85
|
+
} catch {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function truncate(text, max) {
|
|
90
|
+
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
|
|
91
|
+
}
|
|
92
|
+
const PERMISSION_OPTION_ALLOW_ONCE = "allow-once";
|
|
93
|
+
const PERMISSION_OPTION_ALLOW_ALWAYS = "allow-always";
|
|
94
|
+
const PERMISSION_OPTION_REJECT = "deny";
|
|
95
|
+
function buildPermissionOptions() {
|
|
96
|
+
return [
|
|
97
|
+
{
|
|
98
|
+
optionId: PERMISSION_OPTION_ALLOW_ONCE,
|
|
99
|
+
name: "Allow once",
|
|
100
|
+
kind: "allow_once"
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
optionId: PERMISSION_OPTION_ALLOW_ALWAYS,
|
|
104
|
+
name: "Always allow",
|
|
105
|
+
kind: "allow_always"
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
optionId: PERMISSION_OPTION_REJECT,
|
|
109
|
+
name: "Reject",
|
|
110
|
+
kind: "reject_once"
|
|
111
|
+
}
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Resolve an ACP permission outcome to a CLI PermissionResponse. Fails CLOSED:
|
|
116
|
+
* a cancelled turn, an unknown option id, or a missing outcome all deny.
|
|
117
|
+
*/
|
|
118
|
+
function permissionResponseFromOutcome(outcome) {
|
|
119
|
+
if (!outcome || outcome.outcome !== "selected") return "deny";
|
|
120
|
+
switch (outcome.optionId) {
|
|
121
|
+
case PERMISSION_OPTION_ALLOW_ONCE: return "allow-once";
|
|
122
|
+
case PERMISSION_OPTION_ALLOW_ALWAYS: return "allow-always";
|
|
123
|
+
case PERMISSION_OPTION_REJECT: return "deny";
|
|
124
|
+
default: return "deny";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Flatten a prompt's content blocks into a single text string for the agent.
|
|
129
|
+
* Text blocks pass through; resource links are rendered as an @-reference so
|
|
130
|
+
* the agent's file tools can pick them up. Image/audio/embedded blocks are
|
|
131
|
+
* summarized as a placeholder (v1 sends text to the agent core).
|
|
132
|
+
*/
|
|
133
|
+
function contentBlocksToText(blocks) {
|
|
134
|
+
const parts = [];
|
|
135
|
+
for (const block of blocks) switch (block.type) {
|
|
136
|
+
case "text":
|
|
137
|
+
parts.push(block.text);
|
|
138
|
+
break;
|
|
139
|
+
case "resource_link":
|
|
140
|
+
parts.push(`@${block.uri}`);
|
|
141
|
+
break;
|
|
142
|
+
case "resource":
|
|
143
|
+
if ("resource" in block && block.resource && "text" in block.resource && typeof block.resource.text === "string") parts.push(block.resource.text);
|
|
144
|
+
else if ("resource" in block && block.resource && "uri" in block.resource) parts.push(`@${block.resource.uri}`);
|
|
145
|
+
break;
|
|
146
|
+
case "image":
|
|
147
|
+
parts.push("[image]");
|
|
148
|
+
break;
|
|
149
|
+
case "audio": parts.push("[audio]");
|
|
150
|
+
}
|
|
151
|
+
return parts.join("\n").trim();
|
|
152
|
+
}
|
|
153
|
+
function textChunk(text) {
|
|
154
|
+
return {
|
|
155
|
+
type: "text",
|
|
156
|
+
text
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function agentMessageChunk(text) {
|
|
160
|
+
return {
|
|
161
|
+
sessionUpdate: "agent_message_chunk",
|
|
162
|
+
content: textChunk(text)
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function agentThoughtChunk(text) {
|
|
166
|
+
return {
|
|
167
|
+
sessionUpdate: "agent_thought_chunk",
|
|
168
|
+
content: textChunk(text)
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function userMessageChunk(text) {
|
|
172
|
+
return {
|
|
173
|
+
sessionUpdate: "user_message_chunk",
|
|
174
|
+
content: textChunk(text)
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/** A tool call entering the in-progress state (emitted on an `action` step). */
|
|
178
|
+
function toolCallStart(toolCallId, step) {
|
|
179
|
+
const toolName = step.metadata?.toolName ?? "tool";
|
|
180
|
+
return {
|
|
181
|
+
sessionUpdate: "tool_call",
|
|
182
|
+
toolCallId,
|
|
183
|
+
title: toolCallTitle(toolName, step.metadata?.toolInput),
|
|
184
|
+
kind: toolKind(toolName),
|
|
185
|
+
status: "in_progress",
|
|
186
|
+
rawInput: step.metadata?.toolInput ?? void 0
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/** A tool call reaching a terminal state (emitted on an `observation` step). */
|
|
190
|
+
function toolCallCompleted(toolCallId, content) {
|
|
191
|
+
return {
|
|
192
|
+
sessionUpdate: "tool_call_update",
|
|
193
|
+
toolCallId,
|
|
194
|
+
status: "completed",
|
|
195
|
+
content: content ? [{
|
|
196
|
+
type: "content",
|
|
197
|
+
content: textChunk(content)
|
|
198
|
+
}] : void 0
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/** Notify the client of the current mode after a session/set_mode. */
|
|
202
|
+
function currentModeUpdate(currentModeId) {
|
|
203
|
+
return {
|
|
204
|
+
sessionUpdate: "current_mode_update",
|
|
205
|
+
currentModeId
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
//#endregion
|
|
209
|
+
//#region src/acp/cwd.ts
|
|
210
|
+
/**
|
|
211
|
+
* Working-directory validation for ACP sessions.
|
|
212
|
+
*
|
|
213
|
+
* The client supplies a `cwd` on session/new and session/load. Because the CLI
|
|
214
|
+
* file tools resolve paths against `process.cwd()`, that directory becomes the
|
|
215
|
+
* file-tool root for the session. We validate it up front and fail loud with a
|
|
216
|
+
* protocol error rather than letting a bad path surface as opaque tool errors
|
|
217
|
+
* later.
|
|
218
|
+
*/
|
|
219
|
+
/**
|
|
220
|
+
* Validate a client-supplied session cwd and return its canonical (symlink-
|
|
221
|
+
* resolved) absolute path. Throws an ACP invalid-params error if the path is
|
|
222
|
+
* not absolute, does not exist, or is not a directory.
|
|
223
|
+
*/
|
|
224
|
+
function assertConfinedCwd(cwd) {
|
|
225
|
+
if (typeof cwd !== "string" || cwd.length === 0 || !isAbsolute(cwd)) throw RequestError.invalidParams(void 0, `Session cwd must be an absolute path, got: ${JSON.stringify(cwd)}`);
|
|
226
|
+
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) throw RequestError.invalidParams(void 0, `Session cwd is not an existing directory: ${cwd}`);
|
|
227
|
+
return realpathSync(cwd);
|
|
228
|
+
}
|
|
229
|
+
//#endregion
|
|
230
|
+
//#region src/acp/AcpServer.ts
|
|
231
|
+
/**
|
|
232
|
+
* ACP agent-side server. Bridges the Agent Client Protocol to the same B4M
|
|
233
|
+
* ReAct agent core the interactive TUI and headless modes drive, so an
|
|
234
|
+
* ACP-capable editor (Zed, etc.) can host a thread while we keep auth, credits,
|
|
235
|
+
* and model routing server-side.
|
|
236
|
+
*
|
|
237
|
+
* Design notes:
|
|
238
|
+
* - The heavy agent stack (LLM transport, tools, MCP, orchestrator) is built
|
|
239
|
+
* ONCE, lazily, on the first session. Individual ACP sessions are lightweight
|
|
240
|
+
* conversation contexts (history + cwd + mode) that reuse the shared agent.
|
|
241
|
+
* - Because sessions share one agent instance and one process working
|
|
242
|
+
* directory, prompt turns are serialized through a single mutex. This is
|
|
243
|
+
* stricter than the spec's per-session requirement and guarantees histories
|
|
244
|
+
* can never interleave.
|
|
245
|
+
* - Permission requests bridge to session/request_permission and FAIL CLOSED:
|
|
246
|
+
* a client timeout, disconnect, or cancel all resolve to a denial.
|
|
247
|
+
*/
|
|
248
|
+
/** Fail-closed deadline for a client permission decision. */
|
|
249
|
+
const PERMISSION_TIMEOUT_MS = 3e5;
|
|
250
|
+
const silentLogger = {
|
|
251
|
+
log: () => {},
|
|
252
|
+
info: () => {},
|
|
253
|
+
warn: () => {},
|
|
254
|
+
error: () => {},
|
|
255
|
+
debug: () => {}
|
|
256
|
+
};
|
|
257
|
+
var AcpServer = class {
|
|
258
|
+
/**
|
|
259
|
+
* @param connectionSignal aborts when the ACP connection closes; used to fail
|
|
260
|
+
* permission prompts closed.
|
|
261
|
+
* @param version CLI version reported to the client as `agentInfo.version`.
|
|
262
|
+
*/
|
|
263
|
+
constructor(connectionSignal, version) {
|
|
264
|
+
this.connectionSignal = connectionSignal;
|
|
265
|
+
this.version = version;
|
|
266
|
+
this.configStore = new ConfigStore();
|
|
267
|
+
this.sessionStore = new SessionStore();
|
|
268
|
+
this.customCommandStore = new CustomCommandStore();
|
|
269
|
+
this.sessions = /* @__PURE__ */ new Map();
|
|
270
|
+
this.turnMutex = new Mutex();
|
|
271
|
+
this.stackPromise = null;
|
|
272
|
+
this.stack = null;
|
|
273
|
+
this.activeTurn = null;
|
|
274
|
+
this.promptFn = async (toolName, args, preview) => {
|
|
275
|
+
const turn = this.activeTurn;
|
|
276
|
+
if (!turn) return { action: "deny" };
|
|
277
|
+
const toolCall = {
|
|
278
|
+
toolCallId: `perm-${randomUUID()}`,
|
|
279
|
+
title: toolCallTitle(toolName, args),
|
|
280
|
+
kind: toolKind(toolName),
|
|
281
|
+
status: "pending",
|
|
282
|
+
rawInput: args,
|
|
283
|
+
content: preview ? [{
|
|
284
|
+
type: "content",
|
|
285
|
+
content: {
|
|
286
|
+
type: "text",
|
|
287
|
+
text: [...preview].slice(0, 4e3).join("")
|
|
288
|
+
}
|
|
289
|
+
}] : void 0
|
|
290
|
+
};
|
|
291
|
+
const decisionController = new AbortController();
|
|
292
|
+
const timer = setTimeout(() => decisionController.abort(), PERMISSION_TIMEOUT_MS);
|
|
293
|
+
const onClose = () => decisionController.abort();
|
|
294
|
+
turn.signal.addEventListener("abort", onClose);
|
|
295
|
+
this.connectionSignal.addEventListener("abort", onClose);
|
|
296
|
+
try {
|
|
297
|
+
return { action: await Promise.race([turn.client.request(methods.client.session.requestPermission, {
|
|
298
|
+
sessionId: turn.sessionId,
|
|
299
|
+
toolCall,
|
|
300
|
+
options: buildPermissionOptions()
|
|
301
|
+
}, { cancellationSignal: decisionController.signal }).then((res) => permissionResponseFromOutcome(res?.outcome)).catch(() => "deny"), new Promise((resolve) => {
|
|
302
|
+
decisionController.signal.addEventListener("abort", () => resolve("deny"), { once: true });
|
|
303
|
+
})]) };
|
|
304
|
+
} finally {
|
|
305
|
+
clearTimeout(timer);
|
|
306
|
+
turn.signal.removeEventListener("abort", onClose);
|
|
307
|
+
this.connectionSignal.removeEventListener("abort", onClose);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
this.userQuestionFn = (_payload) => {
|
|
311
|
+
return Promise.resolve({ answers: [] });
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
initialize(_params) {
|
|
315
|
+
return {
|
|
316
|
+
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
317
|
+
agentInfo: {
|
|
318
|
+
name: AGENT_INFO.name,
|
|
319
|
+
title: AGENT_INFO.title,
|
|
320
|
+
version: this.version
|
|
321
|
+
},
|
|
322
|
+
agentCapabilities: {
|
|
323
|
+
loadSession: true,
|
|
324
|
+
promptCapabilities: {
|
|
325
|
+
image: false,
|
|
326
|
+
embeddedContext: true
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
async newSession(params) {
|
|
332
|
+
const cwd = assertConfinedCwd(params.cwd);
|
|
333
|
+
const stack = await this.ensureStack();
|
|
334
|
+
const id = randomUUID();
|
|
335
|
+
const persisted = {
|
|
336
|
+
id,
|
|
337
|
+
name: `ACP ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
338
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
339
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
340
|
+
model: stack.modelId,
|
|
341
|
+
messages: [],
|
|
342
|
+
metadata: {
|
|
343
|
+
totalTokens: 0,
|
|
344
|
+
totalCost: 0,
|
|
345
|
+
toolCallCount: 0
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
this.sessions.set(id, {
|
|
349
|
+
id,
|
|
350
|
+
cwd,
|
|
351
|
+
mode: acpModeToInteraction("ask") ?? "normal",
|
|
352
|
+
history: [],
|
|
353
|
+
abortController: null,
|
|
354
|
+
persisted
|
|
355
|
+
});
|
|
356
|
+
return {
|
|
357
|
+
sessionId: id,
|
|
358
|
+
modes: buildSessionModeState("ask")
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
async loadSession(params, ctx) {
|
|
362
|
+
const cwd = assertConfinedCwd(params.cwd);
|
|
363
|
+
await this.ensureStack();
|
|
364
|
+
const persisted = await this.sessionStore.load(params.sessionId);
|
|
365
|
+
if (!persisted) throw RequestError.resourceNotFound(params.sessionId);
|
|
366
|
+
const history = persisted.messages.filter((m) => m.role === "user" || m.role === "assistant").map((m) => ({
|
|
367
|
+
role: m.role,
|
|
368
|
+
content: m.content
|
|
369
|
+
}));
|
|
370
|
+
this.sessions.set(params.sessionId, {
|
|
371
|
+
id: params.sessionId,
|
|
372
|
+
cwd,
|
|
373
|
+
mode: acpModeToInteraction("ask") ?? "normal",
|
|
374
|
+
history,
|
|
375
|
+
abortController: null,
|
|
376
|
+
persisted
|
|
377
|
+
});
|
|
378
|
+
for (const message of history) {
|
|
379
|
+
const update = message.role === "user" ? userMessageChunk(message.content) : agentMessageChunk(message.content);
|
|
380
|
+
await ctx.notify(methods.client.session.update, {
|
|
381
|
+
sessionId: params.sessionId,
|
|
382
|
+
update
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
return { modes: buildSessionModeState("ask") };
|
|
386
|
+
}
|
|
387
|
+
setSessionMode(params, ctx) {
|
|
388
|
+
const session = this.requireSession(params.sessionId);
|
|
389
|
+
const mode = acpModeToInteraction(params.modeId);
|
|
390
|
+
if (!mode) throw RequestError.invalidParams(void 0, `Unsupported session mode: ${params.modeId}`);
|
|
391
|
+
session.mode = mode;
|
|
392
|
+
ctx.notify(methods.client.session.update, {
|
|
393
|
+
sessionId: params.sessionId,
|
|
394
|
+
update: currentModeUpdate(params.modeId)
|
|
395
|
+
});
|
|
396
|
+
return {};
|
|
397
|
+
}
|
|
398
|
+
/** session/cancel is a notification: abort the running turn for this session. */
|
|
399
|
+
cancel(params) {
|
|
400
|
+
this.sessions.get(params.sessionId)?.abortController?.abort();
|
|
401
|
+
}
|
|
402
|
+
async prompt(params, ctx, requestSignal) {
|
|
403
|
+
const session = this.requireSession(params.sessionId);
|
|
404
|
+
const userText = contentBlocksToText(params.prompt);
|
|
405
|
+
if (!userText.trim()) return { stopReason: "end_turn" };
|
|
406
|
+
const stack = await this.ensureStack();
|
|
407
|
+
return this.turnMutex.runExclusive(async () => {
|
|
408
|
+
const abortController = new AbortController();
|
|
409
|
+
session.abortController = abortController;
|
|
410
|
+
const onExternalAbort = () => abortController.abort();
|
|
411
|
+
requestSignal.addEventListener("abort", onExternalAbort);
|
|
412
|
+
this.connectionSignal.addEventListener("abort", onExternalAbort);
|
|
413
|
+
this.activeTurn = {
|
|
414
|
+
client: ctx,
|
|
415
|
+
sessionId: params.sessionId,
|
|
416
|
+
signal: abortController.signal
|
|
417
|
+
};
|
|
418
|
+
let detachEvents = null;
|
|
419
|
+
let result = null;
|
|
420
|
+
try {
|
|
421
|
+
process.chdir(session.cwd);
|
|
422
|
+
stack.agent.setSystemPrompt(stack.buildPromptForMode(session.mode));
|
|
423
|
+
detachEvents = this.wireTurnEvents(stack.agent, ctx, params.sessionId, abortController.signal);
|
|
424
|
+
result = await stack.agent.run(userText, {
|
|
425
|
+
signal: abortController.signal,
|
|
426
|
+
previousMessages: session.history,
|
|
427
|
+
isReadOnlyTool,
|
|
428
|
+
maxHistoryIterations: 4
|
|
429
|
+
});
|
|
430
|
+
} catch (error) {
|
|
431
|
+
if (abortController.signal.aborted) return { stopReason: "cancelled" };
|
|
432
|
+
logger.error(`[acp] Prompt turn failed: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
433
|
+
throw RequestError.internalError(void 0, error instanceof Error ? error.message : String(error));
|
|
434
|
+
} finally {
|
|
435
|
+
detachEvents?.();
|
|
436
|
+
requestSignal.removeEventListener("abort", onExternalAbort);
|
|
437
|
+
this.connectionSignal.removeEventListener("abort", onExternalAbort);
|
|
438
|
+
session.abortController = null;
|
|
439
|
+
this.activeTurn = null;
|
|
440
|
+
}
|
|
441
|
+
if (abortController.signal.aborted) return { stopReason: "cancelled" };
|
|
442
|
+
session.history.push({
|
|
443
|
+
role: "user",
|
|
444
|
+
content: userText
|
|
445
|
+
});
|
|
446
|
+
session.history.push({
|
|
447
|
+
role: "assistant",
|
|
448
|
+
content: result.finalAnswer
|
|
449
|
+
});
|
|
450
|
+
await this.persistTurn(session, userText, result).catch((err) => {
|
|
451
|
+
logger.debug(`[acp] Failed to persist session: ${err instanceof Error ? err.message : String(err)}`);
|
|
452
|
+
});
|
|
453
|
+
return {
|
|
454
|
+
stopReason: this.stopReasonFor(result),
|
|
455
|
+
usage: {
|
|
456
|
+
totalTokens: result.completionInfo.totalTokens,
|
|
457
|
+
inputTokens: result.completionInfo.totalInputTokens,
|
|
458
|
+
outputTokens: result.completionInfo.totalOutputTokens
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
/** Tear down shared resources when the connection closes. */
|
|
464
|
+
async close() {
|
|
465
|
+
this.activeTurn = null;
|
|
466
|
+
if (this.stack) {
|
|
467
|
+
await this.stack.cleanup().catch(() => {});
|
|
468
|
+
this.stack = null;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
requireSession(sessionId) {
|
|
472
|
+
const session = this.sessions.get(sessionId);
|
|
473
|
+
if (!session) throw RequestError.invalidParams(void 0, `Unknown session: ${sessionId}`);
|
|
474
|
+
return session;
|
|
475
|
+
}
|
|
476
|
+
stopReasonFor(result) {
|
|
477
|
+
if (result.completionInfo.reachedMaxTotalTokens) return "max_tokens";
|
|
478
|
+
if (result.completionInfo.reachedMaxIterations) return "max_turn_requests";
|
|
479
|
+
return "end_turn";
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Forward the main agent's ReAct events to the client as session/update
|
|
483
|
+
* notifications for the duration of one turn. Text is streamed via
|
|
484
|
+
* text_delta; the final_answer is emitted only if nothing streamed, to avoid
|
|
485
|
+
* duplicating the message. Returns a detach function.
|
|
486
|
+
*/
|
|
487
|
+
wireTurnEvents(agent, client, sessionId, signal) {
|
|
488
|
+
let streamedAnyText = false;
|
|
489
|
+
const pendingToolCalls = /* @__PURE__ */ new Map();
|
|
490
|
+
const notify = (update) => {
|
|
491
|
+
if (signal.aborted) return;
|
|
492
|
+
client.notify(methods.client.session.update, {
|
|
493
|
+
sessionId,
|
|
494
|
+
update
|
|
495
|
+
}).catch(() => {});
|
|
496
|
+
};
|
|
497
|
+
const onTextDelta = (info) => {
|
|
498
|
+
if (!info.delta) return;
|
|
499
|
+
streamedAnyText = true;
|
|
500
|
+
notify(agentMessageChunk(info.delta));
|
|
501
|
+
};
|
|
502
|
+
const onThought = (step) => {
|
|
503
|
+
if (step.content) notify(agentThoughtChunk(step.content));
|
|
504
|
+
};
|
|
505
|
+
const onAction = (step) => {
|
|
506
|
+
const toolName = step.metadata?.toolName ?? "tool";
|
|
507
|
+
const toolCallId = `tool-${randomUUID()}`;
|
|
508
|
+
const queue = pendingToolCalls.get(toolName) ?? [];
|
|
509
|
+
queue.push(toolCallId);
|
|
510
|
+
pendingToolCalls.set(toolName, queue);
|
|
511
|
+
notify(toolCallStart(toolCallId, step));
|
|
512
|
+
};
|
|
513
|
+
const onObservation = (step) => {
|
|
514
|
+
const toolName = step.metadata?.toolName;
|
|
515
|
+
const toolCallId = this.dequeueToolCall(pendingToolCalls, toolName);
|
|
516
|
+
if (!toolCallId) return;
|
|
517
|
+
notify(toolCallCompleted(toolCallId, typeof step.content === "string" ? step.content : ""));
|
|
518
|
+
};
|
|
519
|
+
const onFinalAnswer = (step) => {
|
|
520
|
+
if (!streamedAnyText && step.content) notify(agentMessageChunk(step.content));
|
|
521
|
+
};
|
|
522
|
+
agent.on("text_delta", onTextDelta);
|
|
523
|
+
agent.on("thought", onThought);
|
|
524
|
+
agent.on("action", onAction);
|
|
525
|
+
agent.on("observation", onObservation);
|
|
526
|
+
agent.on("final_answer", onFinalAnswer);
|
|
527
|
+
return () => {
|
|
528
|
+
agent.off("text_delta", onTextDelta);
|
|
529
|
+
agent.off("thought", onThought);
|
|
530
|
+
agent.off("action", onAction);
|
|
531
|
+
agent.off("observation", onObservation);
|
|
532
|
+
agent.off("final_answer", onFinalAnswer);
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
dequeueToolCall(pending, toolName) {
|
|
536
|
+
if (toolName) {
|
|
537
|
+
const queue = pending.get(toolName);
|
|
538
|
+
if (queue && queue.length > 0) {
|
|
539
|
+
const id = queue.shift();
|
|
540
|
+
if (queue.length === 0) pending.delete(toolName);
|
|
541
|
+
return id;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
for (const [name, queue] of pending.entries()) if (queue.length > 0) {
|
|
545
|
+
const id = queue.shift();
|
|
546
|
+
if (queue.length === 0) pending.delete(name);
|
|
547
|
+
return id;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
async persistTurn(session, userText, result) {
|
|
551
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
552
|
+
session.persisted.messages.push({
|
|
553
|
+
id: randomUUID(),
|
|
554
|
+
role: "user",
|
|
555
|
+
content: userText,
|
|
556
|
+
timestamp: now
|
|
557
|
+
}, {
|
|
558
|
+
id: randomUUID(),
|
|
559
|
+
role: "assistant",
|
|
560
|
+
content: result.finalAnswer,
|
|
561
|
+
timestamp: now
|
|
562
|
+
});
|
|
563
|
+
session.persisted.updatedAt = now;
|
|
564
|
+
session.persisted.metadata.totalTokens += result.completionInfo.totalTokens;
|
|
565
|
+
session.persisted.metadata.toolCallCount += result.completionInfo.toolCalls;
|
|
566
|
+
session.persisted.metadata.totalCredits = (session.persisted.metadata.totalCredits ?? 0) + (result.completionInfo.totalCredits ?? 0);
|
|
567
|
+
await this.sessionStore.save(session.persisted);
|
|
568
|
+
}
|
|
569
|
+
ensureStack() {
|
|
570
|
+
if (!this.stackPromise) this.stackPromise = this.buildStack().then((stack) => {
|
|
571
|
+
this.stack = stack;
|
|
572
|
+
return stack;
|
|
573
|
+
});
|
|
574
|
+
return this.stackPromise;
|
|
575
|
+
}
|
|
576
|
+
async buildStack() {
|
|
577
|
+
const config = await this.configStore.load();
|
|
578
|
+
await this.loadCustomCommands();
|
|
579
|
+
const authTokens = await this.configStore.getAuthTokens();
|
|
580
|
+
if (!authTokens) throw RequestError.authRequired(void 0, "Not authenticated. Run `b4m /login` first.");
|
|
581
|
+
if (new Date(authTokens.expiresAt) <= /* @__PURE__ */ new Date()) {
|
|
582
|
+
await this.configStore.clearAuthTokens();
|
|
583
|
+
throw RequestError.authRequired(void 0, "Authentication expired. Run `b4m /login` again.");
|
|
584
|
+
}
|
|
585
|
+
const apiBaseURL = requireApiUrl(config.apiConfig);
|
|
586
|
+
const apiClient = new ApiClient(apiBaseURL, this.configStore);
|
|
587
|
+
const tokenGetter = async () => (await this.configStore.getAuthTokens())?.accessToken ?? null;
|
|
588
|
+
await this.mergeRemoteSkills(config, apiClient);
|
|
589
|
+
const startupLog = [];
|
|
590
|
+
const { llm, wsManager, modelInfo } = await buildLlmBackend({
|
|
591
|
+
config,
|
|
592
|
+
apiClient,
|
|
593
|
+
tokenGetter,
|
|
594
|
+
startupLog
|
|
595
|
+
});
|
|
596
|
+
const permissionManager = new PermissionManager(config.trustedTools ?? [], void 0, config.tools.disabled ?? []);
|
|
597
|
+
const checkpointStore = new CheckpointStore(this.configStore.getProjectConfigDir() ?? process.cwd());
|
|
598
|
+
const stackSessionId = randomUUID();
|
|
599
|
+
const { sandboxOrchestrator } = await buildSandbox({
|
|
600
|
+
config,
|
|
601
|
+
sessionId: stackSessionId,
|
|
602
|
+
permissionManager,
|
|
603
|
+
checkpointStore
|
|
604
|
+
});
|
|
605
|
+
const additionalDirectories = await this.resolveAdditionalDirectories();
|
|
606
|
+
const agentContext = {
|
|
607
|
+
currentAgent: null,
|
|
608
|
+
observationQueue: []
|
|
609
|
+
};
|
|
610
|
+
const { agentStore, contextResult, loadedB4mTools, orchestrator, backgroundManager, historyStore, mcpManager } = await buildSupportingStores({
|
|
611
|
+
config,
|
|
612
|
+
llm,
|
|
613
|
+
modelId: modelInfo.id,
|
|
614
|
+
permissionManager,
|
|
615
|
+
apiClient,
|
|
616
|
+
configStore: this.configStore,
|
|
617
|
+
customCommandStore: this.customCommandStore,
|
|
618
|
+
checkpointStore,
|
|
619
|
+
sandboxOrchestrator,
|
|
620
|
+
additionalDirectories,
|
|
621
|
+
agentContext,
|
|
622
|
+
promptFn: this.promptFn,
|
|
623
|
+
userQuestionFn: this.userQuestionFn,
|
|
624
|
+
startupLog,
|
|
625
|
+
silentLogger,
|
|
626
|
+
onBackgroundStatusChange: () => {},
|
|
627
|
+
onGroupCompletion: () => {}
|
|
628
|
+
});
|
|
629
|
+
const llmWithFallback = config.fallbackModels && config.fallbackModels.length > 0 ? new FallbackLlmBackend(llm, config.fallbackModels, (from, to, error) => logger.debug(`[acp] Model "${from}" failed (${error.message}); falling back to "${to}"`)) : llm;
|
|
630
|
+
const notifyingLlm = new NotifyingLlmBackend(llmWithFallback, backgroundManager);
|
|
631
|
+
const cliTools = this.buildCliTools({
|
|
632
|
+
config,
|
|
633
|
+
orchestrator,
|
|
634
|
+
agentStore,
|
|
635
|
+
backgroundManager,
|
|
636
|
+
historyStore,
|
|
637
|
+
sessionId: stackSessionId
|
|
638
|
+
});
|
|
639
|
+
const agentToolsRef = { current: null };
|
|
640
|
+
const toolSearchTool = deferredToolRegistry.size() > 0 ? createToolSearchTool(() => {
|
|
641
|
+
if (!agentToolsRef.current) throw new Error("tool_search invoked before agent context was wired");
|
|
642
|
+
return agentToolsRef.current;
|
|
643
|
+
}) : null;
|
|
644
|
+
const allTools = [
|
|
645
|
+
...loadedB4mTools,
|
|
646
|
+
...toolSearchTool ? [toolSearchTool] : [],
|
|
647
|
+
...cliTools
|
|
648
|
+
];
|
|
649
|
+
const { agent, buildPromptForMode } = buildAgent({
|
|
650
|
+
config,
|
|
651
|
+
modelId: modelInfo.id,
|
|
652
|
+
notifyingLlm,
|
|
653
|
+
allTools,
|
|
654
|
+
agentContext,
|
|
655
|
+
agentToolsRef,
|
|
656
|
+
silentLogger,
|
|
657
|
+
sessionId: stackSessionId,
|
|
658
|
+
initialInteractionMode: "normal",
|
|
659
|
+
contextContent: contextResult.mergedContent,
|
|
660
|
+
agentStore,
|
|
661
|
+
customCommandStore: this.customCommandStore,
|
|
662
|
+
enableSkillTool: config.preferences.enableSkillTool !== false,
|
|
663
|
+
additionalDirectories,
|
|
664
|
+
featureModulePrompts: ""
|
|
665
|
+
});
|
|
666
|
+
const cleanup = async () => {
|
|
667
|
+
await mcpManager.disconnect().catch(() => {});
|
|
668
|
+
wsManager?.disconnect();
|
|
669
|
+
setWebSocketToolExecutor(null);
|
|
670
|
+
agent.removeAllListeners();
|
|
671
|
+
};
|
|
672
|
+
return {
|
|
673
|
+
agent,
|
|
674
|
+
buildPromptForMode,
|
|
675
|
+
permissionManager,
|
|
676
|
+
modelId: modelInfo.id,
|
|
677
|
+
cleanup
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
buildCliTools(input) {
|
|
681
|
+
const { config, orchestrator, agentStore, backgroundManager, historyStore, sessionId } = input;
|
|
682
|
+
const tools = [
|
|
683
|
+
createAgentDelegateTool(orchestrator, agentStore, sessionId, backgroundManager),
|
|
684
|
+
...createBackgroundAgentTools(backgroundManager),
|
|
685
|
+
createResumeAgentTool(orchestrator, historyStore, backgroundManager),
|
|
686
|
+
createWriteTodosTool(createTodoStore()),
|
|
687
|
+
createFindDefinitionTool(),
|
|
688
|
+
createGetFileStructureTool()
|
|
689
|
+
];
|
|
690
|
+
if (config.preferences.enableSkillTool !== false) tools.push(createSkillTool({
|
|
691
|
+
customCommandStore: this.customCommandStore,
|
|
692
|
+
subagentOrchestrator: orchestrator,
|
|
693
|
+
sessionId
|
|
694
|
+
}));
|
|
695
|
+
if (config.preferences.enableCoordinatorMode === true) tools.push(createCoordinateTaskTool(orchestrator, agentStore, sessionId));
|
|
696
|
+
return tools;
|
|
697
|
+
}
|
|
698
|
+
async loadCustomCommands() {
|
|
699
|
+
try {
|
|
700
|
+
await this.customCommandStore.loadCommands();
|
|
701
|
+
} catch {}
|
|
702
|
+
}
|
|
703
|
+
async mergeRemoteSkills(config, apiClient) {
|
|
704
|
+
if (!(process.env.B4M_NO_REMOTE_SKILLS !== "1" && config.preferences.enableRemoteSkills !== false)) return;
|
|
705
|
+
try {
|
|
706
|
+
this.customCommandStore.setRemoteSource(new RemoteSkillSource(apiClient));
|
|
707
|
+
await this.customCommandStore.mergeRemoteCommands();
|
|
708
|
+
} catch {}
|
|
709
|
+
}
|
|
710
|
+
async resolveAdditionalDirectories() {
|
|
711
|
+
const configDirs = await this.configStore.getAdditionalDirectories();
|
|
712
|
+
return [.../* @__PURE__ */ new Set([...configDirs, ...this.parseEnvDirs()])];
|
|
713
|
+
}
|
|
714
|
+
/** Parse B4M_ADDITIONAL_DIRS defensively - a malformed value must not brick bootstrap. */
|
|
715
|
+
parseEnvDirs() {
|
|
716
|
+
const raw = process.env.B4M_ADDITIONAL_DIRS;
|
|
717
|
+
if (!raw) return [];
|
|
718
|
+
try {
|
|
719
|
+
const parsed = JSON.parse(raw);
|
|
720
|
+
return Array.isArray(parsed) ? parsed.filter((d) => typeof d === "string") : [];
|
|
721
|
+
} catch {
|
|
722
|
+
logger.debug(`[acp] Ignoring malformed B4M_ADDITIONAL_DIRS: ${raw}`);
|
|
723
|
+
return [];
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
//#endregion
|
|
728
|
+
//#region src/acp/app.ts
|
|
729
|
+
/**
|
|
730
|
+
* Wires the ACP agent-side request/notification handlers to an AcpServer.
|
|
731
|
+
*
|
|
732
|
+
* Extracted from the command entry point so the same handler graph can be
|
|
733
|
+
* driven in-process by tests (via the SDK's client/agent direct connect) as
|
|
734
|
+
* well as over stdio in production.
|
|
735
|
+
*/
|
|
736
|
+
/**
|
|
737
|
+
* Build the ACP AgentApp. `getServer` is a late-bound accessor because the
|
|
738
|
+
* server needs the connection's abort signal, which only exists after
|
|
739
|
+
* `connect()` - the accessor lets handlers resolve the server on first use.
|
|
740
|
+
*/
|
|
741
|
+
function buildAcpApp(getServer) {
|
|
742
|
+
return agent({ name: AGENT_INFO.name }).onRequest(methods.agent.initialize, ({ params }) => getServer().initialize(params)).onRequest(methods.agent.session.new, ({ params }) => getServer().newSession(params)).onRequest(methods.agent.session.load, ({ params, client }) => getServer().loadSession(params, client)).onRequest(methods.agent.session.prompt, ({ params, client, signal }) => getServer().prompt(params, client, signal)).onRequest(methods.agent.session.setMode, ({ params, client }) => getServer().setSessionMode(params, client)).onNotification(methods.agent.session.cancel, ({ params }) => getServer().cancel(params));
|
|
743
|
+
}
|
|
744
|
+
//#endregion
|
|
745
|
+
//#region src/commands/acpCommand.ts
|
|
746
|
+
/**
|
|
747
|
+
* `b4m acp` - run the agent as an Agent Client Protocol (ACP) server.
|
|
748
|
+
*
|
|
749
|
+
* ACP is "LSP for coding agents": JSON-RPC 2.0 over stdio. This turns the CLI
|
|
750
|
+
* into a first-class agent backend for any ACP-capable editor (Zed today) with
|
|
751
|
+
* no per-editor extension work. It sits parallel to the interactive TUI and the
|
|
752
|
+
* headless stream-json mode, reusing the same agent core, permission model, and
|
|
753
|
+
* session store.
|
|
754
|
+
*
|
|
755
|
+
* Transport contract: stdout carries the JSON-RPC stream and NOTHING else, so
|
|
756
|
+
* all diagnostics are forced to stderr before the agent stack (which logs
|
|
757
|
+
* freely) boots.
|
|
758
|
+
*/
|
|
759
|
+
/**
|
|
760
|
+
* Isolate stdout for the JSON-RPC frame stream. Returns a writer bound to the
|
|
761
|
+
* REAL stdout for the protocol to use, then redirects everything else -
|
|
762
|
+
* `console.*` AND any stray `process.stdout.write` deep in the agent stack - to
|
|
763
|
+
* stderr. stdout purity is the one invariant this transport must hold: a single
|
|
764
|
+
* unrelated byte on stdout corrupts a frame, so we close the whole channel
|
|
765
|
+
* rather than trusting no dependency ever prints.
|
|
766
|
+
*/
|
|
767
|
+
function captureStdout() {
|
|
768
|
+
const writeToRealStdout = process.stdout.write.bind(process.stdout);
|
|
769
|
+
const toStderr = (...args) => {
|
|
770
|
+
process.stderr.write(args.map(String).join(" ") + "\n");
|
|
771
|
+
};
|
|
772
|
+
console.log = toStderr;
|
|
773
|
+
console.info = toStderr;
|
|
774
|
+
console.debug = toStderr;
|
|
775
|
+
process.stdout.write = process.stderr.write.bind(process.stderr);
|
|
776
|
+
return writeToRealStdout;
|
|
777
|
+
}
|
|
778
|
+
async function handleAcpCommand(options) {
|
|
779
|
+
const writeFrame = captureStdout();
|
|
780
|
+
logger.setVerbose(options.verbose);
|
|
781
|
+
const input = Readable.toWeb(process.stdin);
|
|
782
|
+
const output = new WritableStream({ write(chunk) {
|
|
783
|
+
if (writeFrame(chunk)) return;
|
|
784
|
+
return new Promise((resolve) => process.stdout.once("drain", resolve));
|
|
785
|
+
} });
|
|
786
|
+
const stream = ndJsonStream(output, input);
|
|
787
|
+
let server = null;
|
|
788
|
+
const guardServer = () => {
|
|
789
|
+
if (!server) throw new Error("ACP server accessed before connection established");
|
|
790
|
+
return server;
|
|
791
|
+
};
|
|
792
|
+
const connection = buildAcpApp(guardServer).connect(stream);
|
|
793
|
+
server = new AcpServer(connection.signal, options.version);
|
|
794
|
+
await connection.closed;
|
|
795
|
+
await server.close();
|
|
796
|
+
}
|
|
797
|
+
//#endregion
|
|
798
|
+
export { handleAcpCommand };
|