From cb00835a9201d9388274d9625c471072ef441e82 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:05:57 +0530 Subject: [PATCH 1/6] fix(agents): keep stale active work observable --- src/local-agent-client.ts | 24 ++++++++++++++++++++++-- src/local-agent-daemon.test.ts | 23 ++++++++++++++--------- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index 4e6367d9c..5aa5bce35 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -183,6 +183,13 @@ export class LocalAgentClient { return this.startupPromise; } + private async ensureReadyForObservation(): Promise> { + const existing = await this.tryHello(true); + if (existing.isErr()) return existing; + if (existing.value) return Result.ok(existing.value); + return this.ensureReady(); + } + private async ensureReadyInternal(): Promise> { const existing = await this.tryHello(); if (existing.isErr()) return existing; @@ -223,7 +230,9 @@ export class LocalAgentClient { })); } - private async tryHello(): Promise> { + private async tryHello( + allowStaleBusyConfig = false, + ): Promise> { const authToken = this.authTokenResult("hello"); if (authToken.isErr()) return authToken; const response = await sendRequest(this.endpoint, { @@ -263,6 +272,9 @@ export class LocalAgentClient { const decoded = decodeValue(response.value.result, "hello", decodeDaemonHello); if (decoded.isErr()) return decoded; if (!decoded.value.configMatches) { + if (allowStaleBusyConfig && decoded.value.status.activeTurns > 0) { + return Result.ok(decoded.value.status); + } return this.replaceIdleChangedDaemon(authToken.value, decoded.value.status); } return Result.ok(decoded.value.status.state === "ready" ? decoded.value.status : undefined); @@ -376,7 +388,9 @@ export class LocalAgentClient { params: Extract['params'], timeoutMs: number | null = this.requestTimeoutMs, ): Promise>> { - const ready = await this.ensureReady(); + const ready = await (isObservationRequest(method) + ? this.ensureReadyForObservation() + : this.ensureReady()); if (ready.isErr()) return ready as BetterResult>; const authToken = this.authTokenResult(method); if (authToken.isErr()) return authToken as BetterResult>; @@ -475,6 +489,12 @@ export class LocalAgentClient { } } +function isObservationRequest( + method: LocalAgentDaemonRequest["method"], +): method is "agent.get" | "agent.list" | "agent.wait" { + return method === "agent.get" || method === "agent.list" || method === "agent.wait"; +} + export function createLocalAgentClient( config: Pick, ): LocalAgentClient { diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 466cf066e..88722dafa 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -329,16 +329,21 @@ try { } assert.equal(staleActiveSpawns, 0); assert.equal(staleActiveManager.closed, false); - assert.deepEqual(Object.keys(unwrap(await staleActiveClient.status())).sort(), [ - "activeTurns", - "clientConnections", - "endpoint", - "pid", - "protocolVersion", - "runtimeCount", - "startedAt", - "state", + const staleScope = { workspaceId: record.workspaceId!, workspaceRoot: record.workspaceRoot }; + assert.equal(unwrap(await staleActiveClient.get(record.id, staleScope)).id, record.id); + assert.equal(unwrap(await staleActiveClient.list(staleScope))[0]?.id, record.id); + assert.deepEqual(unwrap(await staleActiveClient.wait([record.id], staleScope, 0)), [ + { id: record.id, status: "running" }, ]); + const blockedStart = await staleActiveClient.run({ + target: "reviewer", + prompt: "must use current provider config", + workspaceId: record.workspaceId!, + workspaceRoot: record.workspaceRoot, + }); + assert.equal(blockedStart.isErr(), true); + if (blockedStart.isErr()) assert.equal(blockedStart.error.code, "DAEMON_CONFIG_CHANGED"); + assert.equal("configRevision" in unwrap(await staleActiveClient.status()), false); } finally { await staleActiveDaemon.close(); } From 6c3b61e74804f1bf825bc8ff3a98924de4473965 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:07:26 +0530 Subject: [PATCH 2/6] fix(agents): tighten orchestration contracts --- src/local-agent-acp.test.ts | 1 + src/local-agent-acp.ts | 28 +++----------------- src/local-agent-availability.ts | 35 ++----------------------- src/local-agent-command.ts | 35 +++++++++++++++++++++++++ src/local-agent-daemon-protocol.test.ts | 2 ++ src/local-agent-daemon-protocol.ts | 2 +- 6 files changed, 44 insertions(+), 59 deletions(-) create mode 100644 src/local-agent-command.ts diff --git a/src/local-agent-acp.test.ts b/src/local-agent-acp.test.ts index 39227fa66..7a5222261 100644 --- a/src/local-agent-acp.test.ts +++ b/src/local-agent-acp.test.ts @@ -336,6 +336,7 @@ if (process.platform !== "win32") { await writeFile(candidate, `#!/bin/sh\ntouch '${marker}'\nexit 0\n`, { mode: 0o700 }); await chmod(candidate, 0o700); assert.equal(resolveAcpCommand("cursor", { PATH: commandRoot }), candidate); + assert.equal(resolveAcpCommand("cursor", { CURSOR_COMMAND: commandRoot }), undefined); assert.equal(existsSync(marker), false, "ACP command discovery must not execute PATH candidates"); } finally { await rm(commandRoot, { recursive: true, force: true }); diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index 043939cf6..078391ea9 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -1,7 +1,6 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; -import { accessSync, constants } from "node:fs"; import { createRequire } from "node:module"; -import { delimiter, resolve } from "node:path"; +import { resolve } from "node:path"; import { Readable, Writable } from "node:stream"; import { AgentProviderProtocolError, @@ -28,6 +27,7 @@ import type { LocalAgentRuntimeContext, LocalAgentWriteMode, } from "./local-agent-runtime.js"; +import { resolveExecutableCommand } from "./local-agent-command.js"; export type AcpProvider = "cursor" | "copilot" | "grok"; @@ -617,20 +617,7 @@ export function resolveAcpCommand( ? env.COPILOT_COMMAND : env.GROK_COMMAND; const command = configured ?? ACP_COMMANDS[provider][0]; - if (command.includes("/") || command.includes("\\")) return executableExists(command) ? command : undefined; - const path = env.PATH; - if (!path) return undefined; - const extensions = process.platform === "win32" - ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] - : [""]; - for (const directory of path.split(delimiter)) { - if (!directory) continue; - for (const extension of extensions) { - const candidate = resolve(directory, `${command}${extension}`); - if (executableExists(candidate)) return candidate; - } - } - return undefined; + return resolveExecutableCommand(command, env); } export type AcpCommandResolver = (provider: AcpProvider, env: NodeJS.ProcessEnv) => string | undefined; @@ -829,15 +816,6 @@ function appendTail(current: string, chunk: string, maxBytes: number): string { return Buffer.from(next, "utf8").subarray(-maxBytes).toString("utf8"); } -function executableExists(command: string): boolean { - try { - accessSync(command, process.platform === "win32" ? constants.F_OK : constants.X_OK); - return true; - } catch { - return false; - } -} - async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { let timer: NodeJS.Timeout | undefined; const timeout = new Promise((_resolve, reject) => { diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index 77786f1ea..14764488c 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -1,9 +1,8 @@ -import { accessSync, constants, statSync } from "node:fs"; -import { delimiter, resolve } from "node:path"; import { LOCAL_AGENT_PROVIDERS, type LocalAgentProvider, } from "./local-agent-profiles.js"; +import { resolveExecutableCommand } from "./local-agent-command.js"; import { localAgentProviderEnvironment, type SubagentsConfig, @@ -94,40 +93,10 @@ function commandAvailability( command: string, env: NodeJS.ProcessEnv, ): LocalAgentProviderAvailability { - if (resolveCommand(command, env)) return { name: provider, available: true }; + if (resolveExecutableCommand(command, env)) return { name: provider, available: true }; return { name: provider, available: false, reason: `${command} executable not found`, }; } - -function resolveCommand(command: string, env: NodeJS.ProcessEnv): string | undefined { - if (!command) return undefined; - if (command.includes("/") || command.includes("\\")) { - return executableExists(command) ? command : undefined; - } - const path = env.PATH; - if (!path) return undefined; - const extensions = process.platform === "win32" - ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] - : [""]; - for (const directory of path.split(delimiter)) { - if (!directory) continue; - for (const extension of extensions) { - const candidate = resolve(directory, `${command}${extension}`); - if (executableExists(candidate)) return candidate; - } - } - return undefined; -} - -function executableExists(command: string): boolean { - const mode = process.platform === "win32" ? constants.F_OK : constants.X_OK; - try { - accessSync(command, mode); - return statSync(command).isFile(); - } catch { - return false; - } -} diff --git a/src/local-agent-command.ts b/src/local-agent-command.ts new file mode 100644 index 000000000..a23ca8068 --- /dev/null +++ b/src/local-agent-command.ts @@ -0,0 +1,35 @@ +import { accessSync, constants, statSync } from "node:fs"; +import { delimiter, resolve } from "node:path"; + +export function resolveExecutableCommand( + command: string, + env: NodeJS.ProcessEnv, +): string | undefined { + if (!command) return undefined; + if (command.includes("/") || command.includes("\\")) { + return isExecutableFile(command) ? command : undefined; + } + const path = env.PATH; + if (!path) return undefined; + const extensions = process.platform === "win32" + ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] + : [""]; + for (const directory of path.split(delimiter)) { + if (!directory) continue; + for (const extension of extensions) { + const candidate = resolve(directory, `${command}${extension}`); + if (isExecutableFile(candidate)) return candidate; + } + } + return undefined; +} + +function isExecutableFile(command: string): boolean { + const mode = process.platform === "win32" ? constants.F_OK : constants.X_OK; + try { + accessSync(command, mode); + return statSync(command).isFile(); + } catch { + return false; + } +} diff --git a/src/local-agent-daemon-protocol.test.ts b/src/local-agent-daemon-protocol.test.ts index 95d750451..bd340d5b5 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -127,6 +127,8 @@ const record = decodeAgentRecord({ }); assert.equal(record.id, "agt_1234"); assert.equal(record.latestResponse, " response whitespace \n"); +assert.equal(decodeAgentRecord({ ...record, latestResponse: "" }).latestResponse, ""); +assert.equal(decodeAgentRecord({ ...record, latestResponse: " \n" }).latestResponse, " \n"); const directRecord = decodeAgentRecord({ ...record, workspaceId: undefined }); assert.equal(directRecord.workspaceId, undefined); diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts index a19571e37..21aeb6806 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -223,7 +223,7 @@ export function decodeAgentRecord(value: unknown): LocalAgentRecord { effort: optionalString(record?.effort), providerSessionId: optionalString(record?.providerSessionId), status, - latestResponse: optionalContentString(record?.latestResponse), + latestResponse: typeof record?.latestResponse === "string" ? record.latestResponse : undefined, error: optionalContentString(record?.error), errorCode: optionalString(record?.errorCode), errorRetryable: optionalBoolean(record?.errorRetryable), From bd6875aac2ef3e17bcacb3955cf6d4581b0a9576 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:20:51 +0530 Subject: [PATCH 3/6] feat(agents): support env for every provider --- docs/configuration.md | 14 +-- schema/v1/devspace.schema.json | 124 ++++++++++++++++++--------- src/local-agent-adapters.ts | 8 +- src/local-agent-availability.test.ts | 1 + src/local-agent-config.test.ts | 8 +- src/local-agent-config.ts | 48 +++++++---- src/local-agent-opencode.test.ts | 42 ++++++++- src/local-agent-opencode.ts | 99 +++++++++++++++++++-- src/local-agent-pi-sandbox.test.ts | 23 +++++ src/local-agent-pi-sandbox.ts | 8 +- src/local-agent-pi.test.ts | 7 +- src/local-agent-pi.ts | 35 +++++++- 12 files changed, 332 insertions(+), 85 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index ead8e21fc..98f0d124e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -168,11 +168,15 @@ a wrapper executable when startup needs fixed arguments. `env` maps environment variable names to literal string values and preserves empty strings. DevSpace does not expand `$NAME` references in these values. -Codex, Claude, Cursor, Copilot, and Grok accept `command` and `env`. OpenCode and -Pi are embedded, so their provider entries reject both fields. The daemon -inherits its startup environment, then overlays the provider's `env`. An -explicit `command` wins over both the inherited command override and a command -override placed in `env`. +All subagent providers accept `env`. The daemon inherits its startup +environment, then overlays the provider's `env` without mutating the daemon's +process environment. OpenCode receives that environment on its managed server +process; embedded Pi scopes it to its provider requests and command execution. + +Codex, Claude, Cursor, Copilot, and Grok also accept `command`. OpenCode and Pi +do not expose a command override. For providers that support it, an explicit +`command` wins over both the inherited command override and a command override +placed in `env`. Existing process-level overrides remain supported: `CODEX_COMMAND`, `CODEX_HOME`, `CLAUDE_COMMAND`, `CURSOR_COMMAND`, `COPILOT_COMMAND`, diff --git a/schema/v1/devspace.schema.json b/schema/v1/devspace.schema.json index 61978ceb6..cf9ce8f84 100644 --- a/schema/v1/devspace.schema.json +++ b/schema/v1/devspace.schema.json @@ -177,52 +177,92 @@ "providers": { "type": "array", "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "enum": [ - "codex", - "claude", - "opencode", - "pi", - "cursor", - "copilot", - "grok" - ] - }, - "enabled": { - "type": "boolean" - }, - "model": { - "type": "string", - "minLength": 1 - }, - "effort": { - "type": "string", - "minLength": 1 - }, - "command": { - "type": "string", - "minLength": 1, - "pattern": "\\S" + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": [ + "codex", + "claude", + "cursor", + "copilot", + "grok" + ] + }, + "enabled": { + "type": "boolean" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "effort": { + "type": "string", + "minLength": 1 + }, + "env": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "additionalProperties": { + "type": "string" + } + }, + "command": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + } + }, + "required": [ + "id", + "enabled" + ], + "additionalProperties": false }, - "env": { + { "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + "properties": { + "id": { + "type": "string", + "enum": [ + "opencode", + "pi" + ] + }, + "enabled": { + "type": "boolean" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "effort": { + "type": "string", + "minLength": 1 + }, + "env": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "additionalProperties": { + "type": "string" + } + } }, - "additionalProperties": { - "type": "string" - } + "required": [ + "id", + "enabled" + ], + "additionalProperties": false } - }, - "required": [ - "id", - "enabled" - ], - "additionalProperties": false + ] } } }, diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index d3b7b815b..d83d2fc8e 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -1,5 +1,6 @@ import { localAgentProviderEnvironment, + localAgentProviderEnvironmentOverrides, type SubagentsConfig, } from "./local-agent-config.js"; import type { LocalAgentProvider } from "./local-agent-profiles.js"; @@ -45,11 +46,14 @@ export function createLocalAgentDrivers( const providerEnv = (provider: LocalAgentProvider) => options.subagents ? localAgentProviderEnvironment(options.subagents, provider, env) : env; + const providerEnvOverrides = (provider: LocalAgentProvider) => options.subagents + ? localAgentProviderEnvironmentOverrides(options.subagents, provider) + : {}; return [ new CodexLocalAgentDriver(providerEnv("codex")), new ClaudeLocalAgentDriver(options.claudeQueryFactory, providerEnv("claude")), - new OpencodeLocalAgentDriver(options.opencodeFactory), - new PiLocalAgentDriver(options.piSessionFactory), + new OpencodeLocalAgentDriver(options.opencodeFactory, providerEnv("opencode")), + new PiLocalAgentDriver(options.piSessionFactory, providerEnvOverrides("pi")), new AcpLocalAgentDriver("cursor", providerEnv("cursor")), new AcpLocalAgentDriver("copilot", providerEnv("copilot")), new AcpLocalAgentDriver("grok", providerEnv("grok")), diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts index 223a7c522..a5592b42a 100644 --- a/src/local-agent-availability.test.ts +++ b/src/local-agent-availability.test.ts @@ -40,6 +40,7 @@ assert.equal( }, { enabled: true, + instructions: "on-demand", providers: [{ id: "codex", enabled: true, diff --git a/src/local-agent-config.test.ts b/src/local-agent-config.test.ts index 5641066f7..6cf19515a 100644 --- a/src/local-agent-config.test.ts +++ b/src/local-agent-config.test.ts @@ -100,7 +100,7 @@ assert.throws( enabled: true, providers: [{ id: "unknown", enabled: true }], }), - /Invalid option/, + /Invalid discriminator value/, ); assert.throws( () => subagentsConfigSchema.parse({ @@ -124,11 +124,15 @@ assert.throws( /Invalid environment variable name/, ); for (const id of ["opencode", "pi"] as const) { + const embedded = subagentsConfigSchema.parse({ + enabled: true, + providers: [{ id, enabled: true, env: { HARNESS_ENV: id } }], + }); + assert.equal(localAgentProviderEnvironment(embedded, id, {}).HARNESS_ENV, id); assert.throws( () => subagentsConfigSchema.parse({ enabled: true, providers: [{ id, enabled: true, command: "/opt/bin/agent" }], }), - new RegExp(`${id} is embedded and does not support command or env configuration`), ); } diff --git a/src/local-agent-config.ts b/src/local-agent-config.ts index cf6c32df1..39d42f09d 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -1,7 +1,6 @@ import { createHash } from "node:crypto"; import * as z from "zod/v4"; import { - LOCAL_AGENT_PROVIDERS, type LocalAgentProvider, } from "./local-agent-profiles.js"; @@ -10,25 +9,30 @@ const environmentSchema = z.record( z.string(), ); -const providerSchema = z.object({ - id: z.enum(LOCAL_AGENT_PROVIDERS as [LocalAgentProvider, ...LocalAgentProvider[]]), +const providerShape = { enabled: z.boolean(), model: z.string().trim().min(1).optional(), effort: z.string().trim().min(1).optional(), - command: z.string() - .regex(/\S/, "Command must contain a non-whitespace character") - .trim() - .min(1) - .optional(), env: environmentSchema.optional(), -}).strict().superRefine((value, context) => { - if ((value.id === "opencode" || value.id === "pi") && (value.command || value.env)) { - context.addIssue({ - code: "custom", - message: `${value.id} is embedded and does not support command or env configuration.`, - }); - } -}); +}; + +const commandSchema = z.string() + .regex(/\S/, "Command must contain a non-whitespace character") + .trim() + .min(1) + .optional(); + +const providerSchema = z.discriminatedUnion("id", [ + z.object({ + id: z.enum(["codex", "claude", "cursor", "copilot", "grok"]), + ...providerShape, + command: commandSchema, + }).strict(), + z.object({ + id: z.enum(["opencode", "pi"]), + ...providerShape, + }).strict(), +]); export const subagentsConfigSchema = z.object({ enabled: z.boolean(), @@ -79,10 +83,18 @@ export function localAgentProviderEnvironment( const providerConfig = subagentProviderConfig(config, provider); const env = { ...inherited, ...providerConfig?.env }; const commandVariable = providerCommandVariable(provider); - if (commandVariable && providerConfig?.command) env[commandVariable] = providerConfig.command; + const command = providerConfig && "command" in providerConfig ? providerConfig.command : undefined; + if (commandVariable && command) env[commandVariable] = command; return env; } +export function localAgentProviderEnvironmentOverrides( + config: SubagentsConfig, + provider: LocalAgentProvider, +): Record { + return { ...subagentProviderConfig(config, provider)?.env }; +} + export function providerCommandVariable(provider: LocalAgentProvider): string | undefined { switch (provider) { case "codex": return "CODEX_COMMAND"; @@ -104,7 +116,7 @@ export function localAgentProviderConfigRevision(config: SubagentsConfig): strin enabled: provider.enabled, ...(provider.model ? { model: provider.model } : {}), ...(provider.effort ? { effort: provider.effort } : {}), - ...(provider.command ? { command: provider.command } : {}), + ...("command" in provider && provider.command ? { command: provider.command } : {}), ...(provider.env && Object.keys(provider.env).length > 0 ? { env: Object.fromEntries( diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index 7bc2edc7f..7480128a6 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -1,4 +1,7 @@ import assert from "node:assert/strict"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { opencodeAgentConfig, OpencodeLocalAgentDriver, @@ -51,14 +54,16 @@ const client = { } as unknown as OpencodeClientLike; let factoryCalls = 0; let closeCalls = 0; -const factory: OpencodeFactory = async () => { +let factoryEnv: NodeJS.ProcessEnv | undefined; +const factory: OpencodeFactory = async (_context, env) => { factoryCalls += 1; + factoryEnv = env; return { client, server: { close: () => { closeCalls += 1; } }, }; }; -const driver = new OpencodeLocalAgentDriver(factory); +const driver = new OpencodeLocalAgentDriver(factory, { HARNESS_ENV: "opencode" }); const pool = new LocalAgentRuntimePool(); const first = await pool.run(driver, { @@ -81,8 +86,41 @@ const second = await pool.run(driver, { }); assert.equal(factoryCalls, 1, "OpenCode agents share one server runtime"); +assert.equal(factoryEnv?.HARNESS_ENV, "opencode"); assert.equal(first.isOk(), true); assert.equal(second.isOk(), true); + +if (process.platform !== "win32") { + const commandRoot = await mkdtemp(join(tmpdir(), "devspace-opencode-env-")); + const marker = join(commandRoot, "env.txt"); + const command = join(commandRoot, "opencode"); + try { + await writeFile(command, [ + "#!/bin/sh", + 'printf "%s" "$HARNESS_ENV" > "$MARKER"', + 'echo "opencode server listening on http://127.0.0.1:4096"', + "trap 'exit 0' TERM INT", + "while true; do /bin/sleep 1; done", + "", + ].join("\n")); + await chmod(command, 0o700); + const envDriver = new OpencodeLocalAgentDriver(undefined, { + PATH: commandRoot, + HARNESS_ENV: "opencode-child", + MARKER: marker, + }); + const created = await envDriver.createRuntime({ + agentId: "agt_env", + provider: "opencode", + workspaceRoot: "/tmp/project", + }); + assert.equal(created.isOk(), true); + if (created.isOk()) await created.value.close(); + assert.equal(await readFile(marker, "utf8"), "opencode-child"); + } finally { + await rm(commandRoot, { recursive: true, force: true }); + } +} if (first.isErr()) throw first.error; if (second.isErr()) throw second.error; const firstRecord = first.value; diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index 88cd6c05b..c682c79da 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -1,3 +1,4 @@ +import { createRequire } from "node:module"; import type { ModelRef, OpencodeClient, @@ -19,9 +20,15 @@ import type { LocalAgentRuntime, LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; +import { terminateProcessTree } from "./process-platform.js"; const OPENCODE_SESSION_POLL_INTERVAL_MS = 250; const OPENCODE_SESSION_POLL_TIMEOUT_MS = 5 * 60_000; +const OPENCODE_SERVER_HOSTNAME = "127.0.0.1"; +const OPENCODE_SERVER_PORT = 4096; +const OPENCODE_SERVER_START_TIMEOUT_MS = 5_000; +const require = createRequire(import.meta.url); +const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; export type OpencodeClientLike = Pick; @@ -29,7 +36,10 @@ export interface OpencodeServerLike { close(): void; } -export type OpencodeFactory = (context?: LocalAgentRuntimeContext) => Promise<{ +export type OpencodeFactory = ( + context?: LocalAgentRuntimeContext, + env?: NodeJS.ProcessEnv, +) => Promise<{ client: OpencodeClientLike; server: OpencodeServerLike; }>; @@ -124,7 +134,10 @@ export class OpencodeLocalAgentDriver implements LocalAgentDriver { readonly provider = "opencode" as const; readonly idleTimeoutMs = 5 * 60_000; - constructor(private readonly factory: OpencodeFactory = defaultOpencodeFactory) {} + constructor( + private readonly factory: OpencodeFactory = defaultOpencodeFactory, + private readonly env: NodeJS.ProcessEnv = process.env, + ) {} runtimeKey(_context: LocalAgentRuntimeContext): string { return "opencode:default"; @@ -136,22 +149,94 @@ export class OpencodeLocalAgentDriver implements LocalAgentDriver { agentId: context.agentId, operation: "create_runtime", run: async (): Promise => { - const { client, server } = await this.factory(context); + const { client, server } = await this.factory(context, this.env); return new OpencodeRuntime(client, server); }, }); } } -async function defaultOpencodeFactory(): Promise<{ client: OpencodeClientLike; server: OpencodeServerLike }> { - const { createOpencode } = await import("@opencode-ai/sdk/v2"); - return createOpencode({ config: { +async function defaultOpencodeFactory( + _context?: LocalAgentRuntimeContext, + env: NodeJS.ProcessEnv = process.env, +): Promise<{ client: OpencodeClientLike; server: OpencodeServerLike }> { + const { createOpencodeClient } = await import("@opencode-ai/sdk/v2"); + const config = { agent: { devspace_read_only: opencodeAgentConfig("read_only"), devspace_allowed: opencodeAgentConfig("allowed"), devspace_full_access: opencodeAgentConfig("full_access"), }, - } }); + }; + const server = await startOpencodeServer(env, config); + return { + client: createOpencodeClient({ baseUrl: server.url }), + server, + }; +} + +async function startOpencodeServer( + env: NodeJS.ProcessEnv, + config: Record, +): Promise { + const detached = process.platform !== "win32"; + const child = spawn("opencode", [ + "serve", + `--hostname=${OPENCODE_SERVER_HOSTNAME}`, + `--port=${OPENCODE_SERVER_PORT}`, + ], { + detached, + env: { + ...env, + OPENCODE_CONFIG_CONTENT: JSON.stringify(config), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let closed = false; + const close = () => { + if (closed) return; + closed = true; + terminateProcessTree(child, "SIGTERM", detached); + }; + const url = await new Promise((resolve, reject) => { + let output = ""; + let ready = false; + const timer = setTimeout(() => { + if (ready) return; + close(); + reject(new Error(`Timeout waiting for OpenCode server after ${OPENCODE_SERVER_START_TIMEOUT_MS}ms`)); + }, OPENCODE_SERVER_START_TIMEOUT_MS); + timer.unref(); + child.stdout?.on("data", (chunk: Buffer | string) => { + if (ready) return; + output += chunk.toString(); + for (const line of output.split("\n")) { + if (!line.startsWith("opencode server listening")) continue; + const match = line.match(/on\s+(https?:\/\/[^\s]+)/); + if (!match?.[1]) continue; + ready = true; + clearTimeout(timer); + resolve(match[1]); + return; + } + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + if (!ready) output += chunk.toString(); + }); + child.once("error", (error) => { + if (ready) return; + clearTimeout(timer); + close(); + reject(error); + }); + child.once("exit", (code) => { + if (ready) return; + clearTimeout(timer); + close(); + reject(new Error(`OpenCode server exited with code ${code}${output.trim() ? `\n${output.trim()}` : ""}`)); + }); + }); + return { url, close }; } export function opencodeAgentConfig(writeMode: LocalAgentRunInput["writeMode"]): { diff --git a/src/local-agent-pi-sandbox.test.ts b/src/local-agent-pi-sandbox.test.ts index b1410d83f..e5a7b4bf4 100644 --- a/src/local-agent-pi-sandbox.test.ts +++ b/src/local-agent-pi-sandbox.test.ts @@ -12,6 +12,29 @@ import { releasePiSandboxSession, } from "./local-agent-pi-sandbox.js"; +{ + const workspace = await mkdtemp(join(tmpdir(), "devspace-pi-env-test-")); + const modeRef = createPiSandboxModeRef("full_access"); + const tools = new Map Promise }>(); + try { + createPiSandboxExtension(workspace, modeRef, { + ...process.env, + DEVSPACE_PI_ENV_TEST: "provider-env", + })({ + registerTool: (tool: { name: string; execute: (...args: any[]) => Promise }) => + tools.set(tool.name, tool), + } as never); + const bash = tools.get("bash"); + assert.ok(bash); + const result = await bash.execute("provider-env-test", { command: "printf %s \"$DEVSPACE_PI_ENV_TEST\"" }) as { + content: Array<{ type: string; text?: string }>; + }; + assert.equal(result.content[0]?.text, "provider-env"); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +} + const dependencies = await SandboxManager.checkDependenciesAsync(); if (process.env.DEVSPACE_REQUIRE_PI_SANDBOX === "1") { assert.equal(SandboxManager.isSupportedPlatform(), true, "Pi sandbox integration is required on this CI lane"); diff --git a/src/local-agent-pi-sandbox.ts b/src/local-agent-pi-sandbox.ts index 1413f823a..3cdf72a04 100644 --- a/src/local-agent-pi-sandbox.ts +++ b/src/local-agent-pi-sandbox.ts @@ -81,6 +81,7 @@ export function createPiSandboxModeRef(value: PiSandboxWriteMode): PiSandboxMode export function createPiSandboxExtension( workspace: string, modeRef: PiSandboxModeRef, + env: NodeJS.ProcessEnv = {}, ): ExtensionFactory { return (pi) => { const localRead = createReadTool(workspace); @@ -107,9 +108,14 @@ export function createPiSandboxExtension( const restrictedLs = createLsTool(workspace, { operations: createLsOperations(workspace) }); pi.registerTool(dynamicTool(localLs, restrictedLs, modeRef)); - const localBash = createBashTool(workspace); + const withProviderEnv = (context: { command: string; cwd: string; env: NodeJS.ProcessEnv }) => ({ + ...context, + env: { ...context.env, ...env }, + }); + const localBash = createBashTool(workspace, { spawnHook: withProviderEnv }); const restrictedBash = createBashTool(workspace, { operations: createSandboxedBashOperations(), + spawnHook: withProviderEnv, }); pi.registerTool(dynamicTool(localBash, restrictedBash, modeRef, true)); }; diff --git a/src/local-agent-pi.test.ts b/src/local-agent-pi.test.ts index bc0dd1685..df111666c 100644 --- a/src/local-agent-pi.test.ts +++ b/src/local-agent-pi.test.ts @@ -53,13 +53,15 @@ class FakePiSession implements PiSessionLike { const contexts: LocalAgentRuntimeContext[] = []; const sessions: FakePiSession[] = []; -const factory: PiSessionFactory = async (context) => { +let factoryEnv: NodeJS.ProcessEnv | undefined; +const factory: PiSessionFactory = async (context, _input, env) => { contexts.push(context); + factoryEnv = env; const session = new FakePiSession(); sessions.push(session); return session; }; -const driver = new PiLocalAgentDriver(factory); +const driver = new PiLocalAgentDriver(factory, { HARNESS_ENV: "pi" }); const pool = new LocalAgentRuntimePool(); const context: LocalAgentRuntimeContext = { agentId: "agt_pi", @@ -77,6 +79,7 @@ const first = await pool.run(driver, context, { }, { onSessionId: (sessionId) => { sessionIds.push(sessionId); }, }); +assert.equal(factoryEnv?.HARNESS_ENV, "pi"); const second = await pool.run(driver, context, { prompt: "second", workspaceRoot: "/tmp/project", diff --git a/src/local-agent-pi.ts b/src/local-agent-pi.ts index 8880983b1..bfb85cb3a 100644 --- a/src/local-agent-pi.ts +++ b/src/local-agent-pi.ts @@ -1,5 +1,5 @@ import { join } from "node:path"; -import type { AgentSession } from "@earendil-works/pi-coding-agent"; +import type { AgentSession, ModelRegistry } from "@earendil-works/pi-coding-agent"; import { AgentProviderExecutionError, AgentProviderProtocolError, @@ -43,6 +43,7 @@ export type PiSessionLike = Pick< export type PiSessionFactory = ( context: LocalAgentRuntimeContext, input: LocalAgentRunInput, + env?: NodeJS.ProcessEnv, ) => Promise; export class PiSessionRuntime implements LocalAgentRuntime { @@ -165,7 +166,10 @@ export class PiLocalAgentDriver implements LocalAgentDriver { readonly provider = "pi" as const; readonly idleTimeoutMs = 3 * 60_000; - constructor(private readonly factory: PiSessionFactory = defaultPiSessionFactory) {} + constructor( + private readonly factory: PiSessionFactory = defaultPiSessionFactory, + private readonly env: NodeJS.ProcessEnv = {}, + ) {} runtimeKey(context: LocalAgentRuntimeContext): string { return `pi:${context.agentId}`; @@ -185,7 +189,7 @@ export class PiLocalAgentDriver implements LocalAgentDriver { model: context.model, effort: context.effort, }; - const session = await this.factory(context, input); + const session = await this.factory(context, input, this.env); return new PiSessionRuntime(session); }, }); @@ -195,6 +199,7 @@ export class PiLocalAgentDriver implements LocalAgentDriver { async function defaultPiSessionFactory( context: LocalAgentRuntimeContext, input: LocalAgentRunInput, + env: NodeJS.ProcessEnv = {}, ): Promise { const { AuthStorage, @@ -209,6 +214,7 @@ async function defaultPiSessionFactory( const agentDir = getAgentDir(); const authStorage = AuthStorage.create(join(agentDir, "auth.json")); const modelRegistry = ModelRegistry.create(authStorage, join(agentDir, "models.json")); + applyPiProviderEnvironment(modelRegistry, env); const sessionManager = await resolveSessionManager(SessionManager, input.workspaceRoot, input.providerSessionId); const model = input.model ? resolvePiModel(modelRegistry, input.model) : undefined; if (input.model && !model) { @@ -225,7 +231,7 @@ async function defaultPiSessionFactory( const resourceLoader = new DefaultResourceLoader({ cwd: input.workspaceRoot, agentDir, - extensionFactories: [createPiSandboxExtension(input.workspaceRoot, modeRef)], + extensionFactories: [createPiSandboxExtension(input.workspaceRoot, modeRef, env)], }); let session: PiSessionLike | undefined; try { @@ -258,6 +264,27 @@ async function defaultPiSessionFactory( } } +function applyPiProviderEnvironment( + modelRegistry: ModelRegistry, + env: NodeJS.ProcessEnv, +): void { + const getApiKeyAndHeaders = modelRegistry.getApiKeyAndHeaders.bind(modelRegistry); + const providerEnv = Object.fromEntries( + Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); + modelRegistry.getApiKeyAndHeaders = async (model) => { + const auth = await getApiKeyAndHeaders(model); + if (!auth.ok) return auth; + return { + ...auth, + env: { + ...auth.env, + ...providerEnv, + }, + }; + }; +} + export function piToolsForWriteMode(writeMode: LocalAgentRunInput["writeMode"]): readonly string[] { switch (writeMode) { case "read_only": return PI_READ_ONLY_TOOLS; From 0516feccd0d6be1b11112ce5e9c30d168ad88be5 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:30:47 +0530 Subject: [PATCH 4/6] fix(agents): allocate opencode server ports --- src/local-agent-opencode.test.ts | 11 ++++++++++- src/local-agent-opencode.ts | 22 ++++++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index 7480128a6..71af6f9a4 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -93,12 +93,16 @@ assert.equal(second.isOk(), true); if (process.platform !== "win32") { const commandRoot = await mkdtemp(join(tmpdir(), "devspace-opencode-env-")); const marker = join(commandRoot, "env.txt"); + const argsMarker = join(commandRoot, "args.txt"); const command = join(commandRoot, "opencode"); try { await writeFile(command, [ "#!/bin/sh", 'printf "%s" "$HARNESS_ENV" > "$MARKER"', - 'echo "opencode server listening on http://127.0.0.1:4096"', + 'printf "%s\\n" "$@" > "$ARGS_MARKER"', + 'port=""', + 'for arg in "$@"; do case "$arg" in --port=*) port="${arg#--port=}" ;; esac; done', + 'echo "opencode server listening on http://127.0.0.1:$port"', "trap 'exit 0' TERM INT", "while true; do /bin/sleep 1; done", "", @@ -108,6 +112,7 @@ if (process.platform !== "win32") { PATH: commandRoot, HARNESS_ENV: "opencode-child", MARKER: marker, + ARGS_MARKER: argsMarker, }); const created = await envDriver.createRuntime({ agentId: "agt_env", @@ -117,6 +122,10 @@ if (process.platform !== "win32") { assert.equal(created.isOk(), true); if (created.isOk()) await created.value.close(); assert.equal(await readFile(marker, "utf8"), "opencode-child"); + const args = (await readFile(argsMarker, "utf8")).trim().split("\n"); + const portArgument = args.find((argument) => argument.startsWith("--port=")); + assert.ok(portArgument, "OpenCode receives an explicitly allocated port"); + assert.notEqual(portArgument, "--port=4096", "OpenCode must not use a process-global fixed port"); } finally { await rm(commandRoot, { recursive: true, force: true }); } diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index c682c79da..079256b5c 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -1,4 +1,5 @@ import { createRequire } from "node:module"; +import { createServer as createNetServer } from "node:net"; import type { ModelRef, OpencodeClient, @@ -25,7 +26,6 @@ import { terminateProcessTree } from "./process-platform.js"; const OPENCODE_SESSION_POLL_INTERVAL_MS = 250; const OPENCODE_SESSION_POLL_TIMEOUT_MS = 5 * 60_000; const OPENCODE_SERVER_HOSTNAME = "127.0.0.1"; -const OPENCODE_SERVER_PORT = 4096; const OPENCODE_SERVER_START_TIMEOUT_MS = 5_000; const require = createRequire(import.meta.url); const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; @@ -180,10 +180,11 @@ async function startOpencodeServer( config: Record, ): Promise { const detached = process.platform !== "win32"; + const port = await allocateOpencodePort(); const child = spawn("opencode", [ "serve", `--hostname=${OPENCODE_SERVER_HOSTNAME}`, - `--port=${OPENCODE_SERVER_PORT}`, + `--port=${port}`, ], { detached, env: { @@ -239,6 +240,23 @@ async function startOpencodeServer( return { url, close }; } +async function allocateOpencodePort(): Promise { + const server = createNetServer(); + server.unref(); + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen({ host: OPENCODE_SERVER_HOSTNAME, port: 0, exclusive: true }, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Failed to allocate an OpenCode server port.")); + return; + } + server.close((error) => error ? reject(error) : resolve(address.port)); + }); + }); +} + export function opencodeAgentConfig(writeMode: LocalAgentRunInput["writeMode"]): { mode: "primary"; permission: PermissionConfig; From f724dae40842c4e37a30879e91890ef942b1ddec Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:48:20 +0530 Subject: [PATCH 5/6] fix(agents): retry opencode port races --- src/local-agent-opencode.test.ts | 25 +++++++++++++++++++++++++ src/local-agent-opencode.ts | 32 +++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index 71af6f9a4..91aa1a99c 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -94,14 +94,34 @@ if (process.platform !== "win32") { const commandRoot = await mkdtemp(join(tmpdir(), "devspace-opencode-env-")); const marker = join(commandRoot, "env.txt"); const argsMarker = join(commandRoot, "args.txt"); + const collisionMarker = join(commandRoot, "collision.txt"); + const holderReady = join(commandRoot, "holder-ready.txt"); + const holderScript = join(commandRoot, "hold-port.mjs"); const command = join(commandRoot, "opencode"); try { + await writeFile(holderScript, [ + 'import { writeFileSync } from "node:fs";', + 'import { createServer } from "node:net";', + 'const [port, ready] = process.argv.slice(2);', + 'const server = createServer();', + 'server.listen({ host: "127.0.0.1", port: Number(port), exclusive: true }, () => {', + ' writeFileSync(ready, "ready");', + ' setTimeout(() => server.close(() => process.exit(0)), 1000);', + '});', + "", + ].join("\n")); await writeFile(command, [ "#!/bin/sh", 'printf "%s" "$HARNESS_ENV" > "$MARKER"', 'printf "%s\\n" "$@" > "$ARGS_MARKER"', 'port=""', 'for arg in "$@"; do case "$arg" in --port=*) port="${arg#--port=}" ;; esac; done', + 'if [ ! -f "$COLLISION_MARKER" ]; then', + ' printf "collision" > "$COLLISION_MARKER"', + ' "$NODE_EXECUTABLE" "$HOLDER_SCRIPT" "$port" "$HOLDER_READY" &', + ' while [ ! -f "$HOLDER_READY" ]; do /bin/sleep 0.01; done', + ' exit 1', + 'fi', 'echo "opencode server listening on http://127.0.0.1:$port"', "trap 'exit 0' TERM INT", "while true; do /bin/sleep 1; done", @@ -113,6 +133,10 @@ if (process.platform !== "win32") { HARNESS_ENV: "opencode-child", MARKER: marker, ARGS_MARKER: argsMarker, + COLLISION_MARKER: collisionMarker, + HOLDER_READY: holderReady, + HOLDER_SCRIPT: holderScript, + NODE_EXECUTABLE: process.execPath, }); const created = await envDriver.createRuntime({ agentId: "agt_env", @@ -122,6 +146,7 @@ if (process.platform !== "win32") { assert.equal(created.isOk(), true); if (created.isOk()) await created.value.close(); assert.equal(await readFile(marker, "utf8"), "opencode-child"); + assert.equal(await readFile(collisionMarker, "utf8"), "collision", "OpenCode retries a claimed allocated port"); const args = (await readFile(argsMarker, "utf8")).trim().split("\n"); const portArgument = args.find((argument) => argument.startsWith("--port=")); assert.ok(portArgument, "OpenCode receives an explicitly allocated port"); diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index 079256b5c..3d40f5d64 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -27,6 +27,7 @@ const OPENCODE_SESSION_POLL_INTERVAL_MS = 250; const OPENCODE_SESSION_POLL_TIMEOUT_MS = 5 * 60_000; const OPENCODE_SERVER_HOSTNAME = "127.0.0.1"; const OPENCODE_SERVER_START_TIMEOUT_MS = 5_000; +const OPENCODE_SERVER_START_ATTEMPTS = 3; const require = createRequire(import.meta.url); const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; @@ -178,9 +179,24 @@ async function defaultOpencodeFactory( async function startOpencodeServer( env: NodeJS.ProcessEnv, config: Record, +): Promise { + for (let attempt = 1; attempt <= OPENCODE_SERVER_START_ATTEMPTS; attempt += 1) { + const port = await allocateOpencodePort(); + try { + return await launchOpencodeServer(env, config, port); + } catch (error) { + if (attempt === OPENCODE_SERVER_START_ATTEMPTS || !await isOpencodePortInUse(port)) throw error; + } + } + throw new Error("OpenCode server failed to start."); +} + +async function launchOpencodeServer( + env: NodeJS.ProcessEnv, + config: Record, + port: number, ): Promise { const detached = process.platform !== "win32"; - const port = await allocateOpencodePort(); const child = spawn("opencode", [ "serve", `--hostname=${OPENCODE_SERVER_HOSTNAME}`, @@ -257,6 +273,20 @@ async function allocateOpencodePort(): Promise { }); } +async function isOpencodePortInUse(port: number): Promise { + const server = createNetServer(); + server.unref(); + return new Promise((resolve, reject) => { + server.once("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EADDRINUSE") resolve(true); + else reject(error); + }); + server.listen({ host: OPENCODE_SERVER_HOSTNAME, port, exclusive: true }, () => { + server.close((error) => error ? reject(error) : resolve(false)); + }); + }); +} + export function opencodeAgentConfig(writeMode: LocalAgentRunInput["writeMode"]): { mode: "primary"; permission: PermissionConfig; From 482cc46a8c5465ee83ae43314df4b55a748963d7 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:04:09 +0530 Subject: [PATCH 6/6] test(agents): isolate opencode port collision --- src/local-agent-opencode.test.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index 91aa1a99c..c7b4bbacb 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -96,10 +96,11 @@ if (process.platform !== "win32") { const argsMarker = join(commandRoot, "args.txt"); const collisionMarker = join(commandRoot, "collision.txt"); const holderReady = join(commandRoot, "holder-ready.txt"); - const holderScript = join(commandRoot, "hold-port.mjs"); + const holderProcess = join(commandRoot, "hold-port.mjs"); + const holderLauncher = join(commandRoot, "launch-holder.mjs"); const command = join(commandRoot, "opencode"); try { - await writeFile(holderScript, [ + await writeFile(holderProcess, [ 'import { writeFileSync } from "node:fs";', 'import { createServer } from "node:net";', 'const [port, ready] = process.argv.slice(2);', @@ -110,6 +111,16 @@ if (process.platform !== "win32") { '});', "", ].join("\n")); + await writeFile(holderLauncher, [ + 'import { spawn } from "node:child_process";', + 'const [script, port, ready] = process.argv.slice(2);', + 'const child = spawn(process.execPath, [script, port, ready], {', + ' detached: true,', + ' stdio: "ignore",', + '});', + 'child.unref();', + "", + ].join("\n")); await writeFile(command, [ "#!/bin/sh", 'printf "%s" "$HARNESS_ENV" > "$MARKER"', @@ -118,7 +129,7 @@ if (process.platform !== "win32") { 'for arg in "$@"; do case "$arg" in --port=*) port="${arg#--port=}" ;; esac; done', 'if [ ! -f "$COLLISION_MARKER" ]; then', ' printf "collision" > "$COLLISION_MARKER"', - ' "$NODE_EXECUTABLE" "$HOLDER_SCRIPT" "$port" "$HOLDER_READY" &', + ' "$NODE_EXECUTABLE" "$HOLDER_LAUNCHER" "$HOLDER_PROCESS" "$port" "$HOLDER_READY"', ' while [ ! -f "$HOLDER_READY" ]; do /bin/sleep 0.01; done', ' exit 1', 'fi', @@ -135,7 +146,8 @@ if (process.platform !== "win32") { ARGS_MARKER: argsMarker, COLLISION_MARKER: collisionMarker, HOLDER_READY: holderReady, - HOLDER_SCRIPT: holderScript, + HOLDER_PROCESS: holderProcess, + HOLDER_LAUNCHER: holderLauncher, NODE_EXECUTABLE: process.execPath, }); const created = await envDriver.createRuntime({