@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,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
|
-
|
|
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 =
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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 +=
|
|
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 +=
|
|
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 };
|