@runfusion/fusion 0.12.0 → 0.14.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.
- package/README.md +13 -0
- package/dist/bin.js +1707 -610
- package/dist/client/assets/AgentDetailView-CBFUveyO.js +18 -0
- package/dist/client/assets/AgentsView-DPezXQ-U.js +522 -0
- package/dist/client/assets/{AgentsView-Bkk-uBij.css → AgentsView-V5GhlBYu.css} +1 -1
- package/dist/client/assets/ChatView-5N4-EuhD.js +1 -0
- package/dist/client/assets/{DevServerView-DQrVLbK5.js → DevServerView-Daft4YFc.js} +1 -1
- package/dist/client/assets/{DirectoryPicker-DVmy6sLM.js → DirectoryPicker-rew1y6qO.js} +1 -1
- package/dist/client/assets/{DocumentsView-DHEv-Q2a.js → DocumentsView-i72qJzwd.js} +1 -1
- package/dist/client/assets/{InsightsView-ByyY7GX7.js → InsightsView-BL5eZJ0a.js} +3 -3
- package/dist/client/assets/{MemoryView-Udiu0u8R.js → MemoryView-pl8Cdg_p.js} +2 -2
- package/dist/client/assets/{NodesView-CupS-GGc.js → NodesView-D6eJ15zc.js} +4 -4
- package/dist/client/assets/PiExtensionsManager-ExInwXWP.js +11 -0
- package/dist/client/assets/PluginManager-CYhtxHun.js +1 -0
- package/dist/client/assets/{ResearchView-BG9Feaeb.js → ResearchView-B_QPUEjB.js} +1 -1
- package/dist/client/assets/{RoadmapsView-BTJtmBnF.js → RoadmapsView-DBNLaEsK.js} +2 -2
- package/dist/client/assets/SettingsModal-1ET586M3.js +31 -0
- package/dist/client/assets/{SettingsModal-eNCZiHa6.js → SettingsModal-CL_gWmOj.js} +1 -1
- package/dist/client/assets/SettingsModal-D_AFkDJa.css +1 -0
- package/dist/client/assets/{SetupWizardModal-yf79TN1L.js → SetupWizardModal-CLkY9HFL.js} +1 -1
- package/dist/client/assets/{SkillMultiselect-DOj5vX4U.js → SkillMultiselect-B0qi32SQ.js} +1 -1
- package/dist/client/assets/{SkillsView-CgnCnikX.js → SkillsView-umVjRq6o.js} +1 -1
- package/dist/client/assets/TodoView-CFifSvrD.js +6 -0
- package/dist/client/assets/TodoView-SeO9o7km.css +1 -0
- package/dist/client/assets/{folder-open-D11gjHGK.js → folder-open-nYPrL1W3.js} +1 -1
- package/dist/client/assets/index-Bc8nfKeH.js +661 -0
- package/dist/client/assets/index-C1prPuSl.css +1 -0
- package/dist/client/assets/{list-checks-CBzPc3GA.js → list-checks-sK8xJeH_.js} +1 -1
- package/dist/client/assets/{star-BWcRk8nt.js → star-BRtXbYkB.js} +1 -1
- package/dist/client/assets/{upload-91TM4ljC.js → upload-BP60eBwN.js} +1 -1
- package/dist/client/assets/{users-BAsI___L.js → users-qSGAX2Pf.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/sw.js +6 -0
- package/dist/client/version.json +1 -1
- package/dist/droid-cli/index.ts +127 -0
- package/dist/droid-cli/package.json +37 -0
- package/dist/droid-cli/src/__tests__/control-handler.test.ts +164 -0
- package/dist/droid-cli/src/__tests__/event-bridge.test.ts +1318 -0
- package/dist/droid-cli/src/__tests__/mcp-config.test.ts +310 -0
- package/dist/droid-cli/src/__tests__/process-manager.test.ts +818 -0
- package/dist/droid-cli/src/__tests__/prompt-builder.test.ts +1206 -0
- package/dist/droid-cli/src/__tests__/provider.test.ts +1894 -0
- package/dist/droid-cli/src/__tests__/setup-test-isolation.test.ts +32 -0
- package/dist/droid-cli/src/__tests__/setup-test-isolation.ts +14 -0
- package/dist/droid-cli/src/__tests__/stream-parser.test.ts +188 -0
- package/dist/droid-cli/src/__tests__/thinking-config.test.ts +141 -0
- package/dist/droid-cli/src/__tests__/tool-mapping.test.ts +253 -0
- package/dist/droid-cli/src/control-handler.ts +82 -0
- package/dist/droid-cli/src/event-bridge.ts +397 -0
- package/dist/droid-cli/src/mcp-config.ts +144 -0
- package/dist/droid-cli/src/mcp-schema-server.cjs +49 -0
- package/dist/droid-cli/src/process-manager.ts +358 -0
- package/dist/droid-cli/src/prompt-builder.ts +629 -0
- package/dist/droid-cli/src/provider.ts +447 -0
- package/dist/droid-cli/src/stream-parser.ts +37 -0
- package/dist/droid-cli/src/thinking-config.ts +83 -0
- package/dist/droid-cli/src/tool-mapping.ts +147 -0
- package/dist/droid-cli/src/types.ts +87 -0
- package/dist/extension.js +542 -141
- package/dist/pi-claude-cli/package.json +1 -1
- package/dist/pi-claude-cli/src/__tests__/prompt-builder.test.ts +36 -0
- package/dist/pi-claude-cli/src/prompt-builder.ts +19 -28
- package/package.json +2 -1
- package/dist/client/assets/AgentDetailView-B20ApPe1.js +0 -18
- package/dist/client/assets/AgentsView-ChN1tgQ0.js +0 -522
- package/dist/client/assets/ChatView-oPMFwmoc.js +0 -1
- package/dist/client/assets/PiExtensionsManager-DXs2xI8K.js +0 -11
- package/dist/client/assets/PluginManager-BCpiZf4_.js +0 -1
- package/dist/client/assets/SettingsModal-9HS8MnmW.css +0 -1
- package/dist/client/assets/SettingsModal-DZ_LaEhd.js +0 -31
- package/dist/client/assets/TodoView-67BMyICY.js +0 -6
- package/dist/client/assets/TodoView-C1g65hJo.css +0 -1
- package/dist/client/assets/index-BLn1R7Ob.css +0 -1
- package/dist/client/assets/index-CLAHcGnI.js +0 -656
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider orchestration for bridging pi requests to the Droid CLI subprocess.
|
|
3
|
+
*
|
|
4
|
+
* streamViaCli is the core function that:
|
|
5
|
+
* 1. Builds the prompt from conversation context
|
|
6
|
+
* 2. Spawns a Droid CLI subprocess with correct flags
|
|
7
|
+
* 3. Writes the user message to stdin as NDJSON
|
|
8
|
+
* 4. Reads stdout line-by-line, parsing NDJSON
|
|
9
|
+
* 5. Routes stream events through the event bridge to pi's stream
|
|
10
|
+
* 6. Handles result/error messages and cleans up the subprocess
|
|
11
|
+
* 7. Implements break-early: kills subprocess at message_stop when
|
|
12
|
+
* built-in or custom-tools MCP tool_use blocks are seen
|
|
13
|
+
* 8. Hardened lifecycle: inactivity timeout, subprocess exit handler,
|
|
14
|
+
* streamEnded guard, abort via SIGKILL, process registry
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createInterface } from "node:readline";
|
|
18
|
+
import {
|
|
19
|
+
AssistantMessageEventStream,
|
|
20
|
+
type Api,
|
|
21
|
+
type Model,
|
|
22
|
+
type SimpleStreamOptions,
|
|
23
|
+
type TextContent,
|
|
24
|
+
type ThinkingContent,
|
|
25
|
+
type ToolCall,
|
|
26
|
+
} from "@mariozechner/pi-ai";
|
|
27
|
+
import {
|
|
28
|
+
buildPrompt,
|
|
29
|
+
buildSystemPrompt,
|
|
30
|
+
buildResumePrompt,
|
|
31
|
+
type PiContext,
|
|
32
|
+
} from "./prompt-builder.js";
|
|
33
|
+
import {
|
|
34
|
+
spawnDroid,
|
|
35
|
+
writeUserMessage,
|
|
36
|
+
cleanupProcess,
|
|
37
|
+
captureStderr,
|
|
38
|
+
forceKillProcess,
|
|
39
|
+
registerProcess,
|
|
40
|
+
cleanupSystemPromptFile,
|
|
41
|
+
buildDroidSpawnArgs,
|
|
42
|
+
} from "./process-manager.js";
|
|
43
|
+
import { parseLine } from "./stream-parser.js";
|
|
44
|
+
import { createEventBridge } from "./event-bridge.js";
|
|
45
|
+
import { mapThinkingEffort } from "./thinking-config.js";
|
|
46
|
+
import { isPiKnownDroidTool } from "./tool-mapping.js";
|
|
47
|
+
/**
|
|
48
|
+
* Inactivity safety net for the Droid CLI subprocess.
|
|
49
|
+
*
|
|
50
|
+
* Set very high (30 minutes) because the caller is the authoritative source of
|
|
51
|
+
* truth for "this session is stuck": Fusion's engine runs a `StuckTaskDetector`
|
|
52
|
+
* with a configurable heartbeat (default 1 hour) and aborts the session via
|
|
53
|
+
* `AbortSignal` when it decides the agent has gone quiet. droid-cli already
|
|
54
|
+
* forwards that signal to the subprocess (`forceKillProcess` on `signal.abort`).
|
|
55
|
+
*
|
|
56
|
+
* A short timeout here was racing the engine: Sonnet 4.6 with extended thinking
|
|
57
|
+
* on the triage prompt (~40k chars) routinely goes >3 minutes between thinking
|
|
58
|
+
* deltas, and we were killing those subprocesses before they could write
|
|
59
|
+
* PROMPT.md and call `fn_review_spec`. The half-hour ceiling is just a
|
|
60
|
+
* last-resort guard for catastrophically hung processes when no abort signal
|
|
61
|
+
* arrives (e.g. someone embeds droid-cli without a stuck detector).
|
|
62
|
+
*/
|
|
63
|
+
const INACTIVITY_TIMEOUT_MS = 30 * 60_000;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Cold-start ceiling: kill the subprocess if it hasn't produced a single line
|
|
67
|
+
* of stdout within this window. Distinct from INACTIVITY_TIMEOUT_MS so a hung
|
|
68
|
+
* binary (no output ever) is reported with a clear cause instead of being
|
|
69
|
+
* indistinguishable from a slow-thinking turn. Observed cold-start on a healthy
|
|
70
|
+
* droid is ~20s; 60s gives 3x headroom for slow machines / cold caches.
|
|
71
|
+
*/
|
|
72
|
+
const FIRST_LINE_TIMEOUT_MS = 60_000;
|
|
73
|
+
function isDebugStreamEnabled(): boolean {
|
|
74
|
+
return process.env.PI_DROID_CLI_DEBUG === "1";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function debugLog(message: string): void {
|
|
78
|
+
if (!isDebugStreamEnabled()) return;
|
|
79
|
+
console.error(`[droid-cli] ${message}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Extended stream options: pi's SimpleStreamOptions plus optional cwd and mcpConfigPath */
|
|
83
|
+
type StreamViaCLiOptions = SimpleStreamOptions & {
|
|
84
|
+
cwd?: string;
|
|
85
|
+
mcpConfigPath?: string;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Stream a response from Droid CLI as an AssistantMessageEventStream.
|
|
90
|
+
*
|
|
91
|
+
* Orchestrates the full subprocess lifecycle: spawn, write prompt, parse NDJSON,
|
|
92
|
+
* bridge events, handle result, and clean up. Implements break-early pattern:
|
|
93
|
+
* at message_stop, if any built-in or custom-tools MCP tool was seen, kills
|
|
94
|
+
* the subprocess before Droid CLI can auto-execute the tools.
|
|
95
|
+
*
|
|
96
|
+
* Hardened with: inactivity timeout (180s), subprocess exit handler with stderr
|
|
97
|
+
* surfacing, streamEnded guard against double errors, abort via SIGKILL, and
|
|
98
|
+
* process registry integration for teardown cleanup.
|
|
99
|
+
*
|
|
100
|
+
* @param model - The model to use (from pi's model catalog)
|
|
101
|
+
* @param context - The conversation context with messages and system prompt
|
|
102
|
+
* @param options - Optional cwd, abort signal, reasoning level, thinking budgets, and mcpConfigPath
|
|
103
|
+
* @returns An AssistantMessageEventStream that receives bridged events
|
|
104
|
+
*/
|
|
105
|
+
export function streamViaCli(
|
|
106
|
+
model: Model<Api>,
|
|
107
|
+
context: PiContext,
|
|
108
|
+
options?: StreamViaCLiOptions,
|
|
109
|
+
): AssistantMessageEventStream {
|
|
110
|
+
// @ts-expect-error — tsc can't verify AssistantMessageEventStream is a value
|
|
111
|
+
// through pi-ai's `export *` re-export chain. The class constructor exists at runtime.
|
|
112
|
+
const stream = new AssistantMessageEventStream();
|
|
113
|
+
|
|
114
|
+
(async () => {
|
|
115
|
+
let proc: ReturnType<typeof spawnDroid> | undefined;
|
|
116
|
+
let abortHandler: (() => void) | undefined;
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
120
|
+
|
|
121
|
+
// Resume if pi provides a session ID AND this isn't the first turn.
|
|
122
|
+
// Pi passes sessionId on every call (including first), but we can only
|
|
123
|
+
// --resume a CLI session that already exists on disk from a prior turn.
|
|
124
|
+
const resumeSessionId =
|
|
125
|
+
options?.sessionId && context.messages.length > 1
|
|
126
|
+
? options.sessionId
|
|
127
|
+
: undefined;
|
|
128
|
+
|
|
129
|
+
// Build prompt: if resuming, only send the latest user turn;
|
|
130
|
+
// otherwise build the full flattened conversation history
|
|
131
|
+
const prompt = resumeSessionId
|
|
132
|
+
? buildResumePrompt(context)
|
|
133
|
+
: buildPrompt(context);
|
|
134
|
+
const systemPrompt = resumeSessionId
|
|
135
|
+
? undefined
|
|
136
|
+
: buildSystemPrompt(context, cwd);
|
|
137
|
+
|
|
138
|
+
// Compute effort level from reasoning options
|
|
139
|
+
const effort = mapThinkingEffort(
|
|
140
|
+
options?.reasoning,
|
|
141
|
+
model.id,
|
|
142
|
+
options?.thinkingBudgets,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const spawnOptions = {
|
|
146
|
+
cwd,
|
|
147
|
+
signal: options?.signal,
|
|
148
|
+
effort,
|
|
149
|
+
mcpConfigPath: options?.mcpConfigPath,
|
|
150
|
+
resumeSessionId,
|
|
151
|
+
newSessionId: !resumeSessionId ? options?.sessionId : undefined,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Spawn subprocess
|
|
155
|
+
proc = spawnDroid(model.id, systemPrompt || undefined, spawnOptions);
|
|
156
|
+
const getStderr = captureStderr(proc);
|
|
157
|
+
|
|
158
|
+
// Register in global process registry for teardown cleanup
|
|
159
|
+
registerProcess(proc);
|
|
160
|
+
const spawnArgs = buildDroidSpawnArgs(model.id, undefined, {
|
|
161
|
+
effort,
|
|
162
|
+
mcpConfigPath: options?.mcpConfigPath,
|
|
163
|
+
resumeSessionId,
|
|
164
|
+
newSessionId: !resumeSessionId ? options?.sessionId : undefined,
|
|
165
|
+
});
|
|
166
|
+
debugLog(
|
|
167
|
+
`spawned droid subprocess pid=${proc.pid ?? "unknown"} args=${JSON.stringify(spawnArgs)}`,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Write user message to subprocess stdin
|
|
171
|
+
writeUserMessage(proc, prompt);
|
|
172
|
+
debugLog("user message written to stdin, stdin.end() called");
|
|
173
|
+
|
|
174
|
+
// Create event bridge (before endStreamWithError so bridge is in scope)
|
|
175
|
+
const bridge = createEventBridge(stream, model);
|
|
176
|
+
|
|
177
|
+
// Guard against double stream.end() and double error events.
|
|
178
|
+
// First error path wins; subsequent ones are no-ops.
|
|
179
|
+
let streamEnded = false;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* End the stream with an error, using a "done" event instead of "error".
|
|
183
|
+
*
|
|
184
|
+
* Why "done" not "error": AssistantMessageEventStream.extractResult()
|
|
185
|
+
* returns event.error (a string) for error events, but agent-loop.js
|
|
186
|
+
* then calls message.content.filter() on the result, crashing because
|
|
187
|
+
* a string has no .content property. By pushing "done" with a valid
|
|
188
|
+
* AssistantMessage (content:[]), pi gets a well-formed object.
|
|
189
|
+
*/
|
|
190
|
+
function endStreamWithError(errMsg: string) {
|
|
191
|
+
if (streamEnded || broken) return;
|
|
192
|
+
streamEnded = true;
|
|
193
|
+
const output = bridge.getOutput();
|
|
194
|
+
const errorMessage = {
|
|
195
|
+
...output,
|
|
196
|
+
content: output.content?.length
|
|
197
|
+
? output.content
|
|
198
|
+
: [{ type: "text" as const, text: `Error: ${errMsg}` }],
|
|
199
|
+
stopReason: "stop" as const,
|
|
200
|
+
};
|
|
201
|
+
stream.push({
|
|
202
|
+
type: "done",
|
|
203
|
+
reason: "stop",
|
|
204
|
+
message: errorMessage,
|
|
205
|
+
});
|
|
206
|
+
stream.end();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Inactivity timeout: kill subprocess if no stdout for INACTIVITY_TIMEOUT_MS
|
|
210
|
+
let inactivityTimer: ReturnType<typeof setTimeout> | undefined;
|
|
211
|
+
|
|
212
|
+
function resetInactivityTimer() {
|
|
213
|
+
if (inactivityTimer !== undefined) clearTimeout(inactivityTimer);
|
|
214
|
+
inactivityTimer = setTimeout(() => {
|
|
215
|
+
forceKillProcess(proc!);
|
|
216
|
+
endStreamWithError(
|
|
217
|
+
`Droid CLI subprocess timed out: no output for ${INACTIVITY_TIMEOUT_MS / 1000} seconds`,
|
|
218
|
+
);
|
|
219
|
+
}, INACTIVITY_TIMEOUT_MS);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Set up abort signal handler -- uses SIGKILL for immediate force-kill
|
|
223
|
+
if (options?.signal) {
|
|
224
|
+
abortHandler = () => {
|
|
225
|
+
if (proc) {
|
|
226
|
+
forceKillProcess(proc);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
if (options.signal.aborted) {
|
|
231
|
+
abortHandler();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Track tool_use blocks for break-early decision at message_stop
|
|
238
|
+
let sawBuiltInOrCustomTool = false;
|
|
239
|
+
let firstLineReceived = false;
|
|
240
|
+
// Guard against buffered readline lines firing after rl.close()
|
|
241
|
+
let broken = false;
|
|
242
|
+
|
|
243
|
+
// Set up readline for line-by-line NDJSON parsing
|
|
244
|
+
const rl = createInterface({
|
|
245
|
+
input: proc.stdout!,
|
|
246
|
+
crlfDelay: Infinity,
|
|
247
|
+
terminal: false,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Handle process error -- use endStreamWithError for guard
|
|
251
|
+
proc.on("error", (err: Error) => {
|
|
252
|
+
if (broken) return; // Break-early killed the process intentionally
|
|
253
|
+
const stderr = getStderr();
|
|
254
|
+
endStreamWithError(stderr || err.message);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// Handle subprocess close -- surface crashes with stderr and exit code
|
|
258
|
+
proc.on("close", (code: number | null, _signal: string | null) => {
|
|
259
|
+
clearTimeout(inactivityTimer);
|
|
260
|
+
debugLog(`subprocess closed: code=${code} signal=${_signal}`);
|
|
261
|
+
if (broken) return; // Break-early kill, expected
|
|
262
|
+
const stderr = getStderr().trim();
|
|
263
|
+
if (stderr) {
|
|
264
|
+
console.warn(`[droid-cli] Droid CLI stderr on close: ${stderr}`);
|
|
265
|
+
}
|
|
266
|
+
if (code !== 0 && code !== null) {
|
|
267
|
+
const message = stderr
|
|
268
|
+
? `Droid CLI exited with code ${code}: ${stderr}`
|
|
269
|
+
: `Droid CLI exited unexpectedly with code ${code}`;
|
|
270
|
+
endStreamWithError(message);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Start inactivity timer after writing user message
|
|
275
|
+
resetInactivityTimer();
|
|
276
|
+
|
|
277
|
+
// Cold-start ceiling: only fires if firstLineReceived stays false. Cleared
|
|
278
|
+
// when the first line arrives, when proc closes, or on break-early. This
|
|
279
|
+
// distinguishes "droid never started" from "droid is taking a long time
|
|
280
|
+
// between thinking deltas" so the inactivity kill carries actionable info.
|
|
281
|
+
const firstLineTimer: ReturnType<typeof setTimeout> = setTimeout(() => {
|
|
282
|
+
if (firstLineReceived) return;
|
|
283
|
+
forceKillProcess(proc!);
|
|
284
|
+
endStreamWithError(
|
|
285
|
+
`Droid CLI produced no output within ${FIRST_LINE_TIMEOUT_MS / 1000}s — likely binary hang or auth failure (try \`droid --version\` and \`droid auth status\`)`,
|
|
286
|
+
);
|
|
287
|
+
}, FIRST_LINE_TIMEOUT_MS);
|
|
288
|
+
proc.on("close", () => clearTimeout(firstLineTimer));
|
|
289
|
+
|
|
290
|
+
// Process NDJSON lines from stdout using event-based callback
|
|
291
|
+
// NOTE: Using 'line' event instead of `for await` because the async
|
|
292
|
+
// iterator batches lines, breaking real-time streaming to pi.
|
|
293
|
+
rl.on("line", (line: string) => {
|
|
294
|
+
if (!firstLineReceived) {
|
|
295
|
+
firstLineReceived = true;
|
|
296
|
+
debugLog("first stdout line received from Droid CLI");
|
|
297
|
+
}
|
|
298
|
+
if (broken) return; // Guard: ignore buffered lines after break-early
|
|
299
|
+
|
|
300
|
+
// Reset inactivity timer on each line of output
|
|
301
|
+
resetInactivityTimer();
|
|
302
|
+
|
|
303
|
+
const msg = parseLine(line);
|
|
304
|
+
if (!msg) return;
|
|
305
|
+
|
|
306
|
+
if (msg.type === "stream_event") {
|
|
307
|
+
// Only forward top-level events to pi's event bridge.
|
|
308
|
+
// Sub-agent events (parent_tool_use_id !== null) are internal to the CLI.
|
|
309
|
+
const isTopLevel = !msg.parent_tool_use_id;
|
|
310
|
+
if (isTopLevel) {
|
|
311
|
+
bridge.handleEvent(msg.event);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Track tool_use blocks for break-early decision (top-level only)
|
|
315
|
+
if (
|
|
316
|
+
isTopLevel &&
|
|
317
|
+
msg.event.type === "content_block_start" &&
|
|
318
|
+
msg.event.content_block?.type === "tool_use"
|
|
319
|
+
) {
|
|
320
|
+
const toolName = msg.event.content_block.name;
|
|
321
|
+
if (toolName) {
|
|
322
|
+
const piKnownTool = isPiKnownDroidTool(toolName);
|
|
323
|
+
debugLog(
|
|
324
|
+
`top-level tool_use seen: ${toolName} (piKnown=${piKnownTool ? "yes" : "no"})`,
|
|
325
|
+
);
|
|
326
|
+
if (piKnownTool) {
|
|
327
|
+
// Built-in tool (Read/Write/etc.) OR custom MCP tool (mcp__custom-tools__*)
|
|
328
|
+
// Internal Claude Code tools (ToolSearch, Task, etc.) are excluded
|
|
329
|
+
sawBuiltInOrCustomTool = true;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Break-early at message_stop: kill subprocess before CLI auto-executes tools
|
|
335
|
+
// Only on top-level message_stop — sub-agent message_stop is internal
|
|
336
|
+
if (
|
|
337
|
+
isTopLevel &&
|
|
338
|
+
msg.event.type === "message_stop" &&
|
|
339
|
+
sawBuiltInOrCustomTool
|
|
340
|
+
) {
|
|
341
|
+
debugLog("break-early triggered at message_stop after pi-known tool_use");
|
|
342
|
+
broken = true; // Set guard BEFORE rl.close() to prevent buffered lines
|
|
343
|
+
clearTimeout(inactivityTimer);
|
|
344
|
+
clearTimeout(firstLineTimer);
|
|
345
|
+
// Pi will execute these tools. Kill subprocess to prevent CLI from executing them.
|
|
346
|
+
forceKillProcess(proc!);
|
|
347
|
+
rl.close();
|
|
348
|
+
return; // Don't process further -- done event already pushed by event bridge
|
|
349
|
+
}
|
|
350
|
+
} else if (msg.type === "control_request") {
|
|
351
|
+
debugLog(
|
|
352
|
+
`unexpected control_request received (stdin already closed): ${msg.request_id}`,
|
|
353
|
+
);
|
|
354
|
+
} else if (msg.type === "result") {
|
|
355
|
+
if (msg.subtype === "error") {
|
|
356
|
+
endStreamWithError(msg.error ?? "Unknown error from Droid CLI");
|
|
357
|
+
}
|
|
358
|
+
// For both success and error: clean up the subprocess
|
|
359
|
+
clearTimeout(inactivityTimer);
|
|
360
|
+
clearTimeout(firstLineTimer);
|
|
361
|
+
cleanupProcess(proc!);
|
|
362
|
+
rl.close();
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// Wait for readline to close (result received or process ended).
|
|
367
|
+
// Also resolve on subprocess close: if SIGKILL races readline (e.g. after
|
|
368
|
+
// an external abort or watchdog kill), `rl` may never emit "close" because
|
|
369
|
+
// its input stream was destroyed mid-buffer. Forcing rl.close() from the
|
|
370
|
+
// proc close handler guarantees this await unblocks instead of hanging
|
|
371
|
+
// and triggering the engine's "executor did not unwind within 60s" path.
|
|
372
|
+
await new Promise<void>((resolve) => {
|
|
373
|
+
rl.on("close", resolve);
|
|
374
|
+
proc!.on("close", () => {
|
|
375
|
+
try { rl.close(); } catch { /* already closed */ }
|
|
376
|
+
resolve();
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// Push done event after readline closes (async). Pushing synchronously
|
|
381
|
+
// inside handleMessageStop prevents pi from executing tools.
|
|
382
|
+
// Guard with streamEnded to avoid pushing done after an error was already pushed.
|
|
383
|
+
if (!streamEnded) {
|
|
384
|
+
const output = bridge.getOutput();
|
|
385
|
+
const contentEvents = output.content || [];
|
|
386
|
+
|
|
387
|
+
if (contentEvents.length === 0) {
|
|
388
|
+
console.warn(
|
|
389
|
+
`[droid-cli] Droid CLI closed without content events (model=${model.id}, sessionId=${options?.sessionId ?? "none"})`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// If stopReason is toolUse but there are no pi-known tool calls in content,
|
|
394
|
+
// it means only user MCP tools were called (filtered by event bridge).
|
|
395
|
+
// Override to "stop" so pi doesn't try to execute non-existent tools.
|
|
396
|
+
const piToolCalls = (output.content || []).filter(
|
|
397
|
+
(c: TextContent | ThinkingContent | ToolCall) => c.type === "toolCall",
|
|
398
|
+
);
|
|
399
|
+
const effectiveReason =
|
|
400
|
+
output.stopReason === "toolUse" && piToolCalls.length === 0
|
|
401
|
+
? "stop"
|
|
402
|
+
: output.stopReason;
|
|
403
|
+
|
|
404
|
+
streamEnded = true;
|
|
405
|
+
stream.push({
|
|
406
|
+
type: "done",
|
|
407
|
+
reason:
|
|
408
|
+
effectiveReason === "toolUse"
|
|
409
|
+
? "toolUse"
|
|
410
|
+
: effectiveReason === "length"
|
|
411
|
+
? "length"
|
|
412
|
+
: "stop",
|
|
413
|
+
message: { ...output, stopReason: effectiveReason },
|
|
414
|
+
});
|
|
415
|
+
stream.end();
|
|
416
|
+
}
|
|
417
|
+
} catch (err) {
|
|
418
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
419
|
+
// Push a "done" event with a text error so pi gets a valid AssistantMessage.
|
|
420
|
+
// Pushing type:"error" would require an AssistantMessage in the error field,
|
|
421
|
+
// but we don't have a full AssistantMessage here.
|
|
422
|
+
stream.push({
|
|
423
|
+
type: "done",
|
|
424
|
+
reason: "stop",
|
|
425
|
+
message: {
|
|
426
|
+
role: "assistant" as const,
|
|
427
|
+
content: [{ type: "text" as const, text: `Error: ${errMsg}` }],
|
|
428
|
+
api: "droid-cli",
|
|
429
|
+
provider: model.provider,
|
|
430
|
+
model: model.id,
|
|
431
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
432
|
+
stopReason: "stop" as const,
|
|
433
|
+
timestamp: Date.now(),
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
stream.end();
|
|
437
|
+
} finally {
|
|
438
|
+
// Clean up abort listener
|
|
439
|
+
if (options?.signal && abortHandler) {
|
|
440
|
+
options.signal.removeEventListener("abort", abortHandler);
|
|
441
|
+
}
|
|
442
|
+
cleanupSystemPromptFile();
|
|
443
|
+
}
|
|
444
|
+
})();
|
|
445
|
+
|
|
446
|
+
return stream;
|
|
447
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { NdjsonMessage } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse a single NDJSON line from Droid CLI stdout into a typed message.
|
|
5
|
+
*
|
|
6
|
+
* This function is deliberately resilient -- it never throws. Debug noise,
|
|
7
|
+
* empty lines, and malformed JSON all return null so the streaming pipeline
|
|
8
|
+
* can safely skip them and continue processing.
|
|
9
|
+
*/
|
|
10
|
+
export function parseLine(line: string): NdjsonMessage | null {
|
|
11
|
+
const trimmed = line.trim();
|
|
12
|
+
|
|
13
|
+
// Skip empty lines
|
|
14
|
+
if (!trimmed) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Skip non-JSON lines (debug output like "[SandboxDebug] ...")
|
|
19
|
+
if (!trimmed.startsWith("{")) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let parsed: unknown;
|
|
24
|
+
try {
|
|
25
|
+
parsed = JSON.parse(trimmed);
|
|
26
|
+
} catch {
|
|
27
|
+
console.error("Failed to parse NDJSON line:", trimmed);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Validate that the parsed result is a non-null object (not array, not primitive)
|
|
32
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return parsed as NdjsonMessage;
|
|
37
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thinking effort configuration for mapping pi's ThinkingLevel to Droid CLI --effort flags.
|
|
3
|
+
*
|
|
4
|
+
* Maps pi's reasoning levels (minimal/low/medium/high/xhigh) to the CLI's effort
|
|
5
|
+
* levels (low/medium/high/max). Opus models get an elevated mapping where medium
|
|
6
|
+
* becomes high and high becomes max, leveraging their superior reasoning capability.
|
|
7
|
+
*
|
|
8
|
+
* IMPORTANT: The CLI does NOT support --thinking-budget. Only --effort is supported.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ThinkingLevel, ThinkingBudgets } from "@mariozechner/pi-ai";
|
|
12
|
+
|
|
13
|
+
/** CLI effort levels accepted by the --effort flag */
|
|
14
|
+
export type CliEffortLevel = "low" | "medium" | "high" | "max";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Standard model mapping: pi ThinkingLevel -> CLI effort.
|
|
18
|
+
* Non-Opus models never receive "max" (would cause CLI error).
|
|
19
|
+
*/
|
|
20
|
+
const STANDARD_EFFORT_MAP: Record<ThinkingLevel, CliEffortLevel> = {
|
|
21
|
+
minimal: "low",
|
|
22
|
+
low: "low",
|
|
23
|
+
medium: "medium",
|
|
24
|
+
high: "high",
|
|
25
|
+
xhigh: "high", // non-Opus: silently downgrade (max not supported)
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Opus model mapping: shifted up for elevated reasoning.
|
|
30
|
+
* Opus models get max capability at high/xhigh levels.
|
|
31
|
+
*/
|
|
32
|
+
const OPUS_EFFORT_MAP: Record<ThinkingLevel, CliEffortLevel> = {
|
|
33
|
+
minimal: "low",
|
|
34
|
+
low: "low",
|
|
35
|
+
medium: "high", // shifted: standard high
|
|
36
|
+
high: "max", // shifted: maximum capability
|
|
37
|
+
xhigh: "max", // Opus gets max
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Detect whether a model ID refers to an Opus model.
|
|
42
|
+
* Uses includes('opus') for forward-compatibility with future Opus versions.
|
|
43
|
+
*
|
|
44
|
+
* @param modelId - The model identifier string
|
|
45
|
+
* @returns true if the model is an Opus variant
|
|
46
|
+
*/
|
|
47
|
+
export function isOpusModel(modelId: string): boolean {
|
|
48
|
+
return modelId.includes("opus");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Map pi's ThinkingLevel to a CLI effort string.
|
|
53
|
+
*
|
|
54
|
+
* When reasoning is undefined, returns undefined so the --effort flag is omitted
|
|
55
|
+
* entirely, letting the CLI use its default behavior. When thinkingBudgets are
|
|
56
|
+
* provided, a console.warn is logged because the CLI only supports effort levels,
|
|
57
|
+
* not token budgets.
|
|
58
|
+
*
|
|
59
|
+
* @param reasoning - Pi's thinking level (undefined = omit flag)
|
|
60
|
+
* @param modelId - Model ID for Opus detection
|
|
61
|
+
* @param thinkingBudgets - Custom budgets (logged as unsupported, not applied)
|
|
62
|
+
* @returns CLI effort level string, or undefined if flag should be omitted
|
|
63
|
+
*/
|
|
64
|
+
export function mapThinkingEffort(
|
|
65
|
+
reasoning?: ThinkingLevel,
|
|
66
|
+
modelId?: string,
|
|
67
|
+
thinkingBudgets?: ThinkingBudgets,
|
|
68
|
+
): CliEffortLevel | undefined {
|
|
69
|
+
if (reasoning === undefined) {
|
|
70
|
+
return undefined; // omit --effort flag entirely
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (thinkingBudgets && Object.keys(thinkingBudgets).length > 0) {
|
|
74
|
+
console.warn(
|
|
75
|
+
"[droid-cli] Custom thinkingBudgets are not supported with CLI subprocess. " +
|
|
76
|
+
"The CLI uses --effort levels instead of token budgets. Budgets will be ignored.",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const isOpus = modelId ? isOpusModel(modelId) : false;
|
|
81
|
+
const map = isOpus ? OPUS_EFFORT_MAP : STANDARD_EFFORT_MAP;
|
|
82
|
+
return map[reasoning];
|
|
83
|
+
}
|