@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.
Files changed (41) hide show
  1. package/LICENSE +1 -1
  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-BQiATPsQ.mjs +35755 -0
  6. package/dist/ApiClient-BPmlalut.mjs +277 -0
  7. package/dist/{ConfigStore-D39UqFnY.mjs → ConfigStore-CNfbeaJf.mjs} +6702 -4122
  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-DwPvcTpz.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 +3284 -2307
  29. package/dist/{package-I_v_WFUn.mjs → package-CxHSRXdp.mjs} +1 -1
  30. package/dist/serve-Du3HiqAH.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-D-xsWd3C.mjs +0 -27303
  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,382 @@
1
+ #!/usr/bin/env node
2
+ import { v4 } from "uuid";
3
+ import { get_encoding } from "tiktoken";
4
+ //#region src/utils/tokenCounter.ts
5
+ const DEFAULT_CONTEXT_WINDOW = 2e5;
6
+ const IMAGE_BLOCK_TOKEN_ESTIMATE = 1600;
7
+ /**
8
+ * Token counting utility for context window management.
9
+ * Uses tiktoken (cl100k_base encoding) which works for Claude and GPT-4.
10
+ */
11
+ var TokenCounter = class {
12
+ constructor() {
13
+ this.encoder = null;
14
+ }
15
+ getEncoder() {
16
+ if (!this.encoder) this.encoder = get_encoding("cl100k_base");
17
+ return this.encoder;
18
+ }
19
+ /**
20
+ * Count tokens in a text string
21
+ */
22
+ countTokens(text) {
23
+ return this.getEncoder().encode_ordinary(text).length;
24
+ }
25
+ /**
26
+ * Count tokens in a message's content, whether it is a plain string or an
27
+ * array of structured blocks (text / tool_use / tool_result / image). Text is
28
+ * tiktoken-counted; images are billed a flat estimate since their bytes are
29
+ * opaque to the tokenizer.
30
+ */
31
+ countMessageContent(content) {
32
+ if (typeof content === "string") return this.countTokens(content);
33
+ return content.reduce((sum, block) => {
34
+ switch (block.type) {
35
+ case "text": return sum + this.countTokens(block.text ?? "");
36
+ case "thinking": return sum + this.countTokens(block.thinking ?? "");
37
+ case "tool_use": return sum + this.countTokens(`${block.name ?? ""} ${JSON.stringify(block.input ?? {})}`);
38
+ case "tool_result": return sum + this.countTokens(block.content ?? "");
39
+ case "image":
40
+ case "image_url": return sum + IMAGE_BLOCK_TOKEN_ESTIMATE;
41
+ default: return sum;
42
+ }
43
+ }, 0);
44
+ }
45
+ /**
46
+ * Count tokens used in a session including system prompt
47
+ */
48
+ countSessionTokens(session, systemPrompt) {
49
+ const systemPromptTokens = this.countTokens(systemPrompt);
50
+ const messageTokens = session.messages.reduce((sum, msg) => sum + this.countTokens(msg.content), 0);
51
+ return {
52
+ systemPromptTokens,
53
+ messageTokens,
54
+ totalTokens: systemPromptTokens + messageTokens
55
+ };
56
+ }
57
+ /**
58
+ * Get context window size for a model
59
+ * Falls back to DEFAULT_CONTEXT_WINDOW if model info not available
60
+ */
61
+ getContextWindow(modelId, availableModels) {
62
+ return (availableModels?.find((m) => m.id === modelId))?.contextWindow || DEFAULT_CONTEXT_WINDOW;
63
+ }
64
+ /**
65
+ * Count tokens in tool schemas.
66
+ * Tool schemas are sent as part of the API call and consume context.
67
+ */
68
+ countToolSchemaTokens(tools) {
69
+ if (tools.length === 0) return 0;
70
+ const schemaText = tools.map(({ toolSchema }) => `Tool: ${toolSchema.name}\nDescription: ${toolSchema.description}\nParameters: ${JSON.stringify(toolSchema.parameters)}`).join("\n\n");
71
+ return this.countTokens(schemaText);
72
+ }
73
+ /**
74
+ * Free encoder resources when done
75
+ */
76
+ dispose() {
77
+ if (this.encoder) {
78
+ this.encoder.free();
79
+ this.encoder = null;
80
+ }
81
+ }
82
+ };
83
+ let tokenCounter = null;
84
+ /**
85
+ * Get the singleton TokenCounter instance
86
+ */
87
+ function getTokenCounter() {
88
+ if (!tokenCounter) tokenCounter = new TokenCounter();
89
+ return tokenCounter;
90
+ }
91
+ /** Max characters of a single tool input/result rendered into a replay line. */
92
+ const TOOL_TRACE_FIELD_CHARS = 400;
93
+ /**
94
+ * Owns the whole "session messages -> LLM messages" transformation behind a
95
+ * narrow interface. It is the single place that decides how much history a turn
96
+ * gets, maps the persisted model to the agent's IMessage type without losing the
97
+ * tool trace, bounds tool-trace replay, and serializes back for persistence.
98
+ *
99
+ * Persistence keeps the rich form (tool_use / tool_result blocks) losslessly on
100
+ * `Message.richContent`. Replay renders each past turn's tool trace into a
101
+ * bounded text appendix rather than emitting raw provider-native tool blocks,
102
+ * which would need exact assistant/user pairing and could re-bloat context.
103
+ */
104
+ var ConversationContext = class ConversationContext {
105
+ constructor(session, counter) {
106
+ this.base = session;
107
+ this.messages = [...session.messages];
108
+ this.counter = counter;
109
+ }
110
+ /**
111
+ * Back-compat read: loads a session as-is. Legacy string-only messages need no
112
+ * upgrade - they are read through `content` and only wrapped into blocks lazily
113
+ * when a turn actually carries a `richContent` trace.
114
+ */
115
+ static fromSession(session, counter = getTokenCounter()) {
116
+ return new ConversationContext(session, counter);
117
+ }
118
+ /**
119
+ * Append a completed turn. Persists the RICH form: the user message (text, or
120
+ * multimodal blocks preserved on `richContent`) and the assistant message with
121
+ * its tool_use / tool_result trace on `richContent`, plus the final answer as
122
+ * the display `content`.
123
+ */
124
+ recordTurn(turn) {
125
+ this.messages.push(this.buildUserMessage(turn.userInput));
126
+ this.messages.push(this.buildAssistantMessage(turn.result));
127
+ }
128
+ /**
129
+ * The one place that builds the IMessage[] for the next turn. Returns the
130
+ * windowed history followed by the current input as the final user message.
131
+ * Windowing is token-aware: it reserves room for the system prompt, the
132
+ * response, and the current input, then fills from the most recent turn
133
+ * backward, dropping the oldest turns first. The current input is never
134
+ * dropped (the protected suffix).
135
+ */
136
+ buildTurnMessages(newInput, opts) {
137
+ return [...this.windowedHistory(newInput, opts), this.inputToIMessage(newInput)];
138
+ }
139
+ /**
140
+ * The windowed history only, WITHOUT the current input appended. Convenience
141
+ * for hosts whose agent API takes the current input as a separate query and
142
+ * the history as `previousMessages` (e.g. the CLI's `agent.run`). Budgeting
143
+ * still reserves room for `newInput`, so the two together fit the window.
144
+ */
145
+ buildPreviousMessages(newInput, opts) {
146
+ return this.windowedHistory(newInput, opts);
147
+ }
148
+ /**
149
+ * Whether the session should be compacted before the next turn. Measures the
150
+ * FULL session (not the windowed subset) as it would actually be sent -
151
+ * rendering each turn's bounded tool trace - plus the system prompt, and
152
+ * compares against `thresholdRatio` of the context window. Rich-content aware:
153
+ * replaces the old string-only `content` sum so a session whose weight lives
154
+ * in tool traces still triggers compaction.
155
+ */
156
+ needsCompaction(systemPromptTokens, opts, thresholdRatio = .8) {
157
+ return this.estimateSessionTokens(systemPromptTokens, opts) >= opts.contextWindow * thresholdRatio;
158
+ }
159
+ /**
160
+ * Rich-aware token estimate for the whole session as it would be replayed:
161
+ * every user/assistant turn rendered (bounded tool traces included) plus the
162
+ * system prompt. Used by `needsCompaction`.
163
+ */
164
+ estimateSessionTokens(systemPromptTokens, opts) {
165
+ return systemPromptTokens + this.messages.filter((m) => m.role === "user" || m.role === "assistant").reduce((sum, m) => sum + this.estimateTokens(this.renderHistoryMessage(m, opts)), 0);
166
+ }
167
+ /** Serialize back to a Session for on-disk persistence (rich content preserved). */
168
+ toSession() {
169
+ return {
170
+ ...this.base,
171
+ messages: this.messages,
172
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
173
+ };
174
+ }
175
+ /**
176
+ * The most-recent contiguous window of history that fits the token budget
177
+ * after reserving room for the system prompt, the response, and the current
178
+ * input. Fills newest-first and stops at the first turn that no longer fits,
179
+ * so the oldest turns are dropped first and the current input is never
180
+ * crowded out.
181
+ */
182
+ windowedHistory(newInput, opts) {
183
+ const reserved = opts.reservedTokens ?? 8e3;
184
+ const currentTokens = this.estimateTokens(this.inputToIMessage(newInput));
185
+ const history = this.messages.filter((m) => m.role === "user" || m.role === "assistant").map((m) => this.renderHistoryMessage(m, opts));
186
+ let remaining = opts.contextWindow - reserved - currentTokens;
187
+ const kept = [];
188
+ for (let i = history.length - 1; i >= 0; i--) {
189
+ const cost = this.estimateTokens(history[i]);
190
+ if (cost > remaining) break;
191
+ remaining -= cost;
192
+ kept.unshift(history[i]);
193
+ }
194
+ return kept;
195
+ }
196
+ buildUserMessage(input) {
197
+ const base = {
198
+ id: v4(),
199
+ role: "user",
200
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
201
+ };
202
+ if (typeof input === "string") return {
203
+ ...base,
204
+ content: input
205
+ };
206
+ const text = input.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n");
207
+ return {
208
+ ...base,
209
+ content: text,
210
+ richContent: input
211
+ };
212
+ }
213
+ buildAssistantMessage(result) {
214
+ const richContent = reconstructTurnBlocks(result.steps, result.finalAnswer);
215
+ return {
216
+ id: v4(),
217
+ role: "assistant",
218
+ content: result.finalAnswer,
219
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
220
+ ...richContent ? { richContent } : {}
221
+ };
222
+ }
223
+ /** Map a persisted message to the IMessage the agent replays as history. */
224
+ renderHistoryMessage(message, opts) {
225
+ const role = message.role === "assistant" ? "assistant" : "user";
226
+ if (!message.richContent) return {
227
+ role,
228
+ content: message.content
229
+ };
230
+ if (role === "user") return {
231
+ role,
232
+ content: message.richContent
233
+ };
234
+ return {
235
+ role,
236
+ content: this.renderAssistantTrace(message, opts)
237
+ };
238
+ }
239
+ renderAssistantTrace(message, opts) {
240
+ const blocks = message.richContent ?? [];
241
+ const budget = opts.toolTraceReplayTokens ?? 1500;
242
+ const textBlocks = blocks.filter((b) => b.type === "text").map((b) => b.text ?? "");
243
+ const finalText = textBlocks.length > 0 ? textBlocks.join("\n") : message.content;
244
+ const results = /* @__PURE__ */ new Map();
245
+ for (const b of blocks) if (b.type === "tool_result" && b.tool_use_id) results.set(b.tool_use_id, b.content ?? "");
246
+ const lines = [];
247
+ let used = 0;
248
+ let omitted = 0;
249
+ for (const b of blocks) {
250
+ if (b.type !== "tool_use") continue;
251
+ const resultText = b.id && results.get(b.id) || "";
252
+ const line = `[tool ${b.name}] ${this.brief(JSON.stringify(b.input ?? {}))} -> ${this.brief(resultText)}`;
253
+ const cost = this.counter.countTokens(line);
254
+ if (used + cost > budget) {
255
+ omitted++;
256
+ continue;
257
+ }
258
+ used += cost;
259
+ lines.push(line);
260
+ }
261
+ if (omitted > 0) lines.push(`[... ${omitted} more tool call${omitted === 1 ? "" : "s"} omitted]`);
262
+ if (lines.length === 0) return finalText;
263
+ return `${finalText}\n\n<tool-trace>\n${lines.join("\n")}\n</tool-trace>`;
264
+ }
265
+ brief(text) {
266
+ if (text.length <= TOOL_TRACE_FIELD_CHARS) return text;
267
+ return `${text.slice(0, TOOL_TRACE_FIELD_CHARS)}... (+${text.length - TOOL_TRACE_FIELD_CHARS} chars)`;
268
+ }
269
+ inputToIMessage(input) {
270
+ return {
271
+ role: "user",
272
+ content: input
273
+ };
274
+ }
275
+ estimateTokens(message) {
276
+ return this.counter.countMessageContent(message.content);
277
+ }
278
+ };
279
+ /**
280
+ * Reconstruct tool_use / tool_result blocks from the agent's steps, pairing
281
+ * each observation to the earliest still-unmatched action (FIFO). This is exact
282
+ * for sequential tool use; with parallel execution the pairing is best-effort by
283
+ * order, which is fine for context replay. Returns undefined when the turn used
284
+ * no tools (so the message stays string-only and round-trips like a legacy one).
285
+ *
286
+ * Exported so hosts that manage their own message store (e.g. the CLI's live
287
+ * pending-message UI) can attach the rich trace to their assistant message
288
+ * without going through `recordTurn`.
289
+ */
290
+ function reconstructTurnBlocks(steps, finalAnswer) {
291
+ const blocks = [];
292
+ const pendingIds = [];
293
+ let toolCounter = 0;
294
+ for (const step of steps) if (step.type === "action") {
295
+ const id = `tu_${toolCounter++}`;
296
+ pendingIds.push(id);
297
+ const toolUse = {
298
+ type: "tool_use",
299
+ id,
300
+ name: step.metadata?.toolName ?? "unknown",
301
+ input: normalizeToolInput(step.metadata?.toolInput)
302
+ };
303
+ blocks.push(toolUse);
304
+ } else if (step.type === "observation") {
305
+ const toolResult = {
306
+ type: "tool_result",
307
+ tool_use_id: pendingIds.shift() ?? `tu_${toolCounter++}`,
308
+ content: typeof step.content === "string" ? step.content : String(step.content ?? "")
309
+ };
310
+ blocks.push(toolResult);
311
+ }
312
+ if (blocks.length === 0) return void 0;
313
+ blocks.push({
314
+ type: "text",
315
+ text: finalAnswer
316
+ });
317
+ return blocks;
318
+ }
319
+ function normalizeToolInput(input) {
320
+ if (input && typeof input === "object" && !Array.isArray(input)) return input;
321
+ if (typeof input === "string") {
322
+ try {
323
+ const parsed = JSON.parse(input);
324
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
325
+ } catch {}
326
+ return { value: input };
327
+ }
328
+ return {};
329
+ }
330
+ //#endregion
331
+ //#region src/api/WorkItemsClient.ts
332
+ var WorkItemsClient = class {
333
+ constructor(apiClient) {
334
+ this.apiClient = apiClient;
335
+ }
336
+ async list(params = {}) {
337
+ const search = new URLSearchParams();
338
+ if (params.status?.length) search.set("status", params.status.join(","));
339
+ if (params.organizationId) search.set("organizationId", params.organizationId);
340
+ if (params.query) search.set("query", params.query);
341
+ if (params.page !== void 0) search.set("page", String(params.page));
342
+ if (params.limit !== void 0) search.set("limit", String(params.limit));
343
+ if (params.orderBy) search.set("orderBy", params.orderBy);
344
+ if (params.orderDirection) search.set("orderDirection", params.orderDirection);
345
+ const suffix = search.size > 0 ? `?${search.toString()}` : "";
346
+ return this.apiClient.get(`/api/work-items${suffix}`);
347
+ }
348
+ async get(id) {
349
+ return this.apiClient.get(`/api/work-items/${encodeURIComponent(id)}`);
350
+ }
351
+ async create(input) {
352
+ return this.apiClient.post("/api/work-items", input);
353
+ }
354
+ async update(id, input) {
355
+ return (await this.apiClient.getAxiosInstance().patch(`/api/work-items/${encodeURIComponent(id)}`, input)).data;
356
+ }
357
+ /** Convenience for the common `status: 'closed'` transition. */
358
+ async close(id) {
359
+ return this.update(id, { status: "closed" });
360
+ }
361
+ /** Soft delete - the item stops appearing in listings but is not erased. */
362
+ async remove(id) {
363
+ await this.apiClient.delete(`/api/work-items/${encodeURIComponent(id)}`);
364
+ }
365
+ /**
366
+ * Open items whose dependencies are all closed. `truncated` means the backlog
367
+ * exceeded the server's whole-graph read window, so the answer is computed
368
+ * from a prefix and can be wrong (see IWorkItemReadyResult).
369
+ */
370
+ async ready() {
371
+ const response = await this.apiClient.get("/api/work-items/ready");
372
+ return {
373
+ data: response?.data ?? [],
374
+ truncated: response?.truncated ?? false
375
+ };
376
+ }
377
+ async graph() {
378
+ return this.apiClient.get("/api/work-items/graph");
379
+ }
380
+ };
381
+ //#endregion
382
+ export { getTokenCounter as i, ConversationContext as n, reconstructTurnBlocks as r, WorkItemsClient as t };
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
+ import { n as isTerminalShellStatus, t as getShellSessionManager } from "./ShellSessionManager-6o8KZzl1-vrbPAUTq.mjs";
2
3
  import { spawn } from "child_process";
