From 94a4cae355c1e09ef374dd1b845f1ea596d4836b Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Thu, 8 Jan 2026 14:24:06 +0100 Subject: [PATCH 1/5] feat(cli): add --prompt option to all commands Adds the --prompt option to the attach, acp, web and serve commands (where it starts a session) Close #8890 --- packages/opencode/src/acp/agent.ts | 13 +++ packages/opencode/src/acp/types.ts | 1 + packages/opencode/src/cli/cmd/acp.ts | 17 ++-- packages/opencode/src/cli/cmd/serve.ts | 40 +++++++++- packages/opencode/src/cli/cmd/tui/attach.ts | 17 +++- packages/opencode/src/cli/cmd/web.ts | 87 +++++++++++++++------ packages/opencode/test/cli/acp.test.ts | 30 +++++++ packages/opencode/test/cli/attach.test.ts | 72 +++++++++++++++++ packages/opencode/test/cli/serve.test.ts | 48 ++++++++++++ packages/opencode/test/cli/web.test.ts | 42 ++++++++++ 10 files changed, 336 insertions(+), 31 deletions(-) create mode 100644 packages/opencode/test/cli/acp.test.ts create mode 100644 packages/opencode/test/cli/attach.test.ts create mode 100644 packages/opencode/test/cli/serve.test.ts create mode 100644 packages/opencode/test/cli/web.test.ts diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index f8792393c60..3517f5405e7 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -411,6 +411,19 @@ export namespace ACP { this.setupEventSubscriptions(state) + // Send initial prompt if provided (don't await - let it stream via events) + if (this.config.initialPrompt) { + this.sdk.session + .prompt({ + sessionID: sessionId, + directory, + message: this.config.initialPrompt, + }) + .catch((err) => { + log.error("failed to send initial prompt", { error: err, sessionId }) + }) + } + return { sessionId, models: load.models, diff --git a/packages/opencode/src/acp/types.ts b/packages/opencode/src/acp/types.ts index 42b23091237..5efee2d0bd8 100644 --- a/packages/opencode/src/acp/types.ts +++ b/packages/opencode/src/acp/types.ts @@ -19,4 +19,5 @@ export interface ACPConfig { providerID: string modelID: string } + initialPrompt?: string } diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 30e919d999a..cfaa1ab37dd 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -13,11 +13,16 @@ export const AcpCommand = cmd({ command: "acp", describe: "start ACP (Agent Client Protocol) server", builder: (yargs) => { - return withNetworkOptions(yargs).option("cwd", { - describe: "working directory", - type: "string", - default: process.cwd(), - }) + return withNetworkOptions(yargs) + .option("cwd", { + describe: "working directory", + type: "string", + default: process.cwd(), + }) + .option("prompt", { + describe: "prompt to use", + type: "string", + }) }, handler: async (args) => { await bootstrap(process.cwd(), async () => { @@ -55,7 +60,7 @@ export const AcpCommand = cmd({ const agent = await ACP.init({ sdk }) new AgentSideConnection((conn) => { - return agent.create(conn, { sdk }) + return agent.create(conn, { sdk, initialPrompt: args.prompt }) }, stream) log.info("setup connection") diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index bee2c8f711f..00fc9a10ceb 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -2,10 +2,16 @@ import { Server } from "../../server/server" import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "../../flag/flag" +import { createOpencodeClient } from "@opencode-ai/sdk/v2" export const ServeCommand = cmd({ command: "serve", - builder: (yargs) => withNetworkOptions(yargs), + builder: (yargs) => { + return withNetworkOptions(yargs).option("prompt", { + describe: "prompt to use", + type: "string", + }) + }, describe: "starts a headless opencode server", handler: async (args) => { if (!Flag.OPENCODE_SERVER_PASSWORD) { @@ -13,7 +19,37 @@ export const ServeCommand = cmd({ } const opts = await resolveNetworkOptions(args) const server = Server.listen(opts) - console.log(`opencode server listening on http://${server.hostname}:${server.port}`) + const baseUrl = `http://${server.hostname}:${server.port}` + + // If prompt is provided, create a session and send the prompt + if (args.prompt) { + const sdk = createOpencodeClient({ + baseUrl, + }) + + const session = await sdk.session.create({ directory: process.cwd() }) + if (!session.data) throw new Error("Failed to create session") + + // Send the prompt to the session (fire and forget) + sdk.session + .prompt({ + sessionID: session.data.id, + directory: process.cwd(), + parts: [ + { + type: "text", + text: args.prompt, + }, + ], + }) + .catch(() => {}) + + console.log(`opencode server listening on ${baseUrl}`) + console.log(`session created: ${baseUrl}/${session.data.id}/session/${session.data.id}`) + } else { + console.log(`opencode server listening on ${baseUrl}`) + } + await new Promise(() => {}) await server.stop() }, diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 3f9285f631c..bfc2f1cd08d 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -1,5 +1,6 @@ import { cmd } from "../cmd" import { tui } from "./app" +import { iife } from "@/util/iife" export const AttachCommand = cmd({ command: "attach ", @@ -19,12 +20,26 @@ export const AttachCommand = cmd({ alias: ["s"], type: "string", describe: "session id to continue", + }) + .option("prompt", { + type: "string", + describe: "prompt to use", }), handler: async (args) => { if (args.dir) process.chdir(args.dir) + + const prompt = await iife(async () => { + const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined + if (!args.prompt) return piped + return piped ? piped + "\n" + args.prompt : args.prompt + }) + await tui({ url: args.url, - args: { sessionID: args.session }, + args: { + sessionID: args.session, + prompt, + }, directory: args.dir ? process.cwd() : undefined, }) }, diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 5fa2bb42640..bdf1f291c8b 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -5,6 +5,7 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "../../flag/flag" import open from "open" import { networkInterfaces } from "os" +import { createOpencodeClient } from "@opencode-ai/sdk/v2" function getNetworkIPs() { const nets = networkInterfaces() @@ -30,7 +31,12 @@ function getNetworkIPs() { export const WebCommand = cmd({ command: "web", - builder: (yargs) => withNetworkOptions(yargs), + builder: (yargs) => { + return withNetworkOptions(yargs).option("prompt", { + describe: "prompt to use", + type: "string", + }) + }, describe: "start opencode server and open web interface", handler: async (args) => { if (!Flag.OPENCODE_SERVER_PASSWORD) { @@ -42,22 +48,13 @@ export const WebCommand = cmd({ UI.println(UI.logo(" ")) UI.empty() - if (opts.hostname === "0.0.0.0") { - // Show localhost for local access - const localhostUrl = `http://localhost:${server.port}` - UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl) - - // Show network IPs for remote access - const networkIPs = getNetworkIPs() - if (networkIPs.length > 0) { - for (const ip of networkIPs) { - UI.println( - UI.Style.TEXT_INFO_BOLD + " Network access: ", - UI.Style.TEXT_NORMAL, - `http://${ip}:${server.port}`, - ) - } - } + const baseUrl = opts.hostname === "0.0.0.0" ? `http://localhost:${server.port}` : server.url.toString() + + // If prompt is provided, create a session and send the prompt + if (args.prompt) { + const sdk = createOpencodeClient({ + baseUrl, + }) if (opts.mdns) { UI.println( @@ -67,12 +64,58 @@ export const WebCommand = cmd({ ) } - // Open localhost in browser - open(localhostUrl.toString()).catch(() => {}) + const session = await sdk.session.create({ directory: process.cwd() }) + if (!session.data) throw new Error("Failed to create session") + const sessionUrl = `${baseUrl}/${session.data.id}/session/${session.data.id}` + + // Send the prompt to the session (fire and forget) + sdk.session + .prompt({ + sessionID: session.data.id, + directory: process.cwd(), + parts: [ + { + type: "text", + text: args.prompt, + }, + ], + }) + .catch(() => {}) + + UI.println(UI.Style.TEXT_INFO_BOLD + " Session URL: ", UI.Style.TEXT_NORMAL, sessionUrl) + UI.empty() + + // Open the session in browser + open(sessionUrl).catch(() => {}) } else { - const displayUrl = server.url.toString() - UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl) - open(displayUrl).catch(() => {}) + if (opts.hostname === "0.0.0.0") { + // Show localhost for local access + const localhostUrl = `http://localhost:${server.port}` + UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl) + + // Show network IPs for remote access + const networkIPs = getNetworkIPs() + if (networkIPs.length > 0) { + for (const ip of networkIPs) { + UI.println( + UI.Style.TEXT_INFO_BOLD + " Network access: ", + UI.Style.TEXT_NORMAL, + `http://${ip}:${server.port}`, + ) + } + } + + if (opts.mdns) { + UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, "opencode.local") + } + + // Open localhost in browser + open(localhostUrl.toString()).catch(() => {}) + } else { + const displayUrl = server.url.toString() + UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl) + open(displayUrl).catch(() => {}) + } } await new Promise(() => {}) diff --git a/packages/opencode/test/cli/acp.test.ts b/packages/opencode/test/cli/acp.test.ts new file mode 100644 index 00000000000..28279f673ca --- /dev/null +++ b/packages/opencode/test/cli/acp.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test" +import type { ACPConfig } from "../../src/acp/types" + +describe("ACP command with --prompt", () => { + test("ACPConfig should accept initialPrompt parameter", () => { + const config: ACPConfig = { + sdk: {} as any, + initialPrompt: "Test prompt", + } + + expect(config.initialPrompt).toBe("Test prompt") + }) + + test("ACPConfig should allow undefined initialPrompt", () => { + const config: ACPConfig = { + sdk: {} as any, + initialPrompt: undefined, + } + + expect(config.initialPrompt).toBeUndefined() + }) + + test("ACPConfig should allow missing initialPrompt", () => { + const config: ACPConfig = { + sdk: {} as any, + } + + expect(config.initialPrompt).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/cli/attach.test.ts b/packages/opencode/test/cli/attach.test.ts new file mode 100644 index 00000000000..e7b17255980 --- /dev/null +++ b/packages/opencode/test/cli/attach.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test" + +describe("attach command with --prompt", () => { + test("should combine piped input with prompt argument", () => { + const piped = "piped content" + const promptArg = "prompt argument" + + // Simulate attach command logic + const result = piped ? piped + "\n" + promptArg : promptArg + + expect(result).toBe("piped content\nprompt argument") + expect(result).toContain(piped) + expect(result).toContain(promptArg) + }) + + test("should use only prompt argument when no piped input", () => { + const piped = undefined + const promptArg = "prompt argument" + + // Simulate attach command logic + const result = piped ? piped + "\n" + promptArg : promptArg + + expect(result).toBe("prompt argument") + expect(result).not.toContain("\n") + }) + + test("should use only piped input when no prompt argument", () => { + const piped = "piped content" + const promptArg = undefined + + // Simulate attach command logic + const result = promptArg ? (piped ? piped + "\n" + promptArg : promptArg) : piped + + expect(result).toBe("piped content") + }) + + test("should return undefined when neither piped input nor prompt argument", () => { + const piped = undefined + const promptArg = undefined + + // Simulate attach command logic + const result = promptArg ? (piped ? piped + "\n" + promptArg : promptArg) : piped + + expect(result).toBeUndefined() + }) + + test("should construct tui args correctly with prompt", () => { + const sessionID = "ses_test123" + const prompt = "test prompt" + + const tuiArgs = { + sessionID, + prompt, + } + + expect(tuiArgs.sessionID).toBe(sessionID) + expect(tuiArgs.prompt).toBe(prompt) + }) + + test("should construct tui args correctly without prompt", () => { + const sessionID = "ses_test123" + const prompt = undefined + + const tuiArgs = { + sessionID, + prompt, + } + + expect(tuiArgs.sessionID).toBe(sessionID) + expect(tuiArgs.prompt).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/cli/serve.test.ts b/packages/opencode/test/cli/serve.test.ts new file mode 100644 index 00000000000..c2af0602f64 --- /dev/null +++ b/packages/opencode/test/cli/serve.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test" + +describe("serve command with --prompt", () => { + test("should generate correct base URL", () => { + const hostname = "localhost" + const port = 4096 + const baseUrl = `http://${hostname}:${port}` + + expect(baseUrl).toBe("http://localhost:4096") + expect(baseUrl).toContain("localhost") + }) + + test("should generate correct session URL format", () => { + const baseUrl = "http://localhost:4096" + const sessionId = "ses_test123" + const sessionUrl = `${baseUrl}/${sessionId}` + + expect(sessionUrl).toBe("http://localhost:4096/ses_test123") + expect(sessionUrl).toContain(sessionId) + }) + + test("should handle prompt parameter correctly", () => { + const prompt = "Test prompt for serve command" + + expect(prompt).toBeDefined() + expect(prompt.length).toBeGreaterThan(0) + }) + + test("should format console output correctly without prompt", () => { + const baseUrl = "http://localhost:4096" + const output = `opencode server listening on ${baseUrl}` + + expect(output).toContain("opencode server listening on") + expect(output).toContain(baseUrl) + }) + + test("should format console output correctly with prompt", () => { + const baseUrl = "http://localhost:4096" + const sessionId = "ses_abc123" + const sessionUrl = `${baseUrl}/${sessionId}/session/${sessionId}` + const output1 = `opencode server listening on ${baseUrl}` + const output2 = `session created: ${sessionUrl}` + + expect(output1).toContain("opencode server listening on") + expect(output2).toContain("session created:") + expect(output2).toContain(sessionUrl) + }) +}) diff --git a/packages/opencode/test/cli/web.test.ts b/packages/opencode/test/cli/web.test.ts new file mode 100644 index 00000000000..a3074883b36 --- /dev/null +++ b/packages/opencode/test/cli/web.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test" + +describe("web command with --prompt", () => { + test("should use localhost URL when hostname is 0.0.0.0", () => { + const hostname = "0.0.0.0" + const port = 4096 + + // Simulate web command logic + const baseUrl = hostname === "0.0.0.0" ? `http://localhost:${port}` : `http://${hostname}:${port}` + + expect(baseUrl).toBe("http://localhost:4096") + expect(baseUrl).toContain("localhost") + expect(baseUrl).not.toContain("0.0.0.0") + }) + + test("should use server URL when hostname is not 0.0.0.0", () => { + const hostname = "127.0.0.1" + const port = 4096 + + // Simulate web command logic + const baseUrl = hostname === "0.0.0.0" ? `http://localhost:${port}` : `http://${hostname}:${port}` + + expect(baseUrl).toBe("http://127.0.0.1:4096") + expect(baseUrl).toContain("127.0.0.1") + }) + + test("should generate correct session URL format", () => { + const baseUrl = "http://localhost:4096" + const sessionId = "ses_test123" + const sessionUrl = `${baseUrl}/${sessionId}/session/${sessionId}` + + expect(baseUrl).toBe("http://localhost:4096") + expect(sessionUrl).toContain(sessionId) + }) + + test("should handle prompt parameter correctly", () => { + const prompt = "Test prompt for web command" + + expect(prompt).toBeDefined() + expect(prompt.length).toBeGreaterThan(0) + }) +}) From 4b26aa0c99f4386a555074384e1c00ac2f63af92 Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Thu, 8 Jan 2026 13:48:18 +0100 Subject: [PATCH 2/5] feat(cli): add --attach option to other commands Adds an --attach option similar to the attach command to the acp, web and server commands (where it proxies the remote server) --- packages/opencode/src/acp/agent.ts | 7 ++- packages/opencode/src/cli/cmd/acp.ts | 24 +++++++- packages/opencode/src/cli/cmd/serve.ts | 58 +++++++++++++++---- packages/opencode/src/cli/cmd/web.ts | 67 +++++++++++++++++----- packages/opencode/test/cli/acp.test.ts | 49 ++++++++++++++++ packages/opencode/test/cli/serve.test.ts | 72 ++++++++++++++++++++++++ packages/opencode/test/cli/web.test.ts | 72 ++++++++++++++++++++++-- 7 files changed, 316 insertions(+), 33 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 3517f5405e7..7eccea04589 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -417,7 +417,12 @@ export namespace ACP { .prompt({ sessionID: sessionId, directory, - message: this.config.initialPrompt, + parts: [ + { + type: "text", + text: this.config.initialPrompt, + }, + ], }) .catch((err) => { log.error("failed to send initial prompt", { error: err, sessionId }) diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index cfaa1ab37dd..ba79834e840 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -23,14 +23,27 @@ export const AcpCommand = cmd({ describe: "prompt to use", type: "string", }) + .option("attach", { + describe: "attach to existing server URL instead of starting new one", + type: "string", + }) }, handler: async (args) => { await bootstrap(process.cwd(), async () => { - const opts = await resolveNetworkOptions(args) - const server = Server.listen(opts) + let server: ReturnType | undefined + let baseUrl: string + + // If attach URL is provided, use it instead of starting a server + if (args.attach) { + baseUrl = args.attach + } else { + const opts = await resolveNetworkOptions(args) + server = Server.listen(opts) + baseUrl = `http://${server.hostname}:${server.port}` + } const sdk = createOpencodeClient({ - baseUrl: `http://${server.hostname}:${server.port}`, + baseUrl, }) const input = new WritableStream({ @@ -69,6 +82,11 @@ export const AcpCommand = cmd({ process.stdin.on("end", resolve) process.stdin.on("error", reject) }) + + // Only stop server if we started one + if (server) { + await server.stop() + } }) }, }) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 00fc9a10ceb..5cbf8796f13 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -3,28 +3,62 @@ import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "../../flag/flag" import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { Hono } from "hono" +import { proxy } from "hono/proxy" export const ServeCommand = cmd({ command: "serve", builder: (yargs) => { - return withNetworkOptions(yargs).option("prompt", { - describe: "prompt to use", - type: "string", - }) + return withNetworkOptions(yargs) + .option("prompt", { + describe: "prompt to use", + type: "string", + }) + .option("attach", { + describe: "attach to an existing OpenCode server", + type: "string", + }) }, describe: "starts a headless opencode server", handler: async (args) => { - if (!Flag.OPENCODE_SERVER_PASSWORD) { - console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") + let server: ReturnType | Awaited> | undefined + let baseUrl: string + let remoteUrl: string | undefined + + if (args.attach) { + remoteUrl = args.attach + const opts = await resolveNetworkOptions(args) + + // Create a proxy server that forwards to the remote server + const app = new Hono() + app.all("*", async (c) => { + const url = new URL(c.req.url) + const targetUrl = `${remoteUrl}${url.pathname}${url.search}` + return proxy(targetUrl, { + ...c.req, + }) + }) + + server = Bun.serve({ + hostname: opts.hostname, + port: opts.port, + fetch: app.fetch, + }) + + baseUrl = `http://${server.hostname}:${server.port}` + } else { + if (!Flag.OPENCODE_SERVER_PASSWORD) { + console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") + } + const opts = await resolveNetworkOptions(args) + server = Server.listen(opts) + baseUrl = `http://${server.hostname}:${server.port}` } - const opts = await resolveNetworkOptions(args) - const server = Server.listen(opts) - const baseUrl = `http://${server.hostname}:${server.port}` // If prompt is provided, create a session and send the prompt if (args.prompt) { const sdk = createOpencodeClient({ - baseUrl, + baseUrl: remoteUrl ?? baseUrl, }) const session = await sdk.session.create({ directory: process.cwd() }) @@ -51,6 +85,8 @@ export const ServeCommand = cmd({ } await new Promise(() => {}) - await server.stop() + if (server) { + await server.stop() + } }, }) diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index bdf1f291c8b..ffacd6b8f68 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -6,6 +6,8 @@ import { Flag } from "../../flag/flag" import open from "open" import { networkInterfaces } from "os" import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { Hono } from "hono" +import { proxy } from "hono/proxy" function getNetworkIPs() { const nets = networkInterfaces() @@ -32,28 +34,60 @@ function getNetworkIPs() { export const WebCommand = cmd({ command: "web", builder: (yargs) => { - return withNetworkOptions(yargs).option("prompt", { - describe: "prompt to use", - type: "string", - }) + return withNetworkOptions(yargs) + .option("prompt", { + describe: "prompt to use", + type: "string", + }) + .option("attach", { + describe: "attach to an existing OpenCode server", + type: "string", + }) }, describe: "start opencode server and open web interface", handler: async (args) => { - if (!Flag.OPENCODE_SERVER_PASSWORD) { - UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") + let server: ReturnType | Awaited> | undefined + let baseUrl: string + let opts: Awaited> | undefined + let remoteUrl: string | undefined + + if (args.attach) { + remoteUrl = args.attach + opts = await resolveNetworkOptions(args) + + // Create a proxy server that forwards to the remote server + const app = new Hono() + app.all("*", async (c) => { + const url = new URL(c.req.url) + const targetUrl = `${remoteUrl}${url.pathname}${url.search}` + return proxy(targetUrl, { + ...c.req, + }) + }) + + server = Bun.serve({ + hostname: opts.hostname, + port: opts.port, + fetch: app.fetch, + }) + + baseUrl = opts.hostname === "0.0.0.0" ? `http://localhost:${server.port}` : server.url.toString() + } else { + if (!Flag.OPENCODE_SERVER_PASSWORD) { + UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") + } + opts = await resolveNetworkOptions(args) + server = Server.listen(opts) + baseUrl = opts.hostname === "0.0.0.0" ? `http://localhost:${server.port}` : server.url.toString() } - const opts = await resolveNetworkOptions(args) - const server = Server.listen(opts) UI.empty() UI.println(UI.logo(" ")) UI.empty() - const baseUrl = opts.hostname === "0.0.0.0" ? `http://localhost:${server.port}` : server.url.toString() - // If prompt is provided, create a session and send the prompt if (args.prompt) { const sdk = createOpencodeClient({ - baseUrl, + baseUrl: remoteUrl ?? baseUrl, }) if (opts.mdns) { @@ -88,7 +122,10 @@ export const WebCommand = cmd({ // Open the session in browser open(sessionUrl).catch(() => {}) } else { - if (opts.hostname === "0.0.0.0") { + // Show server/proxy details (identical output for both) + if (!server) return + + if (opts!.hostname === "0.0.0.0") { // Show localhost for local access const localhostUrl = `http://localhost:${server.port}` UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl) @@ -113,12 +150,14 @@ export const WebCommand = cmd({ open(localhostUrl.toString()).catch(() => {}) } else { const displayUrl = server.url.toString() - UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl) + UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl) open(displayUrl).catch(() => {}) } } await new Promise(() => {}) - await server.stop() + if (server) { + await server.stop() + } }, }) diff --git a/packages/opencode/test/cli/acp.test.ts b/packages/opencode/test/cli/acp.test.ts index 28279f673ca..4e3ffcdd797 100644 --- a/packages/opencode/test/cli/acp.test.ts +++ b/packages/opencode/test/cli/acp.test.ts @@ -28,3 +28,52 @@ describe("ACP command with --prompt", () => { expect(config.initialPrompt).toBeUndefined() }) }) + +describe("ACP command with --attach", () => { + test("should connect SDK to existing server when --attach is specified", () => { + const attachUrl = "http://remote-server:4096" + const sdkBaseUrl = attachUrl + + expect(sdkBaseUrl).toBe("http://remote-server:4096") + // ACP connects to existing server, doesn't create proxy + }) + + test("should pass initialPrompt to ACP agent when using --attach with --prompt", () => { + const attachUrl = "http://remote-server:4096" + const initialPrompt = "Test prompt" + + const config: ACPConfig = { + sdk: {} as any, + initialPrompt, + } + + expect(config.initialPrompt).toBe("Test prompt") + // Prompt is sent to the attached server + }) + + test("should not start local server when --attach is specified", () => { + const attachUrl = "http://example.com:4096" + const shouldStartServer = !attachUrl + const sdkBaseUrl = attachUrl + + expect(shouldStartServer).toBe(false) + expect(sdkBaseUrl).toBe("http://example.com:4096") + }) + + test("should only stop server if local server was started", () => { + const withAttach = true + const serverStarted = !withAttach + const shouldStopServer = serverStarted + + expect(shouldStopServer).toBe(false) + // No server to stop when using --attach + }) + + test("ACP --attach does not create proxy (unlike web/serve)", () => { + const attachUrl = "http://remote-server:4096" + const createsProxy = false // ACP just connects, doesn't proxy + + expect(createsProxy).toBe(false) + // ACP command connects directly to remote server without proxy + }) +}) diff --git a/packages/opencode/test/cli/serve.test.ts b/packages/opencode/test/cli/serve.test.ts index c2af0602f64..7f040577579 100644 --- a/packages/opencode/test/cli/serve.test.ts +++ b/packages/opencode/test/cli/serve.test.ts @@ -46,3 +46,75 @@ describe("serve command with --prompt", () => { expect(output2).toContain(sessionUrl) }) }) + +describe("serve command with --attach", () => { + test("should create local proxy URL for display (not remote URL)", () => { + const remoteUrl = "http://remote-server:4096" + const localPort = 8080 + const localHostname = "localhost" + + // SDK connects to remote, but display shows local proxy + const sdkBaseUrl = remoteUrl + const displayBaseUrl = `http://${localHostname}:${localPort}` + + expect(sdkBaseUrl).toBe("http://remote-server:4096") + expect(displayBaseUrl).toBe("http://localhost:8080") + expect(displayBaseUrl).not.toContain("remote-server") + }) + + test("should display local proxy session URL when using --attach with --prompt", () => { + const remoteUrl = "http://remote-server:4096" + const localBaseUrl = "http://localhost:8080" + const sessionId = "ses_abc123" + + // Session created on remote but URL shown is local proxy + const displaySessionUrl = `${localBaseUrl}/${sessionId}` + + expect(displaySessionUrl).toBe("http://localhost:8080/ses_abc123") + expect(displaySessionUrl).toContain(localBaseUrl) + expect(displaySessionUrl).not.toContain("remote-server") + }) + + test("should create proxy server instead of OpenCode server when --attach is used", () => { + const remoteUrl = "http://remote-server:4096" + const localPort = 8080 + + // When --attach is used, we create a proxy not an OpenCode server + const isProxyServer = !!remoteUrl + const proxyListensOn = `http://localhost:${localPort}` + + expect(isProxyServer).toBe(true) + expect(proxyListensOn).toBe("http://localhost:8080") + }) + + test("should only stop server if one was started (proxy should stop too)", () => { + const withAttach = true + const serverStarted = true // Proxy server is started even with --attach + const shouldStopServer = serverStarted + + expect(shouldStopServer).toBe(true) + // Proxy server should be stopped when command exits + }) + + test("should output identical format with or without --attach", () => { + // Without --attach + const normalOutput = "opencode server listening on http://localhost:4096" + + // With --attach (should look identical) + const proxyOutput = "opencode server listening on http://localhost:4096" + + expect(normalOutput).toBe(proxyOutput) + // User should not be able to tell the difference from output + }) + + test("should respect local network flags when creating proxy", () => { + const remoteUrl = "http://remote-server:4096" + const localHostname = "localhost" + const localPort = 9000 + + // Proxy respects local network configuration + const proxyUrl = `http://${localHostname}:${localPort}` + + expect(proxyUrl).toBe("http://localhost:9000") + }) +}) diff --git a/packages/opencode/test/cli/web.test.ts b/packages/opencode/test/cli/web.test.ts index a3074883b36..b25eb48a537 100644 --- a/packages/opencode/test/cli/web.test.ts +++ b/packages/opencode/test/cli/web.test.ts @@ -14,11 +14,11 @@ describe("web command with --prompt", () => { }) test("should use server URL when hostname is not 0.0.0.0", () => { - const hostname = "127.0.0.1" - const port = 4096 + const getBaseUrl = (hostname: string, port: number) => { + return hostname === "0.0.0.0" ? `http://localhost:${port}` : `http://${hostname}:${port}` + } - // Simulate web command logic - const baseUrl = hostname === "0.0.0.0" ? `http://localhost:${port}` : `http://${hostname}:${port}` + const baseUrl = getBaseUrl("127.0.0.1", 4096) expect(baseUrl).toBe("http://127.0.0.1:4096") expect(baseUrl).toContain("127.0.0.1") @@ -40,3 +40,67 @@ describe("web command with --prompt", () => { expect(prompt.length).toBeGreaterThan(0) }) }) + +describe("web command with --attach", () => { + test("should create local proxy URL for display (not remote URL)", () => { + const remoteUrl = "http://remote-server:4096" + const localPort = 8080 + const localHostname = "localhost" + + // SDK connects to remote, but display shows local proxy + const sdkBaseUrl = remoteUrl + const displayBaseUrl = `http://${localHostname}:${localPort}` + + expect(sdkBaseUrl).toBe("http://remote-server:4096") + expect(displayBaseUrl).toBe("http://localhost:8080") + expect(displayBaseUrl).not.toContain("remote-server") + }) + + test("should display local proxy session URL when using --attach with --prompt", () => { + const remoteUrl = "http://remote-server:4096" + const localBaseUrl = "http://localhost:8080" + const sessionId = "ses_abc123" + + // Session created on remote but URL shown is local proxy + const displaySessionUrl = `${localBaseUrl}/${sessionId}` + + expect(displaySessionUrl).toBe("http://localhost:8080/ses_abc123") + expect(displaySessionUrl).toContain(localBaseUrl) + expect(displaySessionUrl).not.toContain("remote-server") + }) + + test("should create proxy server instead of OpenCode server when --attach is used", () => { + const remoteUrl = "http://remote-server:4096" + const localPort = 8080 + + // When --attach is used, we create a proxy not an OpenCode server + const isProxyServer = !!remoteUrl + const proxyListensOn = `http://localhost:${localPort}` + + expect(isProxyServer).toBe(true) + expect(proxyListensOn).toBe("http://localhost:8080") + }) + + test("should respect local network flags when creating proxy", () => { + const remoteUrl = "http://remote-server:4096" + const localHostname = "0.0.0.0" + const localPort = 9000 + + // Proxy respects local network configuration + const proxyUrl = + localHostname === "0.0.0.0" ? `http://localhost:${localPort}` : `http://${localHostname}:${localPort}` + + expect(proxyUrl).toBe("http://localhost:9000") + }) + + test("should output identical format with or without --attach", () => { + // Without --attach + const normalOutput = "http://localhost:4096" + + // With --attach (should look identical) + const proxyOutput = "http://localhost:4096" + + expect(normalOutput).toBe(proxyOutput) + // User should not be able to tell the difference from output + }) +}) From 651d6f2a58f4993fa7060933c4aac2e29543a7d2 Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Thu, 8 Jan 2026 14:07:50 +0100 Subject: [PATCH 3/5] chore(deps): ensure bun uses isolated install when global config uses hoisted Close #8890 --- bunfig.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bunfig.toml b/bunfig.toml index 36a21d9332a..119a6ebb373 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,6 +1,6 @@ [install] exact = true +linker = "isolated" [test] root = "./do-not-run-tests-from-root" - From 0c5caef50be8cb3ba34f89a6407a3d17c27a8644 Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Thu, 8 Jan 2026 15:01:18 +0100 Subject: [PATCH 4/5] feat(cli): support passing session URLs to attach command and options When attaching to a server, instead of starting a new session, allow resuming an existing one via URL (except for acp) Close #8890 --- packages/opencode/src/acp/agent.ts | 15 +- packages/opencode/src/acp/types.ts | 1 + packages/opencode/src/cli/cmd/serve.ts | 55 +++++--- packages/opencode/src/cli/cmd/tui/attach.ts | 9 +- .../src/cli/cmd/tui/routes/session/index.tsx | 14 ++ packages/opencode/src/cli/cmd/web.ts | 58 +++++--- .../opencode/src/util/parse-session-url.ts | 24 ++++ packages/opencode/test/cli/attach.test.ts | 128 ++++++++++++++++++ packages/opencode/test/cli/serve.test.ts | 69 ++++++++++ packages/opencode/test/cli/web.test.ts | 68 ++++++++++ .../test/util/parse-session-url.test.ts | 46 +++++++ 11 files changed, 440 insertions(+), 47 deletions(-) create mode 100644 packages/opencode/src/util/parse-session-url.ts create mode 100644 packages/opencode/test/util/parse-session-url.test.ts diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 7eccea04589..6fc0cafa8cb 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -397,9 +397,18 @@ export namespace ACP { try { const model = await defaultModel(this.config, directory) - // Store ACP session state - const state = await this.sessionManager.create(params.cwd, params.mcpServers, model) - const sessionId = state.id + let state: ACPSessionState + let sessionId: string + + // If sessionId is provided in config, use existing session + if (this.config.sessionId) { + sessionId = this.config.sessionId + state = await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) + } else { + // Store ACP session state + state = await this.sessionManager.create(params.cwd, params.mcpServers, model) + sessionId = state.id + } log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length }) diff --git a/packages/opencode/src/acp/types.ts b/packages/opencode/src/acp/types.ts index 5efee2d0bd8..cb121bc93a9 100644 --- a/packages/opencode/src/acp/types.ts +++ b/packages/opencode/src/acp/types.ts @@ -20,4 +20,5 @@ export interface ACPConfig { modelID: string } initialPrompt?: string + sessionId?: string } diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 5cbf8796f13..ed4634fafdc 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -5,6 +5,7 @@ import { Flag } from "../../flag/flag" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { Hono } from "hono" import { proxy } from "hono/proxy" +import { parseSessionUrl } from "@/util/parse-session-url" export const ServeCommand = cmd({ command: "serve", @@ -15,7 +16,7 @@ export const ServeCommand = cmd({ type: "string", }) .option("attach", { - describe: "attach to an existing OpenCode server", + describe: "attach to an existing OpenCode server or session URL", type: "string", }) }, @@ -24,9 +25,12 @@ export const ServeCommand = cmd({ let server: ReturnType | Awaited> | undefined let baseUrl: string let remoteUrl: string | undefined + let sessionId: string | undefined if (args.attach) { - remoteUrl = args.attach + const parsed = parseSessionUrl(args.attach) + remoteUrl = parsed.baseUrl + sessionId = parsed.sessionId const opts = await resolveNetworkOptions(args) // Create a proxy server that forwards to the remote server @@ -55,31 +59,42 @@ export const ServeCommand = cmd({ baseUrl = `http://${server.hostname}:${server.port}` } - // If prompt is provided, create a session and send the prompt - if (args.prompt) { + // If prompt is provided or sessionId is provided, create/use session + if (args.prompt || sessionId) { const sdk = createOpencodeClient({ baseUrl: remoteUrl ?? baseUrl, }) - const session = await sdk.session.create({ directory: process.cwd() }) - if (!session.data) throw new Error("Failed to create session") + let actualSessionId: string - // Send the prompt to the session (fire and forget) - sdk.session - .prompt({ - sessionID: session.data.id, - directory: process.cwd(), - parts: [ - { - type: "text", - text: args.prompt, - }, - ], - }) - .catch(() => {}) + if (sessionId) { + // Use existing session from URL + actualSessionId = sessionId + } else { + // Create new session + const session = await sdk.session.create({ directory: process.cwd() }) + if (!session.data) throw new Error("Failed to create session") + actualSessionId = session.data.id + } + + // Send the prompt to the session if provided (fire and forget) + if (args.prompt) { + sdk.session + .prompt({ + sessionID: actualSessionId, + directory: process.cwd(), + parts: [ + { + type: "text", + text: args.prompt, + }, + ], + }) + .catch(() => {}) + } console.log(`opencode server listening on ${baseUrl}`) - console.log(`session created: ${baseUrl}/${session.data.id}/session/${session.data.id}`) + console.log(`session created: ${baseUrl}/${actualSessionId}/session/${actualSessionId}`) } else { console.log(`opencode server listening on ${baseUrl}`) } diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index bfc2f1cd08d..a88d24224eb 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -1,6 +1,7 @@ import { cmd } from "../cmd" import { tui } from "./app" import { iife } from "@/util/iife" +import { parseSessionUrl } from "@/util/parse-session-url" export const AttachCommand = cmd({ command: "attach ", @@ -9,7 +10,7 @@ export const AttachCommand = cmd({ yargs .positional("url", { type: "string", - describe: "http://localhost:4096", + describe: "http://localhost:4096 or http://localhost:4096/ses_xxx/session/ses_xxx", demandOption: true, }) .option("dir", { @@ -34,10 +35,12 @@ export const AttachCommand = cmd({ return piped ? piped + "\n" + args.prompt : args.prompt }) + const { baseUrl, sessionId } = parseSessionUrl(args.url) + await tui({ - url: args.url, + url: baseUrl, args: { - sessionID: args.session, + sessionID: args.session ?? sessionId, prompt, }, directory: args.dir ? process.cwd() : undefined, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 1d64a2ff156..4c68ba49c35 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -70,6 +70,7 @@ import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" import { Filesystem } from "@/util/filesystem" import { Global } from "@/global" +import { useArgs } from "../../context/args" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" @@ -188,6 +189,7 @@ export function Session() { const toast = useToast() const sdk = useSDK() + const args = useArgs() // Handle initial prompt from fork createEffect(() => { @@ -213,6 +215,18 @@ export function Session() { } }) + // Handle prompt from CLI args (--prompt with session URL) + // Only submit if we attached to an existing session (args.sessionID was provided) + let promptSubmitted = false + createEffect(() => { + if (promptSubmitted || !args.prompt || !args.sessionID || !prompt) return + if (!sync.session.get(route.sessionID)) return // Wait for session to load + + promptSubmitted = true + prompt.set({ input: args.prompt, parts: [] }) + prompt.submit() + }) + let scroll: ScrollBoxRenderable let prompt: PromptRef const keybind = useKeybind() diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index ffacd6b8f68..88f6164213d 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -8,6 +8,7 @@ import { networkInterfaces } from "os" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { Hono } from "hono" import { proxy } from "hono/proxy" +import { parseSessionUrl } from "@/util/parse-session-url" function getNetworkIPs() { const nets = networkInterfaces() @@ -40,7 +41,7 @@ export const WebCommand = cmd({ type: "string", }) .option("attach", { - describe: "attach to an existing OpenCode server", + describe: "attach to an existing OpenCode server or session URL", type: "string", }) }, @@ -50,9 +51,12 @@ export const WebCommand = cmd({ let baseUrl: string let opts: Awaited> | undefined let remoteUrl: string | undefined + let sessionId: string | undefined if (args.attach) { - remoteUrl = args.attach + const parsed = parseSessionUrl(args.attach) + remoteUrl = parsed.baseUrl + sessionId = parsed.sessionId opts = await resolveNetworkOptions(args) // Create a proxy server that forwards to the remote server @@ -84,8 +88,8 @@ export const WebCommand = cmd({ UI.println(UI.logo(" ")) UI.empty() - // If prompt is provided, create a session and send the prompt - if (args.prompt) { + // If prompt is provided or sessionId is provided, create/use session + if (args.prompt || sessionId) { const sdk = createOpencodeClient({ baseUrl: remoteUrl ?? baseUrl, }) @@ -98,23 +102,35 @@ export const WebCommand = cmd({ ) } - const session = await sdk.session.create({ directory: process.cwd() }) - if (!session.data) throw new Error("Failed to create session") - const sessionUrl = `${baseUrl}/${session.data.id}/session/${session.data.id}` - - // Send the prompt to the session (fire and forget) - sdk.session - .prompt({ - sessionID: session.data.id, - directory: process.cwd(), - parts: [ - { - type: "text", - text: args.prompt, - }, - ], - }) - .catch(() => {}) + let actualSessionId: string + + if (sessionId) { + // Use existing session from URL + actualSessionId = sessionId + } else { + // Create new session + const session = await sdk.session.create({ directory: process.cwd() }) + if (!session.data) throw new Error("Failed to create session") + actualSessionId = session.data.id + } + + const sessionUrl = `${baseUrl}/${actualSessionId}/session/${actualSessionId}` + + // Send the prompt to the session if provided (fire and forget) + if (args.prompt) { + sdk.session + .prompt({ + sessionID: actualSessionId, + directory: process.cwd(), + parts: [ + { + type: "text", + text: args.prompt, + }, + ], + }) + .catch(() => {}) + } UI.println(UI.Style.TEXT_INFO_BOLD + " Session URL: ", UI.Style.TEXT_NORMAL, sessionUrl) UI.empty() diff --git a/packages/opencode/src/util/parse-session-url.ts b/packages/opencode/src/util/parse-session-url.ts new file mode 100644 index 00000000000..624ba6752c0 --- /dev/null +++ b/packages/opencode/src/util/parse-session-url.ts @@ -0,0 +1,24 @@ +/** + * Parses a session URL or base URL and extracts the base URL and optional session ID + * + * Supports formats: + * - http://localhost:4096 -> { baseUrl: "http://localhost:4096", sessionId: undefined } + * - http://localhost:4096/ses_123 -> { baseUrl: "http://localhost:4096", sessionId: "ses_123" } + * - http://localhost:4096/ses_123/session/ses_123 -> { baseUrl: "http://localhost:4096", sessionId: "ses_123" } + */ +export function parseSessionUrl(url: string): { baseUrl: string; sessionId?: string } { + const sessionMatch = url.match(/^(https?:\/\/[^\/]+)\/(ses_[a-zA-Z0-9]+)/) + + if (sessionMatch) { + return { + baseUrl: sessionMatch[1], + sessionId: sessionMatch[2], + } + } + + // No session ID, just return the base URL + return { + baseUrl: url.replace(/\/$/, ""), // Remove trailing slash + sessionId: undefined, + } +} diff --git a/packages/opencode/test/cli/attach.test.ts b/packages/opencode/test/cli/attach.test.ts index e7b17255980..68ba2e873cb 100644 --- a/packages/opencode/test/cli/attach.test.ts +++ b/packages/opencode/test/cli/attach.test.ts @@ -1,4 +1,132 @@ import { describe, expect, test } from "bun:test" +import { parseSessionUrl } from "../../src/util/parse-session-url" + +describe("attach command with session URL", () => { + test("should extract session ID from full session URL and pass to tui", () => { + const url = "http://localhost:4096/ses_abc123/session/ses_abc123" + const sessionOption = undefined + + // Simulate attach command logic + const { baseUrl, sessionId } = parseSessionUrl(url) + const finalSessionId = sessionOption ?? sessionId + + const tuiArgs = { + url: baseUrl, + args: { + sessionID: finalSessionId, + prompt: undefined, + }, + } + + expect(tuiArgs.url).toBe("http://localhost:4096") + expect(tuiArgs.args.sessionID).toBe("ses_abc123") + }) + + test("should extract session ID from short session URL format", () => { + const url = "http://localhost:4096/ses_xyz789" + const sessionOption = undefined + + const { baseUrl, sessionId } = parseSessionUrl(url) + const finalSessionId = sessionOption ?? sessionId + + const tuiArgs = { + url: baseUrl, + args: { + sessionID: finalSessionId, + prompt: undefined, + }, + } + + expect(tuiArgs.url).toBe("http://localhost:4096") + expect(tuiArgs.args.sessionID).toBe("ses_xyz789") + }) + + test("should prefer --session option over URL session ID", () => { + const url = "http://localhost:4096/ses_fromurl" + const sessionOption = "ses_fromoption" + + const { baseUrl, sessionId } = parseSessionUrl(url) + const finalSessionId = sessionOption ?? sessionId + + const tuiArgs = { + url: baseUrl, + args: { + sessionID: finalSessionId, + prompt: undefined, + }, + } + + expect(tuiArgs.args.sessionID).toBe("ses_fromoption") + }) + + test("should handle base URL without session ID", () => { + const url = "http://localhost:4096" + const sessionOption = undefined + + const { baseUrl, sessionId } = parseSessionUrl(url) + const finalSessionId = sessionOption ?? sessionId + + const tuiArgs = { + url: baseUrl, + args: { + sessionID: finalSessionId, + prompt: undefined, + }, + } + + expect(tuiArgs.url).toBe("http://localhost:4096") + expect(tuiArgs.args.sessionID).toBeUndefined() + }) + + test("should combine session URL with prompt", () => { + const url = "http://localhost:4096/ses_abc123" + const promptArg = "Continue work" + const piped = undefined + + const { baseUrl, sessionId } = parseSessionUrl(url) + const prompt = piped ? (promptArg ? piped + "\n" + promptArg : piped) : promptArg + + const tuiArgs = { + url: baseUrl, + args: { + sessionID: sessionId, + prompt, + }, + } + + expect(tuiArgs.url).toBe("http://localhost:4096") + expect(tuiArgs.args.sessionID).toBe("ses_abc123") + expect(tuiArgs.args.prompt).toBe("Continue work") + }) + + test("should submit prompt when session route is loaded with args.prompt and args.sessionID", () => { + // Simulate session route receiving args with both prompt and sessionID + const args = { + sessionID: "ses_abc123", + prompt: "Continue work", + } + + // Only submit if sessionID was provided (attached to existing session) + const shouldSubmitPrompt = !!args.prompt && !!args.sessionID + const promptInput = { input: args.prompt, parts: [] } + + expect(shouldSubmitPrompt).toBe(true) + expect(promptInput.input).toBe("Continue work") + }) + + test("should NOT submit prompt in session route when no sessionID (new session from home)", () => { + // Simulate session route for a newly created session (no args.sessionID) + const args = { + sessionID: undefined, + prompt: "Continue work", + } + + // Should NOT submit because sessionID was not provided (home route already handled it) + const shouldSubmitPrompt = !!args.prompt && !!args.sessionID + + expect(shouldSubmitPrompt).toBe(false) + }) +}) describe("attach command with --prompt", () => { test("should combine piped input with prompt argument", () => { diff --git a/packages/opencode/test/cli/serve.test.ts b/packages/opencode/test/cli/serve.test.ts index 7f040577579..328a5a6d847 100644 --- a/packages/opencode/test/cli/serve.test.ts +++ b/packages/opencode/test/cli/serve.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { parseSessionUrl } from "../../src/util/parse-session-url" describe("serve command with --prompt", () => { test("should generate correct base URL", () => { @@ -117,4 +118,72 @@ describe("serve command with --attach", () => { expect(proxyUrl).toBe("http://localhost:9000") }) + + test("should use existing session when session URL is provided in --attach", () => { + const attachUrl = "http://remote:4096/ses_existing/session/ses_existing" + const localPort = 8080 + + // Simulate serve command logic + const { baseUrl: remoteUrl, sessionId } = parseSessionUrl(attachUrl) + const localBaseUrl = `http://localhost:${localPort}` + + const shouldCreateNewSession = !sessionId + let actualSessionId = sessionId ?? "would_create_new" + + const output = `session created: ${localBaseUrl}/${actualSessionId}/session/${actualSessionId}` + + expect(shouldCreateNewSession).toBe(false) + expect(actualSessionId).toBe("ses_existing") + expect(output).toBe("session created: http://localhost:8080/ses_existing/session/ses_existing") + }) + + test("should create new session when no session ID in attach URL", () => { + const attachUrl = "http://remote:4096" + const localPort = 8080 + + const { baseUrl: remoteUrl, sessionId } = parseSessionUrl(attachUrl) + const localBaseUrl = `http://localhost:${localPort}` + + const shouldCreateNewSession = !sessionId + + expect(shouldCreateNewSession).toBe(true) + expect(sessionId).toBeUndefined() + }) + + test("should send prompt to existing session from URL", () => { + const attachUrl = "http://localhost:4096/ses_existing" + const promptArg = "Continue work" + + // Simulate serve command logic + const { baseUrl, sessionId } = parseSessionUrl(attachUrl) + + let actualSessionId: string + if (sessionId) { + actualSessionId = sessionId + } else { + actualSessionId = "new_ses_123" // Would create new session + } + + const shouldSendPrompt = !!promptArg + const targetSessionId = actualSessionId + + expect(actualSessionId).toBe("ses_existing") + expect(shouldSendPrompt).toBe(true) + expect(targetSessionId).toBe("ses_existing") + }) + + test("should print session URL even when session already exists", () => { + const attachUrl = "http://remote:4096/ses_abc123" + const localPort = 8080 + + const { baseUrl: remoteUrl, sessionId } = parseSessionUrl(attachUrl) + const localBaseUrl = `http://localhost:${localPort}` + + const actualSessionId = sessionId! + const output = `session created: ${localBaseUrl}/${actualSessionId}/session/${actualSessionId}` + + expect(output).toBe("session created: http://localhost:8080/ses_abc123/session/ses_abc123") + expect(output).toContain("session created:") + expect(output).not.toContain("remote") + }) }) diff --git a/packages/opencode/test/cli/web.test.ts b/packages/opencode/test/cli/web.test.ts index b25eb48a537..0f6eb014b99 100644 --- a/packages/opencode/test/cli/web.test.ts +++ b/packages/opencode/test/cli/web.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { parseSessionUrl } from "../../src/util/parse-session-url" describe("web command with --prompt", () => { test("should use localhost URL when hostname is 0.0.0.0", () => { @@ -103,4 +104,71 @@ describe("web command with --attach", () => { expect(normalOutput).toBe(proxyOutput) // User should not be able to tell the difference from output }) + + test("should use existing session when session URL is provided in --attach", () => { + const attachUrl = "http://remote:4096/ses_existing/session/ses_existing" + const localPort = 8080 + + // Simulate web command logic + const { baseUrl: remoteUrl, sessionId } = parseSessionUrl(attachUrl) + const localBaseUrl = `http://localhost:${localPort}` + + const shouldCreateNewSession = !sessionId + let actualSessionId = sessionId ?? "would_create_new" + + const displaySessionUrl = `${localBaseUrl}/${actualSessionId}/session/${actualSessionId}` + + expect(shouldCreateNewSession).toBe(false) + expect(actualSessionId).toBe("ses_existing") + expect(displaySessionUrl).toBe("http://localhost:8080/ses_existing/session/ses_existing") + }) + + test("should create new session when no session ID in attach URL", () => { + const attachUrl = "http://remote:4096" + const localPort = 8080 + + const { baseUrl: remoteUrl, sessionId } = parseSessionUrl(attachUrl) + const localBaseUrl = `http://localhost:${localPort}` + + const shouldCreateNewSession = !sessionId + + expect(shouldCreateNewSession).toBe(true) + expect(sessionId).toBeUndefined() + }) + + test("should send prompt to existing session from URL", () => { + const attachUrl = "http://localhost:4096/ses_existing" + const promptArg = "Continue work" + + // Simulate web command logic + const { baseUrl, sessionId } = parseSessionUrl(attachUrl) + + let actualSessionId: string + if (sessionId) { + actualSessionId = sessionId + } else { + actualSessionId = "new_ses_123" // Would create new session + } + + const shouldSendPrompt = !!promptArg + const targetSessionId = actualSessionId + + expect(actualSessionId).toBe("ses_existing") + expect(shouldSendPrompt).toBe(true) + expect(targetSessionId).toBe("ses_existing") + }) + + test("should open session in browser with local proxy URL", () => { + const attachUrl = "http://remote:4096/ses_abc123" + const localPort = 8080 + + const { baseUrl: remoteUrl, sessionId } = parseSessionUrl(attachUrl) + const localBaseUrl = `http://localhost:${localPort}` + + const actualSessionId = sessionId! + const browserUrl = `${localBaseUrl}/${actualSessionId}/session/${actualSessionId}` + + expect(browserUrl).toBe("http://localhost:8080/ses_abc123/session/ses_abc123") + expect(browserUrl).not.toContain("remote") + }) }) diff --git a/packages/opencode/test/util/parse-session-url.test.ts b/packages/opencode/test/util/parse-session-url.test.ts new file mode 100644 index 00000000000..cdf84a51019 --- /dev/null +++ b/packages/opencode/test/util/parse-session-url.test.ts @@ -0,0 +1,46 @@ +import { describe, test, expect } from "bun:test" +import { parseSessionUrl } from "../../src/util/parse-session-url" + +describe("parseSessionUrl", () => { + test("should parse base URL without session", () => { + const result = parseSessionUrl("http://localhost:4096") + expect(result.baseUrl).toBe("http://localhost:4096") + expect(result.sessionId).toBeUndefined() + }) + + test("should parse base URL with trailing slash", () => { + const result = parseSessionUrl("http://localhost:4096/") + expect(result.baseUrl).toBe("http://localhost:4096") + expect(result.sessionId).toBeUndefined() + }) + + test("should parse session URL with session ID only", () => { + const result = parseSessionUrl("http://localhost:4096/ses_abc123") + expect(result.baseUrl).toBe("http://localhost:4096") + expect(result.sessionId).toBe("ses_abc123") + }) + + test("should parse full session URL format", () => { + const result = parseSessionUrl("http://localhost:4096/ses_abc123/session/ses_abc123") + expect(result.baseUrl).toBe("http://localhost:4096") + expect(result.sessionId).toBe("ses_abc123") + }) + + test("should handle HTTPS URLs", () => { + const result = parseSessionUrl("https://example.com:8080/ses_xyz789") + expect(result.baseUrl).toBe("https://example.com:8080") + expect(result.sessionId).toBe("ses_xyz789") + }) + + test("should handle alphanumeric session IDs", () => { + const result = parseSessionUrl("http://localhost:4096/ses_4623efa19ffeMSpTJuf6uJ2n1r") + expect(result.baseUrl).toBe("http://localhost:4096") + expect(result.sessionId).toBe("ses_4623efa19ffeMSpTJuf6uJ2n1r") + }) + + test("should handle remote server URLs", () => { + const result = parseSessionUrl("http://remote-server:5096") + expect(result.baseUrl).toBe("http://remote-server:5096") + expect(result.sessionId).toBeUndefined() + }) +}) From b179b8a8df49d4005b51f35dbe1c0d391545f369 Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Fri, 16 Jan 2026 16:12:17 +0100 Subject: [PATCH 5/5] feat(acp): add CLI option to continue existing session --- packages/opencode/src/acp/agent.ts | 53 ++++++-- packages/opencode/src/cli/cmd/acp.ts | 16 ++- packages/opencode/test/cli/acp.test.ts | 170 +++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 6fc0cafa8cb..9406a4ee4f0 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -394,21 +394,48 @@ export namespace ACP { async newSession(params: NewSessionRequest) { const directory = params.cwd - try { - const model = await defaultModel(this.config, directory) - let state: ACPSessionState - let sessionId: string + // If sessionId is provided in config, delegate to loadSession with additional prompt handling + if (this.config.sessionId) { + const result = await this.loadSession({ + sessionId: this.config.sessionId, + cwd: params.cwd, + mcpServers: params.mcpServers, + }) - // If sessionId is provided in config, use existing session - if (this.config.sessionId) { - sessionId = this.config.sessionId - state = await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) - } else { - // Store ACP session state - state = await this.sessionManager.create(params.cwd, params.mcpServers, model) - sessionId = state.id + // Send initial prompt if provided (after replay completes) + if (this.config.initialPrompt) { + this.sdk.session + .prompt({ + sessionID: this.config.sessionId, + directory, + parts: [ + { + type: "text", + text: this.config.initialPrompt, + }, + ], + }) + .catch((err) => { + log.error("failed to send initial prompt", { error: err, sessionId: this.config.sessionId }) + }) + } + + return { + sessionId: this.config.sessionId, + models: result.models, + modes: result.modes, + _meta: {}, } + } + + // Normal new session creation + try { + const model = await defaultModel(this.config, directory) + + // Store ACP session state + const state = await this.sessionManager.create(params.cwd, params.mcpServers, model) + const sessionId = state.id log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length }) @@ -429,7 +456,7 @@ export namespace ACP { parts: [ { type: "text", - text: this.config.initialPrompt, + text: this.config.initialPrompt!, }, ], }) diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index ba79834e840..6409fd371e9 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -6,6 +6,7 @@ import { ACP } from "@/acp/agent" import { Server } from "@/server/server" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { withNetworkOptions, resolveNetworkOptions } from "../network" +import { parseSessionUrl } from "@/util/parse-session-url" const log = Log.create({ service: "acp-command" }) @@ -24,22 +25,31 @@ export const AcpCommand = cmd({ type: "string", }) .option("attach", { - describe: "attach to existing server URL instead of starting new one", + describe: "attach to existing server URL or session URL instead of starting new one", type: "string", }) + .option("session", { + describe: "session id to continue", + type: "string", + alias: ["s"], + }) }, handler: async (args) => { await bootstrap(process.cwd(), async () => { let server: ReturnType | undefined let baseUrl: string + let sessionId: string | undefined // If attach URL is provided, use it instead of starting a server if (args.attach) { - baseUrl = args.attach + const parsed = parseSessionUrl(args.attach) + baseUrl = parsed.baseUrl + sessionId = args.session ?? parsed.sessionId } else { const opts = await resolveNetworkOptions(args) server = Server.listen(opts) baseUrl = `http://${server.hostname}:${server.port}` + sessionId = args.session } const sdk = createOpencodeClient({ @@ -73,7 +83,7 @@ export const AcpCommand = cmd({ const agent = await ACP.init({ sdk }) new AgentSideConnection((conn) => { - return agent.create(conn, { sdk, initialPrompt: args.prompt }) + return agent.create(conn, { sdk, initialPrompt: args.prompt, sessionId }) }, stream) log.info("setup connection") diff --git a/packages/opencode/test/cli/acp.test.ts b/packages/opencode/test/cli/acp.test.ts index 4e3ffcdd797..2753bb142eb 100644 --- a/packages/opencode/test/cli/acp.test.ts +++ b/packages/opencode/test/cli/acp.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import type { ACPConfig } from "../../src/acp/types" +import { parseSessionUrl } from "../../src/util/parse-session-url" describe("ACP command with --prompt", () => { test("ACPConfig should accept initialPrompt parameter", () => { @@ -76,4 +77,173 @@ describe("ACP command with --attach", () => { expect(createsProxy).toBe(false) // ACP command connects directly to remote server without proxy }) + + test("should extract session ID from session URL when using --attach", () => { + const attachUrl = "http://localhost:4096/ses_abc123/session/ses_abc123" + + // Simulate acp command logic + const { baseUrl, sessionId } = parseSessionUrl(attachUrl) + + const config: ACPConfig = { + sdk: {} as any, + sessionId, + } + + expect(baseUrl).toBe("http://localhost:4096") + expect(sessionId).toBe("ses_abc123") + expect(config.sessionId).toBe("ses_abc123") + }) + + test("should extract session ID from short session URL format", () => { + const attachUrl = "http://localhost:4096/ses_xyz789" + + const { baseUrl, sessionId } = parseSessionUrl(attachUrl) + + const config: ACPConfig = { + sdk: {} as any, + sessionId, + } + + expect(baseUrl).toBe("http://localhost:4096") + expect(sessionId).toBe("ses_xyz789") + expect(config.sessionId).toBe("ses_xyz789") + }) + + test("should handle base URL without session ID", () => { + const attachUrl = "http://localhost:4096" + + const { baseUrl, sessionId } = parseSessionUrl(attachUrl) + + const config: ACPConfig = { + sdk: {} as any, + sessionId, + } + + expect(baseUrl).toBe("http://localhost:4096") + expect(sessionId).toBeUndefined() + expect(config.sessionId).toBeUndefined() + }) + + test("should combine session URL with prompt in ACP config", () => { + const attachUrl = "http://localhost:4096/ses_abc123" + const promptArg = "Continue work" + + const { baseUrl, sessionId } = parseSessionUrl(attachUrl) + + const config: ACPConfig = { + sdk: {} as any, + sessionId, + initialPrompt: promptArg, + } + + expect(baseUrl).toBe("http://localhost:4096") + expect(config.sessionId).toBe("ses_abc123") + expect(config.initialPrompt).toBe("Continue work") + }) +}) + +describe("ACP command with --session option", () => { + test("should prefer --session option over session ID from URL", () => { + const attachUrl = "http://localhost:4096/ses_fromurl" + const sessionOption = "ses_fromoption" + + const { baseUrl, sessionId } = parseSessionUrl(attachUrl) + const finalSessionId = sessionOption ?? sessionId + + const config: ACPConfig = { + sdk: {} as any, + sessionId: finalSessionId, + } + + expect(baseUrl).toBe("http://localhost:4096") + expect(config.sessionId).toBe("ses_fromoption") + expect(finalSessionId).toBe("ses_fromoption") + }) + + test("should use session ID from URL when --session not provided", () => { + const attachUrl = "http://localhost:4096/ses_fromurl" + const sessionOption = undefined + + const { baseUrl, sessionId } = parseSessionUrl(attachUrl) + const finalSessionId = sessionOption ?? sessionId + + const config: ACPConfig = { + sdk: {} as any, + sessionId: finalSessionId, + } + + expect(config.sessionId).toBe("ses_fromurl") + }) + + test("should use --session option without --attach (local server)", () => { + const sessionOption = "ses_local123" + + const config: ACPConfig = { + sdk: {} as any, + sessionId: sessionOption, + } + + expect(config.sessionId).toBe("ses_local123") + }) + + test("should handle --session with --prompt", () => { + const sessionOption = "ses_abc123" + const promptArg = "Continue work" + + const config: ACPConfig = { + sdk: {} as any, + sessionId: sessionOption, + initialPrompt: promptArg, + } + + expect(config.sessionId).toBe("ses_abc123") + expect(config.initialPrompt).toBe("Continue work") + }) + + test("should replay messages before sending prompt when session ID is provided", () => { + const sessionId = "ses_abc123" + const promptArg = "New prompt after replay" + + // Simulate the ACP agent behavior + const shouldReplayMessages = !!sessionId + const shouldSendPrompt = !!promptArg + + // Message replay should happen BEFORE prompt is sent + const executionOrder = [] + if (shouldReplayMessages) { + executionOrder.push("replay_messages") + } + if (shouldSendPrompt) { + executionOrder.push("send_prompt") + } + + expect(executionOrder).toEqual(["replay_messages", "send_prompt"]) + expect(shouldReplayMessages).toBe(true) + expect(shouldSendPrompt).toBe(true) + }) + + test("should send prompt to new session when only --prompt is provided (no --session)", () => { + const sessionOption = undefined + const promptArg = "Initial prompt for new session" + + const config: ACPConfig = { + sdk: {} as any, + sessionId: sessionOption, + initialPrompt: promptArg, + } + + // Should create new session (no sessionId) + expect(config.sessionId).toBeUndefined() + // Should send prompt + expect(config.initialPrompt).toBe("Initial prompt for new session") + + // When newSession is called without sessionId, it should: + // 1. Create new session + // 2. Send initialPrompt to the new session + const shouldCreateNewSession = !config.sessionId + const shouldSendPrompt = !!config.initialPrompt + + expect(shouldCreateNewSession).toBe(true) + expect(shouldSendPrompt).toBe(true) + }) })