@bike4mind/cli 0.18.4 → 0.20.0

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.
Files changed (41) hide show
  1. package/LICENSE +17 -3
  2. package/README.md +204 -35
  3. package/bin/bike4mind-cli.mjs +137 -24
  4. package/bin/hearth-hook.mjs +292 -0
  5. package/dist/AgentHistoryStore-C8uUKjjC.mjs +35512 -0
  6. package/dist/ApiClient-B_CQrUiF.mjs +277 -0
  7. package/dist/{ConfigStore-Cq20962p.mjs → ConfigStore-DD3DcC3-.mjs} +6256 -3911
  8. package/dist/{ImageStore-BVmEG1xc.mjs → ImageStore-kVo-oHoS.mjs} +2 -2
  9. package/dist/PluginStore-DwvOJ-G3.mjs +206 -0
  10. package/dist/ProxyManager-Bqr7Lmsd.mjs +3 -0
  11. package/dist/{ProxyManager-CV94yZUW.mjs → ProxyManager-C5H0pUyK.mjs} +2 -2
  12. package/dist/{SandboxOrchestrator-BS6gALNq.mjs → SandboxOrchestrator-BFPVpmB5.mjs} +1 -1
  13. package/dist/{SandboxOrchestrator-BoINxbX4.mjs → SandboxOrchestrator-C8uleDn2.mjs} +7 -7
  14. package/dist/ShellSessionManager-6o8KZzl1-vrbPAUTq.mjs +252 -0
  15. package/dist/{ViolationLogStore-B-plqJfn.mjs → ViolationLogStore-byEhxa2A.mjs} +1 -1
  16. package/dist/WorkItemsClient-Cow6nXx7.mjs +382 -0
  17. package/dist/{bashExecute-B1N1lMOS-TZVDbcQ4.mjs → bashExecute-CrdPpBqk-DCATrE-D.mjs} +116 -16
  18. package/dist/buildAgent-mVuXU_H4.mjs +824 -0
  19. package/dist/commands/acpCommand.mjs +798 -0
  20. package/dist/commands/apiCommand.mjs +14 -16
  21. package/dist/commands/doctorCommand.mjs +5 -5
  22. package/dist/commands/envCommand.mjs +1 -1
  23. package/dist/commands/headlessCommand.mjs +272 -76
  24. package/dist/commands/mcpCommand.mjs +14 -1
  25. package/dist/commands/pluginCommand.mjs +232 -0
  26. package/dist/commands/updateCommand.mjs +10 -9
  27. package/dist/{grepSearch-DJs-cubo-Bm0Y8oS3.mjs → grepSearch-BaYUfIYs-C-fxWc9G.mjs} +3 -3
  28. package/dist/index.mjs +3281 -2322
  29. package/dist/{package-CBaK53NX.mjs → package-BqKSCbso.mjs} +1 -1
  30. package/dist/serve-CuF0I5en.mjs +772 -0
  31. package/dist/store-BG3e54c8.mjs +3 -0
  32. package/dist/{store-DV5s-qni.mjs → store-CvjTpQPs.mjs} +70 -3
  33. package/dist/{terminalSetup-BbJt04ZG.mjs → terminalSetup-DjXAwpDy.mjs} +2 -3
  34. package/dist/{treeSitterEngine-BRbQ9b7I.mjs → treeSitterEngine-QBE3YkmG.mjs} +51 -1
  35. package/dist/{updateChecker-C8xsNY2L.mjs → updateChecker-CQW8bxo6.mjs} +10 -10
  36. package/package.json +48 -43
  37. package/dist/BackgroundAgentManager-DOesheMD.mjs +0 -27171
  38. package/dist/ProxyManager-ByuAHFMq.mjs +0 -3
  39. package/dist/store-DgzCTRkN.mjs +0 -3
  40. package/dist/utils-Cdktpk_k.mjs +0 -158
  41. 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-C8uUKjjC.mjs";
3
+ import { c as requireApiUrl, n as logger, t as ConfigStore } from "../ConfigStore-DD3DcC3-.mjs";
4
+ import { t as ApiClient } from "../ApiClient-B_CQrUiF.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-mVuXU_H4.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 };