3
4
  import path from "path";
4
- //#region ../../b4m-core/services/dist/bashExecute-B1N1lMOS.mjs
5
+ import { StringDecoder } from "string_decoder";
6
+ //#region ../../b4m-core/services/dist/bashExecute-CrdPpBqk.mjs
5
7
  const DEFAULT_TIMEOUT_MS = 6e4;
6
- const MAX_OUTPUT_SIZE = 100 * 1024;
8
+ const MAX_OUTPUT_SIZE = 102400;
7
9
  /**
8
10
  * Dangerous command patterns that should be blocked or warned about.
9
11
  * These patterns are checked against the full command string.
@@ -263,11 +265,16 @@ function isSafeCommandPrefix(command) {
263
265
  const baseCommand = getBaseCommand(command);
264
266
  return SAFE_COMMAND_PREFIXES.some((safe) => baseCommand === safe || baseCommand.endsWith(`/${safe}`));
265
267
  }
268
+ /** Max hard timeout for a foreground command (5 minutes). */
269
+ const MAX_FOREGROUND_TIMEOUT_MS = 3e5;
270
+ /** Grace window before a `run_in_background` command returns its session id. */
271
+ const BACKGROUND_GRACE_MS = 250;
266
272
  /**
267
- * Execute a bash command with safety checks
273
+ * Run the empty-command and dangerous-pattern safety checks shared by the
274
+ * foreground and background paths. Returns a blocked result, or null if the
275
+ * command may proceed.
268
276
  */
