From 8ced059b4da95a1d4931e991ded21d7597045f5f Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:03:18 +0200 Subject: [PATCH 01/70] fix(omp): fail the turn when OMP rejects a prompt before its agent runs (#6110) OMP reports a prompt it rejects before running the agent, such as one with no API key for the selected model, as a prompt_result with agentInvoked false, status error, and the error message. Paseo read only agentInvoked and ended the turn as completed, so the agent went idle with no reply and no error. The turn now fails with OMP's message. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../server/agent/providers/omp/agent.test.ts | 18 +++++++++ .../src/server/agent/providers/omp/agent.ts | 37 ++++++++++++++++--- .../server/agent/providers/omp/rpc-types.ts | 2 + .../providers/omp/test-utils/omp-harness.ts | 25 +++++++++++++ 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index f06bb62f57..11eaa63fb7 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -927,6 +927,24 @@ describe("OMP agent client and session", () => { expect(omp.completedTurnCount()).toBe(1); }); + test.each(["result after ack", "result before ack"] as const)( + "fails a prompt OMP rejects before its agent runs (%s)", + async (order) => { + const omp = new OmpHarness(); + await omp.start(); + + await expect( + omp.runPromptRejectedBeforeAgentRuns( + "Reply with ok.", + "No API key found for anthropic.", + order, + ), + ).rejects.toThrow("No API key found for anthropic."); + expect(omp.turnFailures()).toEqual(["No API key found for anthropic."]); + expect(omp.completedTurnCount()).toBe(0); + }, + ); + test("completes a no-turn notify with one notification and no assistant text", async () => { const scheduler = new ManualNoTurnScheduler(); const omp = new OmpHarness({ noTurnScheduler: scheduler }); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 4c97014fe8..b4e7b40bee 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -172,6 +172,21 @@ interface OmpPersistenceMetadata { bridgedTools: Map; } +/** OMP's outcome for one prompt, correlated by the prompt's request id. */ +interface OmpPromptResult { + agentInvoked: boolean; + /** Why OMP rejected the prompt before its agent ran, such as a missing API key. */ + error: string | null; +} + +function promptResultError(result: { + status?: string; + error?: { message: string }; +}): string | null { + if (result.status !== "error") return null; + return result.error?.message || "OMP rejected the prompt"; +} + interface StartTurnResult { turnId: string; } @@ -695,7 +710,8 @@ export class OmpAgentSession implements AgentSession { private readonly pendingNoTurnOutputs: Array<{ turnId: string; message: string }> = []; private activePromptRequestId: string | null = null; private activePromptAgentInvoked: boolean | null = null; - private readonly pendingPromptResults = new Map(); + private activePromptError: string | null = null; + private readonly pendingPromptResults = new Map(); private pendingNoTurnCompletionAbort: AbortController | null = null; private lastKnownThinkingOptionId: string | null; private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null; @@ -865,12 +881,13 @@ export class OmpAgentSession implements AgentSession { if (ack.requestId) { this.pendingPromptResults.delete(ack.requestId); } - this.activePromptAgentInvoked = correlatedResult ?? ack.agentInvoked ?? null; - if (correlatedResult === false) { + this.activePromptAgentInvoked = correlatedResult?.agentInvoked ?? ack.agentInvoked ?? null; + this.activePromptError = correlatedResult?.error ?? null; + if (correlatedResult?.agentInvoked === false) { this.scheduleNoTurnPromptCompletion(turnId); return; } - if (correlatedResult !== true && ack.agentInvoked === false) { + if (correlatedResult?.agentInvoked !== true && ack.agentInvoked === false) { await this.completeNoTurnPrompt(turnId); return; } @@ -1309,7 +1326,14 @@ export class OmpAgentSession implements AgentSession { ) { return; } + const error = this.activePromptError; this.emitBufferedNoTurnOutputs(turnId); + if (error) { + this.usagePoller.stopTurn(); + this.resetActiveTurn({ terminalizeWork: true }); + this.emit({ type: "turn_failed", provider: this.provider, turnId, error }); + return; + } this.completeTurn(turnId, []); } @@ -1318,6 +1342,7 @@ export class OmpAgentSession implements AgentSession { this.activeNoTurnPromptText = null; this.activePromptRequestId = null; this.activePromptAgentInvoked = null; + this.activePromptError = null; this.pendingNoTurnOutputs.splice(0, this.pendingNoTurnOutputs.length); } @@ -1715,15 +1740,17 @@ export class OmpAgentSession implements AgentSession { ? event.agentInvoked : undefined; if (requestId && agentInvoked !== undefined) { + const result = { agentInvoked, error: promptResultError(event) }; if (requestId === this.activePromptRequestId && this.activeTurnId) { this.activePromptAgentInvoked = agentInvoked; + this.activePromptError = result.error; if (agentInvoked === false) { this.scheduleNoTurnPromptCompletion(this.activeTurnId); } else { this.cancelNoTurnPromptCompletion(); } } else if (this.activePromptRequestId === null) { - this.pendingPromptResults.set(requestId, agentInvoked); + this.pendingPromptResults.set(requestId, result); } } return; diff --git a/packages/server/src/server/agent/providers/omp/rpc-types.ts b/packages/server/src/server/agent/providers/omp/rpc-types.ts index 4c69f2fbeb..42972b6d1e 100644 --- a/packages/server/src/server/agent/providers/omp/rpc-types.ts +++ b/packages/server/src/server/agent/providers/omp/rpc-types.ts @@ -480,6 +480,8 @@ export const OmpRuntimeEventSchema = z.discriminatedUnion("type", [ type: z.literal("prompt_result"), id: z.string().optional(), agentInvoked: z.boolean().optional(), + status: z.string().optional(), + error: z.object({ message: z.string() }).passthrough().optional(), }) .passthrough(), z.object({ type: z.literal("process_exit"), error: z.string() }).passthrough(), diff --git a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts index f7f0039257..233a10984e 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts @@ -329,6 +329,31 @@ export class OmpHarness { return { completed: () => isCompleted, completion }; } + async runPromptRejectedBeforeAgentRuns( + input: string, + error: string, + order: "result after ack" | "result before ack", + ): Promise { + const session = this.requireSession(); + const runtime = this.omp.latestSession(); + runtime.promptAck = { requestId: "prompt-rejected" }; + const rejection = { + type: "prompt_result", + id: "prompt-rejected", + agentInvoked: false, + status: "error", + sessionSettled: true, + error: { message: error, retryable: false }, + } as const; + const promptStarted = runtime.nextPrompt(); + const run = session.run(input); + if (order === "result before ack") runtime.emit(rejection); + await promptStarted; + await waitForImmediate(); + if (order === "result after ack") runtime.emit(rejection); + return await run; + } + async runPromptAfterCorrelatedTrueResult( input: string, output: string, From dfb8a1a2bca999993c382e012c4ff5b310d4daf2 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:15:10 +0200 Subject: [PATCH 02/70] fix(omp): an OMP run with a message Paseo does not render now finishes instead of staying running (#6112) * fix(omp): finish the turn when OMP's run contains a developer message OMP 18.5 injects hidden developer messages into a run, such as the rule-violation reminder after a tool call matches a built-in rule. The agent_end frame then failed schema validation and was dropped, so the turn stayed running until it was cancelled. Closes #6104 * fix(omp): parse every OMP message role so agent_end is never dropped OMP's RPC AgentMessage also defines pythonExecution, hookMessage, branchSummary, compactionSummary and fileMention. A prompt that @-mentions a file puts a fileMention message in agent_end, the frame failed validation and was dropped, and the turn stayed running. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../agent/providers/omp/cli-runtime.test.ts | 89 +++++++++++++++++++ .../server/agent/providers/omp/rpc-types.ts | 24 +++++ 2 files changed, 113 insertions(+) diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts index ebe99ead03..bca1396ca6 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts @@ -227,6 +227,95 @@ describe("OMP CLI runtime", () => { expect(eventTypes).toEqual(["notice"]); }); + test("emits agent_end for a run that contains an OMP developer message", async () => { + const child = createOmpChild(); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + const eventTypes: string[] = []; + session.onEvent((event) => eventTypes.push(event.type)); + + // OMP 18.5 injects a developer reminder mid-run when a tool call matches a rule. + const reminder = { + role: "developer", + content: [ + { type: "text", text: '…' }, + ], + attribution: "agent", + timestamp: 2, + }; + child.stdout.write(`${JSON.stringify({ type: "message_end", message: reminder })}\n`); + child.stdout.write( + `${JSON.stringify({ + type: "agent_end", + messages: [ + { role: "user", content: [{ type: "text", text: "Write tiny.ts" }], timestamp: 1 }, + reminder, + { + role: "assistant", + content: [{ type: "text", text: "Done" }], + stopReason: "stop", + timestamp: 3, + }, + ], + })}\n`, + ); + + expect(eventTypes).toEqual(["message_end", "agent_end"]); + }); + + test("emits agent_end for a run that contains every OMP message role Paseo does not render", async () => { + const child = createOmpChild(); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + const eventTypes: string[] = []; + session.onEvent((event) => eventTypes.push(event.type)); + + // A prompt that @-mentions a file makes OMP add a fileMention message to the run. + const fileMention = { + role: "fileMention", + files: [{ path: "notes.md", content: "# Notes\n", lineCount: 1 }], + timestamp: 2, + }; + child.stdout.write(`${JSON.stringify({ type: "message_end", message: fileMention })}\n`); + child.stdout.write( + `${JSON.stringify({ + type: "agent_end", + messages: [ + { role: "user", content: [{ type: "text", text: "Read @notes.md" }], timestamp: 1 }, + fileMention, + { + role: "pythonExecution", + code: "print(1)", + output: "1\n", + cancelled: false, + truncated: false, + timestamp: 3, + }, + { + role: "hookMessage", + customType: "hook", + content: "hook output", + display: false, + timestamp: 4, + }, + { role: "branchSummary", summary: "Earlier branch", fromId: "entry-1", timestamp: 5 }, + { + role: "compactionSummary", + summary: "Earlier turns", + tokensBefore: 1000, + timestamp: 6, + }, + { + role: "assistant", + content: [{ type: "text", text: "Done" }], + stopReason: "stop", + timestamp: 7, + }, + ], + })}\n`, + ); + + expect(eventTypes).toEqual(["message_end", "agent_end"]); + }); + test("lists commands through get_available_commands", async () => { const child = createOmpChild(); const commandTypes: string[] = []; diff --git a/packages/server/src/server/agent/providers/omp/rpc-types.ts b/packages/server/src/server/agent/providers/omp/rpc-types.ts index 42972b6d1e..4af44845b2 100644 --- a/packages/server/src/server/agent/providers/omp/rpc-types.ts +++ b/packages/server/src/server/agent/providers/omp/rpc-types.ts @@ -41,6 +41,13 @@ const OmpUserMessageSchema = z content: z.union([z.string(), z.array(z.union([OmpTextContentSchema, OmpImageContentSchema]))]), }) .passthrough(); +// OMP injects hidden developer messages, such as rule-violation reminders, into a run. +const OmpDeveloperMessageSchema = z + .object({ + role: z.literal("developer"), + content: z.union([z.string(), z.array(z.union([OmpTextContentSchema, OmpImageContentSchema]))]), + }) + .passthrough(); const OmpCustomMessageSchema = z .object({ role: z.literal("custom"), @@ -79,13 +86,30 @@ const OmpBashExecutionMessageSchema = z timestamp: z.number(), }) .passthrough(); +// Roles Paseo does not render. They are listed so that a frame carrying one, such as +// agent_end, still parses; every role in OMP's RPC AgentMessage must appear in the union. +const OmpPythonExecutionMessageSchema = z + .object({ role: z.literal("pythonExecution") }) + .passthrough(); +const OmpHookMessageSchema = z.object({ role: z.literal("hookMessage") }).passthrough(); +const OmpBranchSummaryMessageSchema = z.object({ role: z.literal("branchSummary") }).passthrough(); +const OmpCompactionSummaryMessageSchema = z + .object({ role: z.literal("compactionSummary") }) + .passthrough(); +const OmpFileMentionMessageSchema = z.object({ role: z.literal("fileMention") }).passthrough(); export const OmpAgentMessageSchema = z.discriminatedUnion("role", [ OmpUserMessageSchema, + OmpDeveloperMessageSchema, OmpCustomMessageSchema, OmpAssistantMessageSchema, OmpToolResultMessageSchema, OmpBashExecutionMessageSchema, + OmpPythonExecutionMessageSchema, + OmpHookMessageSchema, + OmpBranchSummaryMessageSchema, + OmpCompactionSummaryMessageSchema, + OmpFileMentionMessageSchema, ]); export const OmpModelThinkingSchema = z From 9da676d70944e754947536b3aa4e7eec0262dca7 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:57:59 +0200 Subject: [PATCH 03/70] Keep the daemon running when nobody reads its output (#6117) * fix(server): keep the daemon running when its stdout reader is gone `paseo daemon run` in the foreground hands its stdout and stderr to the supervisor. When the program that started it closed its end, as `paseo daemon run | head -1` does, the supervisor's next write failed with EPIPE and nothing handled the error. The supervisor exited 1, the worker lost its parent and exited too, and the CLI reported "Daemon failed to start". The supervisor now drops output nobody reads and keeps supervising the worker. daemon.log still records everything. Other stdout and stderr errors surface as before. Closes #6114 * test(server): fail on unexpected daemon.log read errors and check stderr reaches the log --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../server/scripts/supervisor.logging.test.ts | 42 ++++++++++++++++++- packages/server/scripts/supervisor.ts | 11 +++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/server/scripts/supervisor.logging.test.ts b/packages/server/scripts/supervisor.logging.test.ts index bdee5f6f7d..7589cb92a1 100644 --- a/packages/server/scripts/supervisor.logging.test.ts +++ b/packages/server/scripts/supervisor.logging.test.ts @@ -27,6 +27,8 @@ async function runSupervisorFixture(options: { fileSizeLimitBlocks?: number; /** Occupy the log path with a directory so the supervisor cannot open daemon.log. */ blockLogPath?: boolean; + /** Close the reading end of the supervisor's stdout and stderr as soon as it starts. */ + closeOutput?: boolean; }): Promise<{ code: number | null; signal: NodeJS.Signals | null; @@ -86,6 +88,11 @@ async function runSupervisorFixture(options: { stdio: ["ignore", "pipe", "pipe"], }); + if (options.closeOutput) { + child.stdout.destroy(); + child.stderr.destroy(); + } + let stdout = ""; let stderr = ""; child.stdout.setEncoding("utf8"); @@ -116,10 +123,19 @@ async function runSupervisorFixture(options: { }); }); - const log = await readFile(logPath, "utf8"); + const log = await readLogIfWritten(logPath); return { code, signal, elapsedMs: Date.now() - startedAt, log, stdout, stderr }; } +async function readLogIfWritten(logPath: string): Promise { + try { + return await readFile(logPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return ""; + throw error; + } +} + describe("supervisor durable logging", () => { test("resolves rotation defaults", () => { const paseoHome = path.join(path.sep, "tmp", "paseo-home"); @@ -189,6 +205,30 @@ describe("supervisor durable logging", () => { expect(result.stderr).toContain('"worker-json-stderr"'); }); + test("keeps supervising the worker when nobody reads its stdout or stderr", async () => { + const result = await runSupervisorFixture({ + closeOutput: true, + workerSource: ` + process.on("message", (message) => { + if (message?.type === "paseo:graceful-shutdown") process.exit(0); + }); + process.stdout.write("first stdout line\\n"); + process.stderr.write("first stderr line\\n"); + setTimeout(() => { + process.stdout.write("later stdout line\\n"); + process.send?.({ type: "paseo:shutdown", reason: "closed_output_probe" }); + }, 500); + setInterval(() => {}, 1000); + `, + }); + + expect(result.code).toBe(0); + expect(result.signal).toBeNull(); + expect(result.log).toContain("first stderr line\n"); + expect(result.log).toContain("later stdout line\n"); + expect(result.log).toContain('"reason":"closed_output_probe"'); + }); + test("preserves raw non-JSON stdout and stderr lines", async () => { const result = await runSupervisorFixture({ workerSource: ` diff --git a/packages/server/scripts/supervisor.ts b/packages/server/scripts/supervisor.ts index 1f8c1374c6..ec56822619 100644 --- a/packages/server/scripts/supervisor.ts +++ b/packages/server/scripts/supervisor.ts @@ -166,6 +166,15 @@ function createDurableLog( }; } +// The program that started the daemon can close its end of stdout or stderr, as +// `paseo daemon run | head -1` does. Drop the output nobody reads and keep supervising +// the worker; daemon.log still records it. +function ignoreClosedOutput(error: NodeJS.ErrnoException): void { + if (error.code !== "EPIPE") { + throw error; + } +} + export function runSupervisor(options: SupervisorOptions): SupervisorController { const restartOnCrash = options.restartOnCrash ?? false; const workerArgs = options.workerArgs ?? process.argv.slice(2); @@ -482,6 +491,8 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController process.on("SIGINT", () => forwardSignal("SIGINT")); process.on("SIGTERM", () => forwardSignal("SIGTERM")); + process.stdout.on("error", ignoreClosedOutput); + process.stderr.on("error", ignoreClosedOutput); process.stdout.write(`[${options.name}] ${options.startupMessage}\n`); writeLifecycleLog(options.startupMessage); From 62f79e88bb6130fb4215b00ad8581e63f58b489e Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:27:26 +0100 Subject: [PATCH 04/70] fix(omp): a prompt OMP 18.3 rejects after acknowledging it now fails with OMP's error (#6115) * fix(omp): fail the turn when OMP 18.3 rejects a prompt after acknowledging it OMP 18.3 acknowledges a prompt, then reports a rejection such as a missing API key only as a second failed response frame for the same request id. The JSONL RPC transport dropped any response with no pending request, so the turn stayed running with no error. The transport now publishes a response nothing is waiting for like any other frame, and the OMP runtime reads a failed prompt response as the prompt_result error later OMP releases send. * fix(omp): drop prompt results held for an OMP process that was replaced Request ids restart with each OMP process, so a rejection the old process reported after its prompt timed out could fail a prompt on the new process with the same id. Move the real-OMP rejection case into the existing OMP real E2E suite. * test(omp): verify late-response routing at the transport boundary --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Mohamed Boudra --- .../agent/providers/jsonl-rpc-process.test.ts | 22 ++++++ .../agent/providers/jsonl-rpc-process.ts | 24 ++++--- .../server/agent/providers/omp/agent.test.ts | 19 +++++ .../src/server/agent/providers/omp/agent.ts | 3 + .../agent/providers/omp/cli-runtime.test.ts | 25 +++++++ .../server/agent/providers/omp/cli-runtime.ts | 8 +-- .../server/agent/providers/omp/rpc-types.ts | 30 ++++++++ .../providers/omp/test-utils/omp-harness.ts | 8 +++ .../server/daemon-e2e/omp.real.e2e.test.ts | 69 ++++++++++++++++++- 9 files changed, 190 insertions(+), 18 deletions(-) diff --git a/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts b/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts index 62b6c9ead9..7dfbb6abb8 100644 --- a/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts +++ b/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts @@ -154,6 +154,28 @@ describe("JsonlRpcProcess", () => { } }); + test("publishes unmatched responses without publishing the response that settles a request", async () => { + const child = createInMemoryChildProcess(); + const transport = startProcess({ child }); + const messages: Record[] = []; + transport.onMessage((message) => messages.push(message)); + + try { + const { id, promise } = transport.startRequest({ type: "prompt" }); + const response = { id, type: "response", command: "prompt" }; + const rejection = { ...response, success: false, error: "rejected after acknowledgement" }; + const unknownResponse = { ...response, id: "unknown-request", success: true }; + child.stdout.write(`${JSON.stringify({ ...response, success: true })}\n`); + child.stdout.write(`${JSON.stringify(rejection)}\n`); + child.stdout.write(`${JSON.stringify(unknownResponse)}\n`); + + await expect(promise).resolves.toBeUndefined(); + expect(messages).toEqual([rejection, unknownResponse]); + } finally { + await transport.close(); + } + }); + test("rejects unsuccessful responses", async () => { const transport = startProcess(); diff --git a/packages/server/src/server/agent/providers/jsonl-rpc-process.ts b/packages/server/src/server/agent/providers/jsonl-rpc-process.ts index d58e2235ca..9d8b9af623 100644 --- a/packages/server/src/server/agent/providers/jsonl-rpc-process.ts +++ b/packages/server/src/server/agent/providers/jsonl-rpc-process.ts @@ -258,25 +258,27 @@ export class JsonlRpcProcess { this.frameDecoder.write(chunk); } - /** Route a complete logical frame to its consumer (response or subscriber). */ + /** Route a complete logical frame to its consumer (pending request or subscriber). */ private dispatchFrame(message: Record): void { - if (message.type === "response") { - this.handleResponse(message as unknown as JsonlRpcResponse); + const pending = + message.type === "response" && typeof message.id === "string" + ? this.pending.get(message.id) + : undefined; + if (pending) { + this.settleRequest(pending, message as unknown as JsonlRpcResponse & { id: string }); return; } + // A response nothing is waiting for is published like any other frame, so a provider + // can read it as an event, such as OMP 18.3 rejecting a prompt after acknowledging it. for (const subscriber of this.messageSubscribers) { subscriber(message); } } - private handleResponse(response: JsonlRpcResponse): void { - if (!response.id) { - return; - } - const pending = this.pending.get(response.id); - if (!pending) { - return; - } + private settleRequest( + pending: PendingRequest, + response: JsonlRpcResponse & { id: string }, + ): void { if (pending.timer) { clearTimeout(pending.timer); } diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 11eaa63fb7..537c94f2a6 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -1236,6 +1236,25 @@ describe("OMP agent client and session", () => { expect(omp.runtime().prompts).toEqual([{ message: "continue", imageCount: 0 }]); }); + test("ignores a prompt rejection a previous OMP process left for a reused request id", async () => { + const omp = new OmpHarness(); + await omp.start(); + omp.emit({ + type: "prompt_result", + id: "req_1", + agentInvoked: false, + status: "error", + error: { message: "No API key found for anthropic." }, + }); + omp.processExit("OMP RPC process exited with code 1 and signal null"); + + await omp.runPromptWithoutTurnOnNextRuntime("/session", "req_1"); + + expect(omp.runtimeLaunches()).toHaveLength(2); + expect(omp.turnFailures()).toEqual(["OMP RPC process exited with code 1 and signal null"]); + expect(omp.completedTurnCount()).toBe(1); + }); + test("reports an immediate relaunch failure without retrying in a loop", async () => { const omp = new OmpHarness(); await omp.start(); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index b4e7b40bee..2dc6c58dea 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -1045,6 +1045,9 @@ export class OmpAgentSession implements AgentSession { const state = await next.getState(); if (this.closed) throw new Error("OMP session is closed"); this.unsubscribeRuntime?.(); + // Request ids restart with each OMP process, so results held for the old one cannot + // correlate with prompts sent to the new one. + this.pendingPromptResults.clear(); this.runtimeSession = next; this.hostTools = restarted.hostTools; this.state = state; diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts index bca1396ca6..2deae45850 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts @@ -441,6 +441,31 @@ describe("OMP CLI runtime", () => { await expect(session.prompt("hello")).resolves.toEqual({ requestId: "req_1" }); }); + test("reports OMP 18.3's late prompt rejection as a failed prompt result", async () => { + const child = createOmpChild(); + onOmpCommand(child, (command) => { + if (command.type !== "prompt") return; + const response = { id: command.id, type: "response", command: "prompt" }; + const rejection = { ...response, success: false, error: "No API key found for anthropic." }; + child.stdout.write( + `${JSON.stringify({ ...response, success: true })}\n${JSON.stringify(rejection)}\n`, + ); + }); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + const events: unknown[] = []; + session.onEvent((event) => events.push(event)); + + await expect(session.prompt("hello")).resolves.toEqual({ requestId: "req_1" }); + + expect(events).toContainEqual({ + type: "prompt_result", + id: "req_1", + agentInvoked: false, + status: "error", + error: { message: "No API key found for anthropic." }, + }); + }); + test("negotiates RPC protocol v2 when OMP advertises it", async () => { const child = createOmpChild({ supportedProtocolVersions: [1, 2] }); const commands: Record[] = []; diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.ts index 51d340ab2c..75bd79f5f0 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.ts @@ -27,7 +27,7 @@ import { OmpModelsResultSchema, OmpPromptAckSchema, OmpRpcCommandSchema, - OmpRuntimeEventSchema, + parseOmpRuntimeEvent, OmpSessionStateSchema, OmpSessionStatsSchema, type OmpThinkingLevel, @@ -121,9 +121,9 @@ class OmpCliRuntimeSession implements OmpRuntimeSession { private readonly launchEnvironment: Record, ) { process.onMessage((message) => { - const event = OmpRuntimeEventSchema.safeParse(message); - if (event.success) { - this.emit(event.data); + const event = parseOmpRuntimeEvent(message); + if (event) { + this.emit(event); } }); process.onExit(({ error }) => { diff --git a/packages/server/src/server/agent/providers/omp/rpc-types.ts b/packages/server/src/server/agent/providers/omp/rpc-types.ts index 4af44845b2..b0514c7863 100644 --- a/packages/server/src/server/agent/providers/omp/rpc-types.ts +++ b/packages/server/src/server/agent/providers/omp/rpc-types.ts @@ -635,6 +635,36 @@ export type OmpSubagentEventPayload = z.infer; export type OmpAgentSessionEvent = z.infer; export type OmpRuntimeEvent = z.infer; + +/** + * COMPAT(ompLatePromptRejection): added in v0.11.0, remove after 2027-04-05 once the + * minimum supported OMP version emits prompt_result errors. + * OMP 18.3 reports a prompt it rejects after acknowledging it, such as one whose model has + * no API key, only as a second failed `response` frame for the prompt id. Later releases + * report the same outcome as a `prompt_result` with `status: "error"`. + */ +const OmpLatePromptRejectionSchema = z.object({ + type: z.literal("response"), + command: z.literal("prompt"), + id: z.string(), + success: z.literal(false), + error: z.string(), +}); + +export function parseOmpRuntimeEvent(message: unknown): OmpRuntimeEvent | null { + const rejection = OmpLatePromptRejectionSchema.safeParse(message); + if (rejection.success) { + return { + type: "prompt_result", + id: rejection.data.id, + agentInvoked: false, + status: "error", + error: { message: rejection.data.error }, + }; + } + const event = OmpRuntimeEventSchema.safeParse(message); + return event.success ? event.data : null; +} export type OmpTodoItem = z.infer; export type OmpTodoPhase = z.infer; export type OmpTodoReminderEvent = z.infer; diff --git a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts index 233a10984e..1820c187c9 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts @@ -294,6 +294,14 @@ export class OmpHarness { return await run; } + async runPromptWithoutTurnOnNextRuntime(input: string, requestId: string): Promise { + const session = this.requireSession(); + this.omp.queueSessionSetup((runtime) => { + runtime.promptAck = { requestId, agentInvoked: false }; + }); + return await session.run(input); + } + async runPromptWithoutTurn(input: string): Promise { const session = this.requireSession(); this.omp.latestSession().promptAck = { agentInvoked: false }; diff --git a/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts index f336f325bf..510e8b384d 100644 --- a/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -10,6 +10,7 @@ import { afterEach, beforeAll, describe, expect, test } from "vitest"; import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; import { MIN_SUPPORTED_OMP_VERSION, + OmpAgentClient, formatOmpVersionSupport, } from "../agent/providers/omp/agent.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; @@ -48,14 +49,53 @@ async function preflight(): Promise { } } -async function createHarness(): Promise { +// OMP exits at startup when no model is usable at all, so the home offers one offline +// model, and an agent on any other provider's model has no credentials. +function createOfflineOmpHome(): string { + const home = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-offline-")); + roots.add(home); + const agentDir = path.join(home, ".omp", "agent"); + mkdirSync(agentDir, { recursive: true }); + writeFileSync( + path.join(agentDir, "models.yml"), + [ + "providers:", + " offline:", + " baseUrl: http://127.0.0.1:9/v1", + " api: openai-completions", + " auth: none", + " models:", + " - id: offline-1", + " name: Offline", + " api: openai-completions", + " reasoning: false", + " input: [text]", + " contextWindow: 128000", + " maxTokens: 4096", + " cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0}", + "", + ].join("\n"), + ); + return home; +} + +async function createHarness(options: { ompHome?: string } = {}): Promise { const cwd = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-")); const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-home-")); const staticDir = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-static-")); for (const root of [cwd, paseoHomeRoot, staticDir]) roots.add(root); const logger = pino({ level: process.env.OMP_E2E_LOG_LEVEL ?? "silent" }); const daemon = await createTestPaseoDaemon({ - agentClients: createRealProviderClients(["omp"], logger), + agentClients: options.ompHome + ? { + omp: new OmpAgentClient({ + logger, + runtimeSettings: { + env: { HOME: options.ompHome, ANTHROPIC_API_KEY: "", ANTHROPIC_OAUTH_TOKEN: "" }, + }, + }), + } + : createRealProviderClients(["omp"], logger), providerOverrides: { omp: { enabled: true } }, logger, paseoHomeRoot, @@ -186,6 +226,29 @@ afterEach(() => { }); describe("daemon E2E (real OMP)", () => { + test( + "a prompt OMP rejects before its agent runs fails the turn with OMP's error", + async () => { + const harness = await createHarness({ ompHome: createOfflineOmpHome() }); + try { + const agent = await harness.client.createAgent({ + cwd: harness.cwd, + title: "no-credentials", + provider: "omp", + model: "anthropic/claude-haiku-4-5", + modeId: "full", + }); + await harness.client.sendMessage(agent.id, "Reply with ok."); + const finish = await harness.client.waitForFinish(agent.id, 60_000); + expect(finish.status).toBe("error"); + expect(finish.error).toContain("No API key found for anthropic"); + } finally { + await closeHarness(harness); + } + }, + TIMEOUT_MS, + ); + test( "prompt, native tool, and resumed follow-up", async () => { From d826d8109f2a37c50d2ff49d2cba39ba488f1962 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:12:12 +0100 Subject: [PATCH 05/70] fix(muse): load models whose effort tiers carry descriptions (#6021) * fix(muse): load models whose effort tiers carry descriptions Muse 1.4.2 sends reasoningEffortVariants as described tiers ({ tier, description? }) and the complete effort list on variants, as the bundled MSP types declare. The catalog schema had the two shapes swapped, so any model with a described tier failed the whole model/list parse: the model picker stayed empty and agent creation failed. Parse both fields as declared, offer the efforts from variants, and show Muse's tier descriptions on the matching options. * fix(muse): leave effort descriptions out until the pickers show them * fix(muse): keep unused catalog metadata outside validation Described effort tiers are presentation metadata, not selectable efforts. Validating their shape blocks both discovery and session startup even though Paseo never consumes them. Normalize the complete variants list at the wire boundary and cover session readiness and selecting max. * fix(muse): align consumed session and event wire fields --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Mohamed Boudra --- plugins/muse-provider/server/catalog.ts | 5 +- plugins/muse-provider/server/connection.ts | 6 + plugins/muse-provider/server/session.ts | 28 +-- plugins/muse-provider/server/sessions.ts | 5 +- plugins/muse-provider/server/wire.ts | 22 ++- plugins/muse-provider/test/fake-muse.mjs | 25 +++ plugins/muse-provider/test/provider.test.ts | 204 +++++++++++++++++++- 7 files changed, 265 insertions(+), 30 deletions(-) diff --git a/plugins/muse-provider/server/catalog.ts b/plugins/muse-provider/server/catalog.ts index d4e483ed79..5e603a9eda 100644 --- a/plugins/muse-provider/server/catalog.ts +++ b/plugins/muse-provider/server/catalog.ts @@ -63,10 +63,7 @@ export class Catalog { export function presentCatalog(response: z.infer): ProviderCatalog { const models = response.models.map((model) => { - let supported: z.infer[] = efforts; - if (Array.isArray(model.reasoningEffortVariants) && model.reasoningEffortVariants.length > 0) - supported = model.reasoningEffortVariants; - else if (model.variants.length > 0) supported = model.variants; + const supported = model.variants.length > 0 ? model.variants : efforts; return { id: model.modelId, label: model.displayLabel, diff --git a/plugins/muse-provider/server/connection.ts b/plugins/muse-provider/server/connection.ts index 100bd101c7..f502705f8f 100644 --- a/plugins/muse-provider/server/connection.ts +++ b/plugins/muse-provider/server/connection.ts @@ -170,6 +170,12 @@ export class MspConnection { return; } if (frame.id === undefined) throw new MuseError("invalidFrame", "MSP response has no id"); + if (frame.id === null) { + if (!frame.error) throw new MuseError("invalidFrame", "MSP result has a null id"); + this.fail(new MuseError(frame.error.data?.kind ?? "rpc", frame.error.message)); + this.child.kill(); + return; + } const pending = this.pending.get(frame.id); if (!pending) return; clearTimeout(pending.timer); diff --git a/plugins/muse-provider/server/session.ts b/plugins/muse-provider/server/session.ts index b152361060..7d367fd1ac 100644 --- a/plugins/muse-provider/server/session.ts +++ b/plugins/muse-provider/server/session.ts @@ -66,7 +66,7 @@ interface Live { export class Session { private readonly host: MspConnection; private liveState: Live | undefined; - private providerId = "meta"; + private providerId: string | undefined; private cursor: string | undefined; private config: ProviderSessionConfig; private catalog: ProviderCatalog = { models: [], modes }; @@ -167,11 +167,11 @@ export class Session { }, ), }; - this.providerId = response.session.providerId; + this.providerId = response.session.providerId ?? undefined; this.cursor = response.viewCursor; this.config = { ...this.config, - model: response.session.modelId, + model: response.session.modelId ?? this.config.model, mode: response.session.approvalMode?.mode ?? this.config.mode ?? "onRequest", }; this.options.emit({ @@ -194,14 +194,7 @@ export class Session { for (const notification of this.buffered) this.enqueue(notification); this.buffered.length = 0; await this.notifications; - this.catalog = presentCatalog( - await this.host.request("model/list", { sessionId: this.live().nativeId }, catalogSchema), - ); - const selected = this.catalog.models.find((model) => model.id === this.config.model); - this.config = { - ...this.config, - thinkingOption: this.config.thinkingOption ?? selected?.defaultThinkingOptionId, - }; + await this.readCatalog(); await this.live().commands.refresh(); this.publishPersistence(); this.publishConfig(); @@ -229,6 +222,19 @@ export class Session { for (const approval of pending.approvals) await this.permission(approval); for (const question of pending.userInputs) this.live().questions.requested(question); } + private async readCatalog(): Promise { + this.catalog = presentCatalog( + await this.host.request("model/list", { sessionId: this.live().nativeId }, catalogSchema), + ); + const model = this.config.model ?? this.catalog.defaultModel; + const selected = this.catalog.models.find((entry) => entry.id === model); + this.config = { + ...this.config, + model, + thinkingOption: this.config.thinkingOption ?? selected?.defaultThinkingOptionId, + }; + } + async prompt(prompt: ProviderPrompt): Promise { if (await this.live().commands.compact(prompt)) return; const id = commandId(); diff --git a/plugins/muse-provider/server/sessions.ts b/plugins/muse-provider/server/sessions.ts index 93a22cd54b..a10879e248 100644 --- a/plugins/muse-provider/server/sessions.ts +++ b/plugins/muse-provider/server/sessions.ts @@ -38,7 +38,10 @@ export class Sessions { updatedAt: session.updatedAt, persistence: { version: 1, - data: { sessionId: session.sessionId, model: session.modelId }, + data: { + sessionId: session.sessionId, + ...(session.modelId === null ? {} : { model: session.modelId }), + }, }, }); if (input.limit && summaries.length >= input.limit) return summaries; diff --git a/plugins/muse-provider/server/wire.ts b/plugins/muse-provider/server/wire.ts index 4955acdaa5..3513cabfd1 100644 --- a/plugins/muse-provider/server/wire.ts +++ b/plugins/muse-provider/server/wire.ts @@ -87,7 +87,7 @@ export const notificationSchema = z.object({ }); export const frameSchema = z.object({ jsonrpc: z.literal("2.0"), - id: z.union([z.string(), z.number()]).optional(), + id: z.union([z.string(), z.number()]).nullable().optional(), method: z.string().optional(), params: z.unknown().optional(), result: z.unknown().optional(), @@ -115,16 +115,20 @@ export const catalogSchema = z.object({ contextLimit: z.number().nullable(), isDefault: z.boolean(), defaultReasoningEffort: effortSchema.nullable().optional(), - reasoningEffortVariants: z.union([z.array(effortSchema), z.literal("unknown")]).default([]), - variants: z.array(effortSchema).default([]), + // Validate consumed fields only. Muse's described subset (reasoningEffortVariants) + // is presentation metadata; its shape must not gate catalog discovery or session startup. + variants: z + .union([z.array(effortSchema), z.literal("unknown")]) + .transform((variants) => (variants === "unknown" ? [] : variants)) + .default([]), }), ), }); export const sessionSchema = z.object({ session: z.object({ sessionId: z.string(), - modelId: z.string(), - providerId: z.string(), + modelId: z.string().nullable(), + providerId: z.string().nullable(), approvalMode: z.object({ mode: approvalModeSchema }).optional(), }), viewCursor: z.string(), @@ -143,7 +147,11 @@ export const persistenceSchema = z.object({ model: z.string().optional(), thinkingOption: effortSchema.optional(), }); -export const deltaSchema = z.object({ itemId: z.string(), field: z.string(), delta: z.string() }); +export const deltaSchema = z.object({ + itemId: z.string(), + field: z.string().default("text"), + delta: z.string(), +}); export const turnSchema = z.object({ turnId: z.string(), commandId: z.string().optional(), @@ -241,7 +249,7 @@ export const sessionListSchema = z.object({ workspaceRoot: z.string().nullable(), title: z.string().optional(), updatedAt: z.string(), - modelId: z.string(), + modelId: z.string().nullable(), }), ), }); diff --git a/plugins/muse-provider/test/fake-muse.mjs b/plugins/muse-provider/test/fake-muse.mjs index fffca0cc89..65e1b88fcf 100644 --- a/plugins/muse-provider/test/fake-muse.mjs +++ b/plugins/muse-provider/test/fake-muse.mjs @@ -66,6 +66,14 @@ function controlResponse(frame) { process.stderr.write("diagnostic tail, not protocol\n"); process.exit(Number(process.env.MUSE_TEST_EXIT)); } + if (frame.method === "initialize" && process.env.MUSE_TEST_NULL_ERROR_ID) { + send({ + jsonrpc: "2.0", + id: null, + error: { code: -32700, message: "Cannot recover request id", data: { kind: "parseError" } }, + }); + return true; + } if (process.env.MUSE_TEST_HANG === frame.method) return true; if (frame.method === "session/setReasoningEffort") { respond(frame, { status: "accepted", commandId: frame.params.commandId }); @@ -138,6 +146,9 @@ function parityResponse(frame) { if (frame.method === "session/list") { const result = responseFor(catalogRows, "session/list"); result.nextCursor = null; + if (process.env.MUSE_TEST_NULL_MODEL) { + for (const session of result.sessions) session.modelId = null; + } respond(frame, result); return true; } @@ -262,6 +273,7 @@ function emitFixtureMessage(recordedMessage, recorded, frame) { return; if (frame.method === "session/start" && message.result?.session?.approvalMode) message.result.session.approvalMode.mode = frame.params.approvalMode; + applyWireVariants(message); if (message.result?.viewCursor && process.env.MUSE_TEST_EMPTY_CURSOR) message.result.viewCursor = ""; applyTestVariants(message); @@ -282,6 +294,19 @@ function emitFixtureMessage(recordedMessage, recorded, frame) { }); } } +function applyWireVariants(message) { + if ( + message.method === "item/delta" && + message.params?.field === "text" && + process.env.MUSE_TEST_IMPLICIT_TEXT_DELTA + ) { + delete message.params.field; + } + if (message.result?.session && process.env.MUSE_TEST_NULL_MODEL) { + message.result.session.modelId = null; + message.result.session.providerId = null; + } +} function parityDelivery(message) { if (process.env.MUSE_TEST_WORKFLOW && message.method === "turn/completed") send({ diff --git a/plugins/muse-provider/test/provider.test.ts b/plugins/muse-provider/test/provider.test.ts index ffb7c0468e..d19415d544 100644 --- a/plugins/muse-provider/test/provider.test.ts +++ b/plugins/muse-provider/test/provider.test.ts @@ -84,6 +84,7 @@ async function harness( async function open( persistence?: Extract["persistence"], mode = "onRequest", + model: string | null = "meta/muse-spark-1.3", ) { await send({ type: "session.open", @@ -97,7 +98,7 @@ async function harness( mcpServers: {}, persist: true, mode, - model: "meta/muse-spark-1.3", + model: model ?? undefined, thinkingOption: "high", systemPrompt: "Test instructions", }, @@ -610,12 +611,15 @@ test("ordinary subagent tool calls render sub_agent details without inventing ch }), ); }); -for (const [variants, reasoningEffortVariants, expected] of [ - [["low", "high"], ["medium"], ["medium"]], - [["low", "high"], "unknown", ["low", "high"]], - [[], [], ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], +for (const [variants, expected] of [ + [ + ["low", "high"], + ["low", "high"], + ], + ["unknown", ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], + [[], ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], ] as const) { - test(`catalogue effort discovery ${JSON.stringify(reasoningEffortVariants)} with variants ${JSON.stringify(variants)}`, async () => { + test(`catalogue effort discovery with variants ${JSON.stringify(variants)}`, async () => { const model = { modelId: "test-model", providerId: "meta", @@ -623,7 +627,6 @@ for (const [variants, reasoningEffortVariants, expected] of [ contextLimit: 123456, isDefault: true, variants, - reasoningEffortVariants, defaultReasoningEffort: "high", }; const h = await harness("catalog-controls", { MUSE_TEST_MODELS: JSON.stringify([model]) }); @@ -639,6 +642,153 @@ for (const [variants, reasoningEffortVariants, expected] of [ }); }); } +test("catalogue loads models whose effort tiers carry descriptions", async () => { + const deepest = "Use this for deepest analysis and complex fixes."; + const row = (modelId: string, isDefault: boolean) => ({ + modelId, + providerId: "meta", + displayLabel: modelId, + contextLimit: 1000000, + isDefault, + defaultReasoningEffort: "max", + variants: ["minimal", "low", "medium", "high", "xhigh", "max"], + reasoningEffortVariants: isDefault ? [] : [{ tier: "xhigh", description: deepest }], + }); + const models = [ + row("muse-spark-1.3", true), + row("muse-spark-1.3-contributor", false), + row("muse-spark-1.2", false), + row("muse-spark-1.2-contributor", false), + ]; + const h = await harness("catalog-controls", { MUSE_TEST_MODELS: JSON.stringify(models) }); + await h.send({ type: "catalog", requestId: "models" }); + const result = await h.wait( + (event) => event.type === "catalog" || event.type === "request.failed", + ); + const offered = [ + { id: "minimal" }, + { id: "low" }, + { id: "medium" }, + { id: "high" }, + { id: "xhigh" }, + { id: "max", isDefault: true }, + ]; + expect(result).toMatchObject({ + type: "catalog", + catalog: { + defaultModel: "muse-spark-1.3", + models: [ + { id: "muse-spark-1.3", defaultThinkingOptionId: "max" }, + { id: "muse-spark-1.3-contributor", thinkingOptions: offered }, + { id: "muse-spark-1.2", thinkingOptions: offered }, + { id: "muse-spark-1.2-contributor", thinkingOptions: offered }, + ], + }, + }); +}); +for (const [configuredModel, catalogModels, expectedModel] of [ + ["configured-model", [], "configured-model"], + [ + "", + [ + { + modelId: "catalog-model", + providerId: "meta", + displayLabel: "Catalog model", + contextLimit: null, + isDefault: true, + variants: ["high"], + }, + ], + "catalog-model", + ], + ["", [], undefined], +] as const) { + test(`null session model preserves ${expectedModel ?? "unset model"}`, async () => { + const h = await harness("text-reasoning", { + MUSE_TEST_NULL_MODEL: "1", + MUSE_TEST_MODELS: JSON.stringify(catalogModels), + }); + expect(await h.open(undefined, "onRequest", configuredModel || null)).toEqual({ + type: "session.ready", + requestId: "open", + sessionId: "paseo-session", + }); + const config = await h.wait((event) => event.type === "session.config"); + if (config.type !== "session.config") throw new Error("Expected session config"); + expect(config.config.model).toBe(expectedModel); + const saved = h.events.findLast((event) => event.type === "session.persistence"); + if (!saved) throw new Error("Expected persistence"); + expect(saved.persistence.data.model).toBe(expectedModel); + await h.prompt(); + await h.wait((event) => event.type === "session.turn" && event.state === "completed"); + }); +} +test("session startup accepts described catalog efforts and publishes the complete list", async () => { + const model = { + modelId: "meta/muse-spark-1.3", + providerId: "meta", + displayLabel: "Muse Spark", + contextLimit: null, + isDefault: true, + defaultReasoningEffort: "max", + variants: ["minimal", "low", "medium", "high", "xhigh", "max"], + reasoningEffortVariants: [{ tier: "xhigh", description: "Deepest analysis" }], + }; + const h = await harness("text-reasoning", { MUSE_TEST_MODELS: JSON.stringify([model]) }); + expect(await h.open()).toEqual({ + type: "session.ready", + requestId: "open", + sessionId: "paseo-session", + }); + expect(await h.wait((event) => event.type === "session.config")).toMatchObject({ + config: { + models: [{ id: model.modelId, defaultThinkingOptionId: "max" }], + thinkingOptions: model.variants.map((id) => ({ id, isDefault: id === "max" })), + }, + }); + await h.send({ + type: "session.configure", + requestId: "choose-max", + sessionId: "paseo-session", + changes: { thinkingOption: "max" }, + }); + await h.wait((event) => event.type === "request.completed" && event.requestId === "choose-max"); + await h.prompt(); + await h.wait((event) => event.type === "session.turn" && event.state === "completed"); + expect( + (await h.recorded()).find((frame) => frame.method === "turn/start").params.reasoningEffort, + ).toBe("max"); +}); +for (const metadata of [ + "unknown", + [{ tier: "future-tier", description: { text: "Future metadata" } }], +]) { + test(`catalog ignores unused effort descriptions ${JSON.stringify(metadata)}`, async () => { + const model = { + modelId: "test-model", + providerId: "meta", + displayLabel: "Muse Spark", + contextLimit: null, + isDefault: true, + defaultReasoningEffort: "high", + variants: ["low", "high"], + reasoningEffortVariants: metadata, + }; + const h = await harness("catalog-controls", { MUSE_TEST_MODELS: JSON.stringify([model]) }); + await h.send({ type: "catalog", requestId: "models" }); + expect( + await h.wait((event) => event.type === "catalog" || event.type === "request.failed"), + ).toMatchObject({ + type: "catalog", + catalog: { + models: [ + { id: model.modelId, thinkingOptions: [{ id: "low" }, { id: "high", isDefault: true }] }, + ], + }, + }); + }); +} test("status accepts future credential states and drains its maintenance host", async () => { const h = await harness("catalog-controls", { MUSE_TEST_ACCOUNT: "futureCredential" }); expect(await h.provider.status!({ launch: h.launch })).toEqual({ available: true }); @@ -1073,6 +1223,46 @@ test("resuming a session reapplies its provider options to the new host", async ]); expect(frames.some((f) => f.method === "session/resume")).toBe(true); }); +test("an uncorrelated MSP parse error keeps the provider diagnostic", async () => { + const h = await harness("text-reasoning", { MUSE_TEST_NULL_ERROR_ID: "1" }); + expect(await h.open()).toMatchObject({ + type: "request.failed", + error: { code: "parseError", message: "Cannot recover request id" }, + }); +}); +test("text deltas with an omitted field stream as text", async () => { + const h = await harness("text-reasoning", { MUSE_TEST_IMPLICIT_TEXT_DELTA: "1" }); + await h.open(); + await h.wait((e) => e.type === "session.ready"); + await h.prompt(); + await h.wait((e) => e.type === "session.turn" && e.state === "completed"); + expect( + h.events.some( + (e) => + e.type === "timeline.item" && + e.item.type === "assistant_message" && + e.item.text === "Sum with", + ), + ).toBe(true); +}); +test("sessions with a null model import and resume without a null persistence model", async () => { + const h = await harness("resume-without-cursor", { MUSE_TEST_NULL_MODEL: "1" }); + await h.send({ + type: "sessions", + requestId: "null-list", + cwd: "/tmp/muse-phase0/repo", + limit: 2, + }); + const listed = await h.wait((e) => e.type === "sessions"); + if (listed.type !== "sessions") throw new Error("Expected sessions"); + expect(listed.sessions).toHaveLength(2); + expect(listed.sessions[0]!.persistence.data.model).toBeUndefined(); + await h.open(listed.sessions[0]!.persistence); + await h.wait((e) => e.type === "session.ready"); + expect( + h.events.some((e) => e.type === "session.config" && e.config.model === "meta/muse-spark-1.3"), + ).toBe(true); +}); test("sessions list filters workspace and imported persistence opens via resume", async () => { const h = await harness("resume-without-cursor"); await h.send({ From 9760292650216e4cb93fa787085251029c882130 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 5 Oct 2026 17:10:02 +0100 Subject: [PATCH 06/70] fix(providers): launch plugin CLIs on Windows and tolerate Muse startup (#6151) * fix(providers): launch plugin CLIs on Windows and tolerate Muse startup Plugin providers bypassed the core Windows launcher and the plugin host exposed an empty server SDK module. Share the launcher through the SDK and bind it in the host. Give Muse initialization its own deadline because native startup traverses session history before answering MSP. * fix(providers): terminate Windows launcher process trees A command-script launcher leaves the CLI behind cmd.exe. Expose forced process-tree termination in the server SDK, use it for Muse and SDK ACP teardown, and bound Muse closing so inherited streams cannot leave callers waiting forever. * test(muse): keep platform setup outside the teardown assertion * refactor(plugin): expose process capabilities without quoting helpers * fix(providers): close process trees on uncorrelated protocol errors --- .oxlintrc.json | 39 ++++++ docs/plugins.md | 2 +- docs/providers.md | 4 +- packages/plugin/src/boundaries.test.ts | 5 + .../src/server/acp-internal/connection.ts | 11 +- packages/plugin/src/server/index.ts | 2 + .../process-internal/windows-command.test.ts | 117 +++++++++++++++++ .../process-internal}/windows-command.ts | 0 packages/plugin/src/server/process.test.ts | 53 ++++++++ packages/plugin/src/server/process.ts | 119 +++++++++++++++++ .../executable-resolution.test.ts | 122 +----------------- .../executable-resolution.ts | 4 - .../src/server/plugins/bundle-evaluator.ts | 3 +- .../src/server/plugins/plugin-process.test.ts | 11 ++ packages/server/src/utils/spawn.ts | 51 +------- .../server/internal/process.ts | 7 +- .../server/internal/signals.ts | 7 +- .../server/provider.test.ts | 18 ++- plugins/muse-provider/server/connection.ts | 51 +++++--- plugins/muse-provider/server/provider.ts | 25 ++-- plugins/muse-provider/test/fake-muse.mjs | 19 +++ plugins/muse-provider/test/provider.test.ts | 108 ++++++++++++++++ public-docs/plugins/providers.md | 26 ++++ 23 files changed, 589 insertions(+), 215 deletions(-) create mode 100644 packages/plugin/src/server/process-internal/windows-command.test.ts rename packages/{server/src/utils => plugin/src/server/process-internal}/windows-command.ts (100%) create mode 100644 packages/plugin/src/server/process.test.ts create mode 100644 packages/plugin/src/server/process.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 269097aab4..d1566195e9 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -84,6 +84,45 @@ "max-nested-callbacks": ["error", { "max": 3 }] }, "overrides": [ + { + "files": [ + "plugins/*-provider/server/**/*.ts", + "packages/plugin/src/server/acp-internal/**/*.ts" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "node:child_process", + "importNames": [ + "spawn", + "exec", + "execFile", + "spawnSync", + "execSync", + "execFileSync" + ], + "message": "Use the plugin server process helpers so Windows command scripts and argv are handled consistently." + }, + { + "name": "child_process", + "importNames": [ + "spawn", + "exec", + "execFile", + "spawnSync", + "execSync", + "execFileSync" + ], + "message": "Use the plugin server process helpers so Windows command scripts and argv are handled consistently." + } + ] + } + ] + } + }, { "files": ["packages/app/src/**/*.{ts,tsx}"], "rules": { diff --git a/docs/plugins.md b/docs/plugins.md index 510ef68af1..401e33b94b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -248,7 +248,7 @@ dependency; shared types must not refer to React components, hooks, Node APIs, o | Entry | Owns | May depend on | | ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- | | `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only | -| `@getpaseo/plugin/server` | Server contribution/handler contexts and lifecycle contracts | Shared and server code | +| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code | | `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code | | `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code | | `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code | diff --git a/docs/providers.md b/docs/providers.md index 47c39fd1a8..c5f37d4161 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -10,7 +10,9 @@ Keep a bundled provider in `plugins//` and register it through import rules belong to [plugins.md](plugins.md#built-in-plugins); the [public provider guide](../public-docs/plugins/providers.md) covers the provider contract. -The plugin owns the CLI transport, session state, catalog, and capabilities. The daemon owns +The plugin owns the CLI transport, session state, catalog, and capabilities. Launch CLI transports +and probes through the [SDK process helpers](../public-docs/plugins/providers.md#launch-the-provider-cli), +which share Windows launcher handling with core providers. The daemon owns executable resolution and applies `agents.providers..command` and `env` before connecting. Register the provider's icon with the plugin rather than adding it to the app's provider icon map. You do not need a core manifest entry or provider factory. diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index 38197e4cb6..5b1ce4f3dc 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -4,6 +4,7 @@ import { isBuiltin } from "node:module"; import { fileURLToPath } from "node:url"; import ts from "typescript"; import { describe, expect, it } from "vitest"; +import * as server from "./server/index.js"; const sourceDirectory = path.dirname(fileURLToPath(import.meta.url)); const entries = { @@ -64,6 +65,10 @@ function boundaryViolations(entry: string, runtime: "shared" | "server" | "clien } describe("plugin SDK import boundaries", () => { + it("publishes process capabilities without their Windows implementation helpers", () => { + expect(Object.keys(server).sort()).toEqual(["execCommand", "spawnProcess", "terminateProcess"]); + }); + it("classifies every published entry", () => { const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); expect(Object.keys(manifest.exports).sort()).toEqual(Object.keys(entries).sort()); diff --git a/packages/plugin/src/server/acp-internal/connection.ts b/packages/plugin/src/server/acp-internal/connection.ts index a71f2ca696..fc78d2a8c4 100644 --- a/packages/plugin/src/server/acp-internal/connection.ts +++ b/packages/plugin/src/server/acp-internal/connection.ts @@ -1,4 +1,5 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { spawnProcess, terminateProcess } from "../process.js"; import { Readable, Writable } from "node:stream"; import { ClientSideConnection, @@ -400,9 +401,9 @@ class AcpRuntime { args: options.options.command.slice(1), env: process.env, }; - child = spawn(launch.command, launch.args, { + child = spawnProcess(launch.command, launch.args, { env: { ...launch.env, ...options.env }, - stdio: ["pipe", "pipe", "pipe"], + stdio: "pipe", }); spawnFailure = new Promise((_resolve, reject) => child!.once("error", reject)); child.stderr.on("data", () => undefined); @@ -734,9 +735,9 @@ class AcpRuntime { if (!child || this.processFailed) return; if (child.exitCode !== null || child.signalCode !== null) return; const closed = new Promise((resolve) => child.once("close", () => resolve())); - child.kill("SIGTERM"); + await terminateProcess(child, "SIGTERM"); if (await settlesWithin(closed, 1_000)) return; - child.kill("SIGKILL"); + await terminateProcess(child); if (!(await settlesWithin(closed, 1_000))) { throw new Error(`ACP provider ${this.options.options.id} did not terminate after SIGKILL`); } diff --git a/packages/plugin/src/server/index.ts b/packages/plugin/src/server/index.ts index dc211f61bf..9180023d19 100644 --- a/packages/plugin/src/server/index.ts +++ b/packages/plugin/src/server/index.ts @@ -22,3 +22,5 @@ export type { PluginBeforeRequests, PluginLifecycleRegistration, } from "./lifecycle.js"; + +export { spawnProcess, execCommand, terminateProcess } from "./process.js"; diff --git a/packages/plugin/src/server/process-internal/windows-command.test.ts b/packages/plugin/src/server/process-internal/windows-command.test.ts new file mode 100644 index 0000000000..3eb5e2e63b --- /dev/null +++ b/packages/plugin/src/server/process-internal/windows-command.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { quoteWindowsArgument, quoteWindowsCommand } from "./windows-command.js"; + +describe("quoteWindowsCommand", () => { + const originalPlatform = process.platform; + + function setPlatform(value: string) { + Object.defineProperty(process, "platform", { value, writable: true }); + } + + afterEach(() => { + setPlatform(originalPlatform); + }); + + test("quotes a Windows path with spaces", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe( + '"C:\\Program Files\\Anthropic\\claude.exe"', + ); + }); + + test("does not double-quote an already-quoted path", () => { + setPlatform("win32"); + expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe( + '"C:\\Program Files\\Anthropic\\claude.exe"', + ); + }); + + test("returns the command unchanged when there are no spaces", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex"); + }); + + test("escapes ampersands", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("feature&bugfix")).toBe("feature^&bugfix"); + }); + + test("escapes pipes", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("feature|bugfix")).toBe("feature^|bugfix"); + }); + + test("does not double percent signs", () => { + setPlatform("win32"); + // cmd.exe only collapses %% → % inside batch files; on the command line + // it stays literal, which corrupts git --format atoms etc. + expect(quoteWindowsCommand("100%")).toBe("100%"); + }); + + test("preserves git --format atoms verbatim", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("--format=%(refname)%09%(committerdate:unix)")).toBe( + "--format=%^(refname^)%09%^(committerdate:unix^)", + ); + }); + + test("escapes carets", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("feature^bugfix")).toBe("feature^^bugfix"); + }); + + test("escapes multiple metacharacters", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("build&(test|deploy)!")).toBe( + "build^&^(test^|deploy^)^!^", + ); + }); + + test("quotes commands with spaces after escaping metacharacters", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("C:\\Program Files\\My Tool&Stuff\\run 100%.cmd")).toBe( + '"C:\\Program Files\\My Tool^&Stuff\\run 100%.cmd"', + ); + }); + + test("returns the command unchanged on non-Windows platforms", () => { + setPlatform("darwin"); + expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); + }); +}); + +describe("quoteWindowsArgument", () => { + const originalPlatform = process.platform; + + function setPlatform(value: string) { + Object.defineProperty(process, "platform", { value, writable: true }); + } + + afterEach(() => { + setPlatform(originalPlatform); + }); + + test("quotes a Windows argument with spaces", () => { + setPlatform("win32"); + expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe( + '"C:\\Program Files\\Anthropic\\cli.js"', + ); + }); + + test("does not double-quote an already-quoted argument", () => { + setPlatform("win32"); + expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe( + '"C:\\Program Files\\Anthropic\\cli.js"', + ); + }); + + test("returns the argument unchanged when there are no spaces", () => { + setPlatform("win32"); + expect(quoteWindowsArgument("--version")).toBe("--version"); + }); + + test("returns the argument unchanged on non-Windows platforms", () => { + setPlatform("darwin"); + expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); + }); +}); diff --git a/packages/server/src/utils/windows-command.ts b/packages/plugin/src/server/process-internal/windows-command.ts similarity index 100% rename from packages/server/src/utils/windows-command.ts rename to packages/plugin/src/server/process-internal/windows-command.ts diff --git a/packages/plugin/src/server/process.test.ts b/packages/plugin/src/server/process.test.ts new file mode 100644 index 0000000000..e9b3000da5 --- /dev/null +++ b/packages/plugin/src/server/process.test.ts @@ -0,0 +1,53 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { execCommand, spawnProcess } from "./index.js"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function writeArgumentReader(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), "plugin process arguments ")); + roots.push(root); + const script = path.join(root, "read arguments.mjs"); + await writeFile(script, "process.stdout.write(JSON.stringify(process.argv.slice(2)));\n"); + return script; +} + +const argumentsToPreserve = [ + "argument with spaces", + "--format=%(refname)%09%(committerdate:unix)", + "100%", + "feature&bugfix", + "feature|bugfix", + "feature^bugfix", +]; + +// A bare extensionless command selects the Windows shell; an absolute node.exe path would +// skip that path. The real child checks quoting through the public process capabilities. +test("execCommand delivers shell arguments to the real child unchanged", async () => { + const script = await writeArgumentReader(); + const { stdout } = await execCommand("node", [script, ...argumentsToPreserve]); + expect(JSON.parse(stdout)).toEqual(argumentsToPreserve); +}); + +test("spawnProcess delivers shell arguments to the real child unchanged", async () => { + const script = await writeArgumentReader(); + const child = spawnProcess("node", [script, ...argumentsToPreserve], { stdio: "pipe" }); + let stdout = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`Argument reader exited with ${code}`)); + }); + }); + expect(JSON.parse(stdout)).toEqual(argumentsToPreserve); +}); diff --git a/packages/plugin/src/server/process.ts b/packages/plugin/src/server/process.ts new file mode 100644 index 0000000000..b2ba70ac12 --- /dev/null +++ b/packages/plugin/src/server/process.ts @@ -0,0 +1,119 @@ +import { + execFile, + spawn, + type ChildProcess, + type ChildProcessWithoutNullStreams, + type SpawnOptions, +} from "node:child_process"; +import { extname } from "node:path"; +import { promisify } from "node:util"; +import { + isWindowsCommandScript, + quoteWindowsArgument, + quoteWindowsCommand, +} from "./process-internal/windows-command.js"; +const execFileAsync = promisify(execFile); +export interface ExecCommandOptions { + env?: NodeJS.ProcessEnv; + cwd?: string; + encoding?: BufferEncoding; + killSignal?: NodeJS.Signals; + timeout?: number; + maxBuffer?: number; + shell?: boolean | string; + signal?: AbortSignal; +} +interface ExecCommandResult { + stdout: string; + stderr: string; +} +function hasPathSeparator(value: string): boolean { + return value.includes("/") || value.includes("\\"); +} + +function shouldUseWindowsShell( + command: string, + requestedShell?: boolean | string, +): boolean | string { + if (isWindowsCommandScript(command)) { + return true; + } + if (requestedShell !== undefined) { + return requestedShell; + } + return process.platform === "win32" && !hasPathSeparator(command) && !extname(command); +} + +/** Launch a CLI with Windows command-script handling and argv quoting. The caller owns env. */ +export function spawnProcess( + command: string, + args: string[], + options: SpawnOptions & { stdio: "pipe" }, +): ChildProcessWithoutNullStreams; +export function spawnProcess(command: string, args: string[], options?: SpawnOptions): ChildProcess; +export function spawnProcess( + command: string, + args: string[], + options?: SpawnOptions, +): ChildProcess { + const spawnOptions = options ?? {}; + const isWindows = process.platform === "win32"; + const shell = shouldUseWindowsShell(command, spawnOptions.shell); + + const shouldQuoteForShell = isWindows && shell !== false; + const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command; + const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args; + + return spawn(resolvedCommand, resolvedArgs, { + ...spawnOptions, + env: options?.env, + shell, + signal: options?.signal, + windowsHide: true, + }); +} + +export async function execCommand( + command: string, + args: string[], + options?: ExecCommandOptions, +): Promise { + const isWindows = process.platform === "win32"; + const shell = shouldUseWindowsShell(command, options?.shell); + const shouldQuoteForShell = isWindows && shell !== false; + const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command; + const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args; + + return execFileAsync(resolvedCommand, resolvedArgs, { + cwd: options?.cwd, + env: options?.env, + encoding: options?.encoding ?? "utf8", + signal: options?.signal, + killSignal: options?.killSignal, + timeout: options?.timeout, + maxBuffer: options?.maxBuffer, + shell, + windowsHide: true, + }) as Promise; +} + +/** Force-stop a CLI, including descendants behind Windows command-script launchers. */ +export async function terminateProcess( + child: ChildProcess, + signal: NodeJS.Signals = "SIGKILL", +): Promise { + if (process.platform !== "win32") { + child.kill(signal); + return; + } + if (child.pid === undefined) return; + try { + await execCommand("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { + shell: false, + timeout: 5000, + }); + } catch (error) { + // The process can finish between its close check and taskkill. + if (!(error instanceof Error && "code" in error && error.code === 128)) throw error; + } +} diff --git a/packages/server/src/executable-resolution/executable-resolution.test.ts b/packages/server/src/executable-resolution/executable-resolution.test.ts index 4434089e98..2ee0bd90df 100644 --- a/packages/server/src/executable-resolution/executable-resolution.test.ts +++ b/packages/server/src/executable-resolution/executable-resolution.test.ts @@ -3,12 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { - executableExists, - findExecutable, - quoteWindowsArgument, - quoteWindowsCommand, -} from "./executable-resolution.js"; +import { executableExists, findExecutable } from "./executable-resolution.js"; import { windowsExecutableResolution } from "./windows.js"; import { isPlatform } from "../test-utils/platform.js"; @@ -217,118 +212,3 @@ describe("executableExists", () => { expect(executableExists("/missing/codex", () => false)).toBeNull(); }); }); - -describe("quoteWindowsCommand", () => { - const originalPlatform = process.platform; - - function setPlatform(value: string) { - Object.defineProperty(process, "platform", { value, writable: true }); - } - - afterEach(() => { - setPlatform(originalPlatform); - }); - - test("quotes a Windows path with spaces", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe( - '"C:\\Program Files\\Anthropic\\claude.exe"', - ); - }); - - test("does not double-quote an already-quoted path", () => { - setPlatform("win32"); - expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe( - '"C:\\Program Files\\Anthropic\\claude.exe"', - ); - }); - - test("returns the command unchanged when there are no spaces", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex"); - }); - - test("escapes ampersands", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("feature&bugfix")).toBe("feature^&bugfix"); - }); - - test("escapes pipes", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("feature|bugfix")).toBe("feature^|bugfix"); - }); - - test("does not double percent signs", () => { - setPlatform("win32"); - // cmd.exe only collapses %% → % inside batch files; on the command line - // it stays literal, which corrupts git --format atoms etc. - expect(quoteWindowsCommand("100%")).toBe("100%"); - }); - - test("preserves git --format atoms verbatim", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("--format=%(refname)%09%(committerdate:unix)")).toBe( - "--format=%^(refname^)%09%^(committerdate:unix^)", - ); - }); - - test("escapes carets", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("feature^bugfix")).toBe("feature^^bugfix"); - }); - - test("escapes multiple metacharacters", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("build&(test|deploy)!")).toBe( - "build^&^(test^|deploy^)^!^", - ); - }); - - test("quotes commands with spaces after escaping metacharacters", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("C:\\Program Files\\My Tool&Stuff\\run 100%.cmd")).toBe( - '"C:\\Program Files\\My Tool^&Stuff\\run 100%.cmd"', - ); - }); - - test("returns the command unchanged on non-Windows platforms", () => { - setPlatform("darwin"); - expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); - }); -}); - -describe("quoteWindowsArgument", () => { - const originalPlatform = process.platform; - - function setPlatform(value: string) { - Object.defineProperty(process, "platform", { value, writable: true }); - } - - afterEach(() => { - setPlatform(originalPlatform); - }); - - test("quotes a Windows argument with spaces", () => { - setPlatform("win32"); - expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe( - '"C:\\Program Files\\Anthropic\\cli.js"', - ); - }); - - test("does not double-quote an already-quoted argument", () => { - setPlatform("win32"); - expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe( - '"C:\\Program Files\\Anthropic\\cli.js"', - ); - }); - - test("returns the argument unchanged when there are no spaces", () => { - setPlatform("win32"); - expect(quoteWindowsArgument("--version")).toBe("--version"); - }); - - test("returns the argument unchanged on non-Windows platforms", () => { - setPlatform("darwin"); - expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); - }); -}); diff --git a/packages/server/src/executable-resolution/executable-resolution.ts b/packages/server/src/executable-resolution/executable-resolution.ts index 75f230409f..59c218511b 100644 --- a/packages/server/src/executable-resolution/executable-resolution.ts +++ b/packages/server/src/executable-resolution/executable-resolution.ts @@ -2,11 +2,8 @@ import { createRequire } from "node:module"; import { existsSync } from "node:fs"; import type { ProcessEnvRecord } from "../server/paseo-env.js"; import { execCommand } from "../utils/spawn.js"; -import { isWindowsCommandScript } from "../utils/windows-command.js"; import { windowsExecutableResolution } from "./windows.js"; -export { quoteWindowsArgument, quoteWindowsCommand } from "../utils/windows-command.js"; - type Which = ( command: string, options: { all: true; path?: string; pathExt?: string }, @@ -82,7 +79,6 @@ export async function probeExecutable( timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: 64 * 1024, - shell: isWindowsCommandScript(executablePath), }); return true; } catch (error) { diff --git a/packages/server/src/server/plugins/bundle-evaluator.ts b/packages/server/src/server/plugins/bundle-evaluator.ts index 2941d4c4ff..e80db885ff 100644 --- a/packages/server/src/server/plugins/bundle-evaluator.ts +++ b/packages/server/src/server/plugins/bundle-evaluator.ts @@ -3,6 +3,7 @@ import * as pluginSharedRuntime from "@getpaseo/plugin"; import * as pluginProviderRuntime from "@getpaseo/plugin/server/provider"; import * as pluginAcpRuntime from "@getpaseo/plugin/server/acp"; import * as pluginUsageRuntime from "@getpaseo/plugin/server/usage"; +import * as pluginServerRuntime from "@getpaseo/plugin/server"; import type { PluginServerContribution } from "@getpaseo/plugin/server"; import * as zod from "zod"; import { isPluginClientOnlySdkSpecifier } from "./plugin-sdk-specifiers.js"; @@ -14,7 +15,7 @@ function runtimeRequire(name: string): unknown { throw new Error(`${name} is available only in plugin client code`); } if (name === "@getpaseo/plugin") return pluginSharedRuntime; - if (name === "@getpaseo/plugin/server") return {}; + if (name === "@getpaseo/plugin/server") return pluginServerRuntime; if (name === "@getpaseo/plugin/server/provider") return pluginProviderRuntime; if (name === "@getpaseo/plugin/server/acp") return pluginAcpRuntime; if (name === "@getpaseo/plugin/server/usage") return pluginUsageRuntime; diff --git a/packages/server/src/server/plugins/plugin-process.test.ts b/packages/server/src/server/plugins/plugin-process.test.ts index 6a4380d503..ddee3cb291 100644 --- a/packages/server/src/server/plugins/plugin-process.test.ts +++ b/packages/server/src/server/plugins/plugin-process.test.ts @@ -37,3 +37,14 @@ it("keeps React out of the plugin host's runtime dependency graph", async () => }), ).resolves.toMatchObject({ errors: [] }); }); + +it("exposes CLI probes through the plugin host's server SDK", async () => { + const { evaluateBundle } = await import("./bundle-evaluator.js"); + const setup = evaluateBundle(`(require) => ({ default: async () => { + const { execCommand } = require("@getpaseo/plugin/server"); + const { stdout } = await execCommand(${JSON.stringify(process.execPath)}, ["-e", "process.stdout.write('plugin CLI ready')"]); + if (stdout !== "plugin CLI ready") throw new Error("CLI output was lost"); + } })`); + // This contribution uses no server context APIs; it exercises the host's real SDK binding. + await expect(setup(undefined as never)).resolves.toBeUndefined(); +}); diff --git a/packages/server/src/utils/spawn.ts b/packages/server/src/utils/spawn.ts index 8fac115f00..b26104c63e 100644 --- a/packages/server/src/utils/spawn.ts +++ b/packages/server/src/utils/spawn.ts @@ -1,15 +1,6 @@ -import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; -import { extname } from "node:path"; -import { promisify } from "node:util"; - +import type { ChildProcess, SpawnOptions } from "node:child_process"; +import { spawnProcess as spawnCli, execCommand as execCli } from "@getpaseo/plugin/server"; import { createExternalCommandProcessEnv, type ProcessEnvRecord } from "../server/paseo-env.js"; -import { - isWindowsCommandScript, - quoteWindowsArgument, - quoteWindowsCommand, -} from "./windows-command.js"; - -const execFileAsync = promisify(execFile); interface ExternalEnvOptions { baseEnv?: ProcessEnvRecord; @@ -35,23 +26,6 @@ interface ExecCommandResult { stderr: string; } -function hasPathSeparator(value: string): boolean { - return value.includes("/") || value.includes("\\"); -} - -function shouldUseWindowsShell( - command: string, - requestedShell?: boolean | string, -): boolean | string { - if (isWindowsCommandScript(command)) { - return true; - } - if (requestedShell !== undefined) { - return requestedShell; - } - return process.platform === "win32" && !hasPathSeparator(command) && !extname(command); -} - export function spawnProcess( command: string, args: string[], @@ -59,12 +33,6 @@ export function spawnProcess( ): ChildProcess { const { baseEnv, env, envOverlay, ...spawnOptions } = options ?? {}; const resolvedBaseEnv = env ?? baseEnv ?? process.env; - const isWindows = process.platform === "win32"; - const shell = shouldUseWindowsShell(command, spawnOptions.shell); - - const shouldQuoteForShell = isWindows && shell !== false; - const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command; - const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args; const childEnv = options?.envMode === "internal" ? ({ ...resolvedBaseEnv, ...envOverlay } as NodeJS.ProcessEnv) @@ -74,12 +42,10 @@ export function spawnProcess( ...(envOverlay ? [envOverlay] : []), ); - return spawn(resolvedCommand, resolvedArgs, { + return spawnCli(command, args, { ...spawnOptions, env: childEnv, - shell, signal: options?.signal, - windowsHide: true, }); } @@ -90,11 +56,6 @@ export async function execCommand( ): Promise { const { baseEnv, env, envOverlay } = options ?? {}; const resolvedBaseEnv = env ?? baseEnv ?? process.env; - const isWindows = process.platform === "win32"; - const shell = shouldUseWindowsShell(command, options?.shell); - const shouldQuoteForShell = isWindows && shell !== false; - const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command; - const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args; const childEnv = options?.envMode === "internal" ? ({ ...resolvedBaseEnv, ...envOverlay } as NodeJS.ProcessEnv) @@ -104,14 +65,14 @@ export async function execCommand( ...(envOverlay ? [envOverlay] : []), ); - return execFileAsync(resolvedCommand, resolvedArgs, { + return execCli(command, args, { cwd: options?.cwd, env: childEnv, + shell: options?.shell, encoding: options?.encoding ?? "utf8", + signal: options?.signal, killSignal: options?.killSignal, timeout: options?.timeout, maxBuffer: options?.maxBuffer, - shell, - windowsHide: true, }) as Promise; } diff --git a/plugins/antigravity-provider/server/internal/process.ts b/plugins/antigravity-provider/server/internal/process.ts index 899a88e5cd..c87a17aadb 100644 --- a/plugins/antigravity-provider/server/internal/process.ts +++ b/plugins/antigravity-provider/server/internal/process.ts @@ -1,4 +1,5 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { spawnProcess } from "@getpaseo/plugin/server"; import { signalProcess } from "./signals.js"; import { createInterface } from "node:readline"; import type { ProviderLaunch, ProviderSessionConfig } from "@getpaseo/plugin/server/provider"; @@ -49,7 +50,7 @@ function driverArgs(options: DriverOptions): string[] { } export function startDriver(options: DriverOptions): Driver { - const child = spawn(options.launch.command, driverArgs(options), { + const child = spawnProcess(options.launch.command, driverArgs(options), { cwd: options.config.cwd, env: { ...options.launch.env, ...options.config.env }, detached: process.platform !== "win32", @@ -183,7 +184,7 @@ interface ProbeOptions { } export function probe(options: ProbeOptions): Promise { return new Promise((resolve, reject) => { - const child = spawn(options.launch.command, [...options.launch.args, ...options.args], { + const child = spawnProcess(options.launch.command, [...options.launch.args, ...options.args], { env: options.launch.env, cwd: options.cwd, stdio: "pipe", diff --git a/plugins/antigravity-provider/server/internal/signals.ts b/plugins/antigravity-provider/server/internal/signals.ts index be34ecc8c9..f7f5b64eab 100644 --- a/plugins/antigravity-provider/server/internal/signals.ts +++ b/plugins/antigravity-provider/server/internal/signals.ts @@ -1,5 +1,4 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; +import { execCommand } from "@getpaseo/plugin/server"; interface SignalOptions { platform: NodeJS.Platform; @@ -11,8 +10,6 @@ type SignalPlan = | { type: "group"; pid: number; signal: NodeJS.Signals } | { type: "tree"; command: "taskkill"; args: string[] }; -const executeFile = promisify(execFile); - export function signalPlan(options: SignalOptions): SignalPlan { if (options.platform === "win32") { return { type: "tree", command: "taskkill", args: ["/PID", String(options.pid), "/T", "/F"] }; @@ -32,7 +29,7 @@ export function signalProcess(options: SignalOptions): void | Promise { async function killTree(plan: Extract): Promise { try { - await executeFile(plan.command, plan.args, { windowsHide: true }); + await execCommand(plan.command, plan.args, { shell: false }); } catch (error) { // taskkill reports an already-exited PID with exit code 128. if (!(error instanceof Error && "code" in error && error.code === 128)) throw error; diff --git a/plugins/antigravity-provider/server/provider.test.ts b/plugins/antigravity-provider/server/provider.test.ts index 8ba12841e0..631f10a6fe 100644 --- a/plugins/antigravity-provider/server/provider.test.ts +++ b/plugins/antigravity-provider/server/provider.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -773,3 +773,19 @@ describe("image prompts", () => { expect((await h.records()).filter((entry) => entry.input)).toHaveLength(0); }); }); + +it.runIf(process.platform === "win32")( + "launches an Antigravity command shim for probes and conversation startup", + async () => { + const h = await harness(); + const shim = path.join(h.cwd, "agy launcher.cmd"); + await writeFile(shim, `@echo off\r\n"${process.execPath}" "${fake}" %*\r\n`); + h.launch.command = shim; + h.launch.args = ["--launch-prefix"]; + expect(await provider.status!({ launch: h.launch })).toEqual({ available: true }); + await h.open(); + expect(await h.wait((event) => event.type === "session.opened")).toMatchObject({ + sessionId: "s", + }); + }, +); diff --git a/plugins/muse-provider/server/connection.ts b/plugins/muse-provider/server/connection.ts index f502705f8f..3fca09ef5e 100644 --- a/plugins/muse-provider/server/connection.ts +++ b/plugins/muse-provider/server/connection.ts @@ -1,5 +1,5 @@ import type { ProviderLaunch } from "@getpaseo/plugin/server/provider"; -import { spawn } from "node:child_process"; +import { spawnProcess, terminateProcess } from "@getpaseo/plugin/server"; import { randomBytes } from "node:crypto"; import { createInterface } from "node:readline"; import { setTimeout as delay } from "node:timers/promises"; @@ -35,6 +35,7 @@ export class MspConnection { private stderr = ""; private failure: MuseError | null = null; private closing = false; + private closeResult: Promise | null = null; private readonly exited: Promise; constructor( @@ -42,15 +43,20 @@ export class MspConnection { launch: ProviderLaunch; cwd?: string; timeoutMs?: number; + startupTimeoutMs?: number; serveArgs?: string[]; }, ) { const { launch, cwd } = options; - this.child = spawn(launch.command, [...launch.args, "serve", ...(options.serveArgs ?? [])], { - env: launch.env, - cwd, - stdio: "pipe", - }); + this.child = spawnProcess( + launch.command, + [...launch.args, "serve", ...(options.serveArgs ?? [])], + { + env: launch.env, + cwd, + stdio: "pipe", + }, + ); this.child.stderr.setEncoding("utf8"); this.child.stderr.on("data", (chunk: string) => { this.stderr = (this.stderr + chunk).slice(-8192); @@ -62,7 +68,7 @@ export class MspConnection { } catch (error) { const message = error instanceof Error ? error.message : String(error); this.fail(new MuseError("invalidFrame", `Invalid MSP frame: ${message}`)); - this.child.kill(); + void this.close().catch(() => {}); } }); this.child.stdin.on("error", () => { @@ -89,6 +95,7 @@ export class MspConnection { }, }, initializedSchema, + this.options.startupTimeoutMs ?? 30000, ); if (response.schema.fingerprint !== fingerprint) { process.stderr.write( @@ -123,7 +130,12 @@ export class MspConnection { } } } - async request(method: string, params: object, schema: z.ZodType): Promise { + async request( + method: string, + params: object, + schema: z.ZodType, + timeoutMs = this.options.timeoutMs ?? 10000, + ): Promise { if (this.failure) throw this.failure; if (this.closing) throw new MuseError("closed", "Muse host is closing"); const id = ++this.sequence; @@ -131,22 +143,31 @@ export class MspConnection { const timer = setTimeout(() => { this.pending.delete(id); reject(new MuseError("timeout", `Muse ${method} timed out`)); - }, this.options.timeoutMs ?? 10000); + }, timeoutMs); this.pending.set(id, { resolve, reject, timer }); this.write({ jsonrpc: "2.0", id, method, params }); }); return schema.parse(response); } - async close(): Promise { - if (this.closing) return this.exited; + close(): Promise { + if (this.closeResult) return this.closeResult; this.closing = true; + this.closeResult = this.finishClose(); + return this.closeResult; + } + private async finishClose(): Promise { this.child.stdin.end(); const drained = await Promise.race([ this.exited.then(() => true), - delay(1000).then(() => false), + delay(1000, undefined, { ref: false }).then(() => false), ]); - if (!drained) this.child.kill("SIGKILL"); - await this.exited; + if (drained) return; + await terminateProcess(this.child); + const stopped = await Promise.race([ + this.exited.then(() => true), + delay(5000, undefined, { ref: false }).then(() => false), + ]); + if (!stopped) throw new MuseError("timeout", "Muse host did not close after termination"); } private write(frame: object): void { this.child.stdin.write(JSON.stringify(frame) + "\n"); @@ -173,7 +194,7 @@ export class MspConnection { if (frame.id === null) { if (!frame.error) throw new MuseError("invalidFrame", "MSP result has a null id"); this.fail(new MuseError(frame.error.data?.kind ?? "rpc", frame.error.message)); - this.child.kill(); + void this.close().catch(() => {}); return; } const pending = this.pending.get(frame.id); diff --git a/plugins/muse-provider/server/provider.ts b/plugins/muse-provider/server/provider.ts index bef6693f91..8d9a3ddfea 100644 --- a/plugins/muse-provider/server/provider.ts +++ b/plugins/muse-provider/server/provider.ts @@ -10,7 +10,7 @@ import { } from "@getpaseo/plugin/server/provider"; import { serveArgs } from "./options.js"; import { Usage } from "./usage.js"; -import { execFile } from "node:child_process"; +import { execCommand } from "@getpaseo/plugin/server"; import { Catalog, launchKey } from "./catalog.js"; import { MspConnection } from "./connection.js"; import { MuseError, actionableError } from "./errors.js"; @@ -63,24 +63,23 @@ function requireLaunch(launch: ProviderLaunch | undefined): ProviderLaunch { async function status(launch: ProviderLaunch): Promise { let host: MspConnection | undefined; try { - const versionText = await new Promise((resolve, reject) => { - execFile( - launch.command, - [...launch.args, "--version"], - { env: launch.env, timeout: 3000, maxBuffer: 8192 }, - (error, stdout) => { - if (error) reject(new MuseError("version", error.message)); - else resolve(stdout); - }, - ); - }); + const { stdout: versionText } = await execCommand( + launch.command, + [...launch.args, "--version"], + { + env: launch.env, + timeout: 3000, + maxBuffer: 8192, + }, + ); const match = /\b(\d+)\.(\d+)\.(\d+)\b/.exec(versionText); if (!match) return { available: false, diagnostic: "Muse returned an unrecognized version." }; const major = Number(match[1]); const minor = Number(match[2]); if (major < 1 || (major === 1 && minor < 3)) return { available: false, diagnostic: `Update Muse Code: found ${match[0]}, need ≥1.3.0` }; - host = new MspConnection({ launch, timeoutMs: 3000 }); + // Leave room for the version probe, account read and cleanup within the status RPC deadline. + host = new MspConnection({ launch, timeoutMs: 3000, startupTimeoutMs: 20000 }); await host.initialize(); const account = await host.request("account/read", {}, accountSchema); if (account.state === "loggedOut") diff --git a/plugins/muse-provider/test/fake-muse.mjs b/plugins/muse-provider/test/fake-muse.mjs index 65e1b88fcf..6dec29f8e5 100644 --- a/plugins/muse-provider/test/fake-muse.mjs +++ b/plugins/muse-provider/test/fake-muse.mjs @@ -6,9 +6,20 @@ import { fileURLToPath } from "node:url"; const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); const scenario = process.env.MUSE_TEST_SCENARIO || "text-reasoning"; if (process.argv.includes("--version")) { + if (process.env.MUSE_TEST_VERSION_ERROR) { + process.stderr.write(process.env.MUSE_TEST_VERSION_ERROR + "\n"); + process.exit(17); + } process.stdout.write(`Muse Code ${process.env.MUSE_TEST_VERSION || "1.4.1"}\n`); process.exit(0); } +if (process.env.MUSE_TEST_STUBBORN) { + setInterval(() => {}, 1000); + appendFileSync( + process.env.MUSE_TEST_REQUESTS, + JSON.stringify({ event: "stubbornHost", pid: process.pid }) + "\n", + ); +} const rows = readFixture(scenario); if (process.env.MUSE_TEST_REQUESTS) appendFileSync( @@ -22,6 +33,14 @@ const receipts = new Set(); const lines = createInterface({ input: process.stdin }); lines.on("line", receive); function receive(line) { + const parsed = JSON.parse(line); + if (parsed.method === "initialize" && process.env.MUSE_TEST_INITIALIZE_DELAY_MS) { + setTimeout(() => receiveFrame(line), Number(process.env.MUSE_TEST_INITIALIZE_DELAY_MS)); + return; + } + receiveFrame(line); +} +function receiveFrame(line) { const frame = JSON.parse(line); if (process.env.MUSE_TEST_REQUESTS) appendFileSync(process.env.MUSE_TEST_REQUESTS, JSON.stringify(frame) + "\n"); diff --git a/plugins/muse-provider/test/provider.test.ts b/plugins/muse-provider/test/provider.test.ts index d19415d544..800a010da9 100644 --- a/plugins/muse-provider/test/provider.test.ts +++ b/plugins/muse-provider/test/provider.test.ts @@ -1,3 +1,4 @@ +import { execFile } from "node:child_process"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { setTimeout as delay } from "node:timers/promises"; import os from "node:os"; @@ -14,6 +15,7 @@ import { type ProviderInput, } from "@getpaseo/plugin/server/provider"; import type { UsageSourceRegistration } from "@getpaseo/plugin/server"; +import { execCommand } from "@getpaseo/plugin/server"; import contribute from "../index.server.js"; const connections: ProviderConnection[] = []; @@ -460,6 +462,25 @@ for (const [env, expected] of [ expect(await h.provider.status!({ launch: h.launch })).toEqual(expected); }); } +test("status preserves the version-probe command and stderr diagnostic", async () => { + const h = await harness("catalog-controls", { + MUSE_TEST_VERSION_ERROR: "Muse version probe could not read its installation metadata", + }); + // Compare against the original execFile callback: wrapping in MuseError only changed its kind. + const previousDiagnostic = await new Promise((resolve, reject) => { + execFile(h.launch.command, [...h.launch.args, "--version"], { env: h.launch.env }, (error) => { + if (error) resolve(error.message); + else reject(new Error("Expected the version probe to fail")); + }); + }); + const status = await h.provider.status!({ launch: h.launch }); + expect(status).toEqual({ available: false, diagnostic: previousDiagnostic }); + expect(status.diagnostic).toContain("--version"); + expect(status.diagnostic).toContain( + "Muse version probe could not read its installation metadata", + ); + expect(await h.recorded()).toEqual([]); +}); test("status reports a missing executable", async () => { const h = await harness(); expect( @@ -1530,3 +1551,90 @@ test("Muse window identity follows the reported duration instead of assuming fiv ], }); }); + +test("status accepts initialization slower than the account deadline without isolating history", async () => { + const h = await harness("catalog-controls", { MUSE_TEST_INITIALIZE_DELAY_MS: "3500" }); + expect(await h.provider.status!({ launch: h.launch })).toEqual({ available: true }); + expect(await h.recorded()).toContainEqual({ event: "hostClosed" }); +}, 10000); + +for (const extension of ["cmd", "bat"]) { + test.runIf(process.platform === "win32")( + `launches the discovered Muse .${extension} for status, catalog and sessions`, + async () => { + const h = await harness("catalog-controls"); + const shim = path.join(h.root, `muse launcher.${extension}`); + await writeFile(shim, `@echo off\r\n"${process.execPath}" "${h.launch.args[0]}" %*\r\n`); + h.launch.command = shim; + h.launch.args = []; + expect(await h.provider.status!({ launch: h.launch })).toEqual({ available: true }); + await h.send({ type: "catalog", requestId: "shim-catalog" }); + expect(await h.wait((event) => event.type === "catalog")).toMatchObject({ + requestId: "shim-catalog", + }); + await h.open(); + expect(await h.wait((event) => event.type === "session.opened")).toMatchObject({ + sessionId: "paseo-session", + }); + }, + 20000, + ); +} + +test("force-stops a Muse host that ignores stdin EOF", async () => { + const h = await stubbornHostHarness(); + try { + const result = await Promise.race([ + h.provider.status!({ launch: h.launch }), + delay(10000).then(() => { + throw new Error("Muse status did not finish closing its host"); + }), + ]); + expect(result).toEqual({ available: true }); + const hosts = (await h.recorded()).filter((row) => row.event === "stubbornHost"); + expect(hosts).toHaveLength(1); + expect(() => process.kill(hosts[0].pid, 0)).toThrow(); + } finally { + await cleanupStubbornHosts(h); + } +}, 15000); + +test("closes the whole Muse host after an uncorrelated MSP error", async () => { + const h = await stubbornHostHarness({ MUSE_TEST_NULL_ERROR_ID: "1" }); + try { + const result = await h.provider.status!({ launch: h.launch }); + expect(result).toEqual({ available: false, diagnostic: "Cannot recover request id" }); + const recorded = await h.recorded(); + expect(recorded.some((row) => row.event === "hostClosed")).toBe(true); + const hosts = recorded.filter((row) => row.event === "stubbornHost"); + expect(hosts).toHaveLength(1); + expect(() => process.kill(hosts[0].pid, 0)).toThrow(); + } finally { + await cleanupStubbornHosts(h); + } +}, 15000); + +async function stubbornHostHarness(env: Record = {}) { + const h = await harness("catalog-controls", { MUSE_TEST_STUBBORN: "1", ...env }); + if (process.platform === "win32") { + const shim = path.join(h.root, "stubborn muse.cmd"); + await writeFile(shim, `@echo off\r\n"${process.execPath}" "${h.launch.args[0]}" %*\r\n`); + h.launch.command = shim; + h.launch.args = []; + } + return h; +} +async function cleanupStubbornHosts(h: Awaited>) { + // Keep a failing Windows regression from leaving its deliberately stubborn child alive. + for (const host of (await h.recorded()).filter((row) => row.event === "stubbornHost")) { + if (process.platform === "win32") { + await execCommand("taskkill.exe", ["/PID", String(host.pid), "/T", "/F"], { + shell: false, + }).catch(() => {}); + } else { + try { + process.kill(host.pid, "SIGKILL"); + } catch {} + } + } +} diff --git a/public-docs/plugins/providers.md b/public-docs/plugins/providers.md index 69cf206641..2f4951669c 100644 --- a/public-docs/plugins/providers.md +++ b/public-docs/plugins/providers.md @@ -73,6 +73,32 @@ The connection has three operations: Keep the native SDK, process, and stream inside the connection implementation. Convert its output to `ProviderEvent` objects before publishing it. +## Launch the provider CLI + +Use `spawnProcess` and `execCommand` from `@getpaseo/plugin/server` for process transports +and CLI probes. Both handle Windows `.cmd`/`.bat` launchers and quote shell arguments. +Raw Node `spawn` and `execFile` reject those launchers on Windows. + +The daemon supplies `request.launch` with the resolved command, argument prefix, and complete +environment. Pass that environment explicitly; the helpers do not apply daemon environment policy. +Keep the process lifecycle and protocol deadlines inside your provider. End stdin for graceful +shutdown; use `terminateProcess(child)` for a forced stop. It kills the process tree on Windows, +including the CLI behind a command-script launcher. Bound your final wait for closed streams. + +```ts +import { spawnProcess, execCommand } from "@getpaseo/plugin/server"; + +const child = spawnProcess(launch.command, [...launch.args, "serve"], { + env: launch.env, + cwd, + stdio: "pipe", +}); +const { stdout } = await execCommand(launch.command, [...launch.args, "--version"], { + env: launch.env, + timeout: 5000, +}); +``` + ## Return models, modes, and thinking options Paseo requests the catalog before creating a session. Return the choices needed by the agent form: From 27a95a5decc66c5de5221d4309c955f76c112913 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 5 Oct 2026 17:36:13 +0100 Subject: [PATCH 07/70] feat(website): give the plugin directory a closing section for building and submitting plugins (#6160) The directory header now keeps only "Submit a plugin", next to the search box. Building and submitting move into a "Build your own plugin" section at the end of the page, with "Read the docs" and "Submit a plugin" buttons. The docs link is labelled "Read the docs" because that is what it does. The site footer gets top margin on every page; it sat too close to content. --- .../website/src/components/site-footer.tsx | 2 +- packages/website/src/plugins/browse-page.tsx | 4 +- .../website/src/plugins/contribute-links.tsx | 45 ++++++++++++++++--- packages/website/src/plugins/links.ts | 2 +- packages/website/src/routes/plugins/index.tsx | 10 ++++- 5 files changed, 49 insertions(+), 14 deletions(-) diff --git a/packages/website/src/components/site-footer.tsx b/packages/website/src/components/site-footer.tsx index 62daa6ebd0..1ab11f9bdb 100644 --- a/packages/website/src/components/site-footer.tsx +++ b/packages/website/src/components/site-footer.tsx @@ -16,7 +16,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) { const widthClasses = WIDTH_CLASSES[width]; const alternatives = getAlternativePages(); return ( -