269
- async function executeBashCommand(params) {
270
- const { command, cwd: relativeCwd, timeout = DEFAULT_TIMEOUT_MS } = params;
277
+ function precheckCommand(command) {
271
278
  if (!command || command.trim().length === 0) return {
272
279
  stdout: "",
273
280
  stderr: "Error: Command cannot be empty",
@@ -285,20 +292,39 @@ async function executeBashCommand(params) {
285
292
  blocked: true,
286
293
  blockedReason: dangerCheck.reason
287
294
  };
295
+ return null;
296
+ }
297
+ /** Resolve a (possibly relative) cwd against the process cwd. */
298
+ function resolveCwd(relativeCwd) {
288
299
  const baseCwd = process.cwd();
289
- const targetCwd = relativeCwd ? path.resolve(baseCwd, relativeCwd) : baseCwd;
290
- const effectiveTimeout = Math.min(timeout, 300 * 1e3);
300
+ return relativeCwd ? path.resolve(baseCwd, relativeCwd) : baseCwd;
301
+ }
302
+ /** Environment for spawned commands: inherit, but suppress color codes. */
303
+ function buildEnv() {
304
+ return {
305
+ ...process.env,
306
+ NO_COLOR: "1",
307
+ FORCE_COLOR: "0"
308
+ };
309
+ }
310
+ /**
311
+ * Execute a bash command with safety checks (foreground, blocking).
312
+ */
313
+ async function executeBashCommand(params) {
314
+ const { command, cwd: relativeCwd, timeout = DEFAULT_TIMEOUT_MS } = params;
315
+ const blocked = precheckCommand(command);
316
+ if (blocked) return blocked;
317
+ const targetCwd = resolveCwd(relativeCwd);
318
+ const effectiveTimeout = Math.min(timeout, MAX_FOREGROUND_TIMEOUT_MS);
291
319
  return new Promise((resolve) => {
292
320
  let stdout = "";
293
321
  let stderr = "";
294
322
  let timedOut = false;
323
+ const outDecoder = new StringDecoder("utf8");
324
+ const errDecoder = new StringDecoder("utf8");
295
325
  const proc = spawn("bash", ["-c", command], {
296
326
  cwd: targetCwd,
297
- env: {
298
- ...process.env,
299
- NO_COLOR: "1",
300
- FORCE_COLOR: "0"
301
- },
327
+ env: buildEnv(),
302
328
  stdio: [
303
329
  "ignore",
304
330
  "pipe",
@@ -314,18 +340,20 @@ async function executeBashCommand(params) {
314
340
  }, effectiveTimeout);
315
341
  proc.stdout.on("data", (data) => {
316
342
  if (stdout.length < MAX_OUTPUT_SIZE) {
317
- stdout += data.toString();
343
+ stdout += outDecoder.write(data);
318
344
  if (stdout.length > MAX_OUTPUT_SIZE) stdout = stdout.slice(0, MAX_OUTPUT_SIZE) + "\n... [output truncated]";
319
345
  }
320
346
  });
321
347
  proc.stderr.on("data", (data) => {
322
348
  if (stderr.length < MAX_OUTPUT_SIZE) {
323
- stderr += data.toString();
349
+ stderr += errDecoder.write(data);
324
350
  if (stderr.length > MAX_OUTPUT_SIZE) stderr = stderr.slice(0, MAX_OUTPUT_SIZE) + "\n... [output truncated]";
325
351
  }
326
352
  });
327
353
  proc.on("close", (exitCode) => {
328
354
  clearTimeout(timeoutId);
355
+ stdout += outDecoder.end();
356
+ stderr += errDecoder.end();
329
357
  resolve({
330
358
  stdout: stdout.trim(),
331
359
  stderr: stderr.trim(),
@@ -377,6 +405,55 @@ function formatResult(result, command) {
377
405
  if (!result.stdout && !result.stderr && !result.timedOut) parts.push("(command completed with no output)");
378
406
  return parts.join("\n");
379
407
  }
408
+ /** Resolve when the session reaches a terminal status, or after `timeoutMs`. */
409
+ function waitForSettleOrTimeout(manager, sessionId, timeoutMs) {
410
+ return new Promise((resolve) => {
411
+ const current = manager.get(sessionId);
412
+ if (!current || isTerminalShellStatus(current.status)) {
413
+ resolve(current);
414
+ return;
415
+ }
416
+ const finish = () => {
417
+ clearTimeout(timer);
418
+ unsubscribe();
419
+ resolve(manager.get(sessionId));
420
+ };
421
+ const timer = setTimeout(finish, timeoutMs);
422
+ const unsubscribe = manager.subscribe((session) => {
423
+ if (session.id === sessionId && isTerminalShellStatus(session.status)) finish();
424
+ });
425
+ });
426
+ }
427
+ /** Format the outcome of a background/yield session for the model. */
428
+ function formatSessionResult(session, output) {
429
+ const parts = [`$ ${session.command}`, ""];
430
+ if (isTerminalShellStatus(session.status)) {
431
+ const exit = session.exitCode === null ? session.status : `exit ${session.exitCode}`;
432
+ parts.push(`[session ${session.id} finished: ${exit}]`);
433
+ if (output.trim()) parts.push("", output.trim());
434
+ else parts.push("", "(no output)");
435
+ return parts.join("\n");
436
+ }
437
+ parts.push(`[background session started: ${session.id} - still running]`);
438
+ parts.push(`Poll with check_shell_output (session_id: "${session.id}"), send input with write_shell_stdin, stop with kill_background_shell.`);
439
+ if (output.trim()) parts.push("", "Output so far:", output.trim());
440
+ return parts.join("\n");
441
+ }
442
+ /**
443
+ * Run a command as a shell session, waiting up to `waitMs` for it to settle.
444
+ * Returns the full output if it finishes in time, otherwise a session id to poll.
445
+ */
446
+ async function executeBackgroundSession(params, waitMs, manager = getShellSessionManager()) {
447
+ const blocked = precheckCommand(params.command);
448
+ if (blocked) return formatResult(blocked, params.command);
449
+ let session;
450
+ try {
451
+ session = manager.spawn(params.command, resolveCwd(params.cwd), buildEnv());
452
+ } catch (error) {
453
+ return `$ ${params.command}\n\n[cannot start background session] ${error instanceof Error ? error.message : String(error)}`;
454
+ }
455
+ return formatSessionResult(await waitForSettleOrTimeout(manager, session.id, waitMs) ?? session, manager.getOutput(session.id)?.output ?? "");
456
+ }
380
457
  const bashExecuteTool = {
381
458
  name: "bash_execute",
382
459
  implementation: (context) => ({
@@ -394,6 +471,14 @@ const bashExecuteTool = {
394
471
  cwd: params.cwd
395
472
  });
396
473
  try {
474
+ if (params.run_in_background === true || typeof params.yield_time_ms === "number") {
475
+ const sessionResult = await executeBackgroundSession(params, params.run_in_background ? BACKGROUND_GRACE_MS : Math.min(Math.max(params.yield_time_ms ?? BACKGROUND_GRACE_MS, 0), MAX_FOREGROUND_TIMEOUT_MS));
476
+ if (context.onFinish) await context.onFinish("bash_execute", {
477
+ command: params.command,
478
+ background: true
479
+ });
480
+ return sessionResult;
481
+ }
397
482
  const result = await executeBashCommand(params);
398
483
  const formattedResult = formatResult(result, params.command);
399
484
  context.logger.info("Bash: Command completed", {
@@ -427,6 +512,13 @@ SAFETY NOTES:
427
512
  - Output is limited to prevent overwhelming responses
428
513
  - This tool ALWAYS requires user permission before execution
429
514
 
515
+ LONG-RUNNING / BACKGROUND COMMANDS:
516
+ - Set run_in_background: true for dev servers, watchers, or anything long-lived. Returns a
517
+ session_id immediately instead of blocking. Then poll it with check_shell_output, feed it
518
+ input with write_shell_stdin, and stop it with kill_background_shell.
519
+ - Set yield_time_ms to run a command in the foreground but, if it is still going after that
520
+ window, hand back a session_id (and keep it running) instead of killing it at the timeout.
521
+
430
522
  COMMON USE CASES:
431
523
  - Running build commands: npm run build, make, cargo build
432
524
  - Git operations: git status, git log, git diff
@@ -454,7 +546,15 @@ BLOCKED OPERATIONS:
454
546
  },
455
547
  timeout: {
456
548
  type: "number",
457
- description: "Timeout in milliseconds (optional, default: 60000, max: 300000). Command will be terminated if it exceeds this time."
549
+ description: "Timeout in milliseconds (optional, default: 60000, max: 300000). Command will be terminated if it exceeds this time. Ignored in background/yield mode."
550
+ },
551
+ run_in_background: {
552
+ type: "boolean",
553
+ description: "Optional. Start the command as a background session and return a session_id immediately (for dev servers, watchers, long builds). Poll with check_shell_output."
554
+ },
555
+ yield_time_ms: {
556
+ type: "number",
557
+ description: "Optional. Run in the foreground but, if still running after this many ms, return a session_id and keep it running instead of killing it."
458
558
  }
459
559
  },
460
560
  required: ["command"]
@@ -463,4 +563,4 @@ BLOCKED OPERATIONS:
463
563
  })
464
564
  };
465
565
  //#endregion
466
- export { bashExecuteTool };
566
+ export { bashExecuteTool, executeBackgroundSession, executeBashCommand, formatSessionResult };