From ec0dac40f50f920b9dc3518f3ce012f43d86037c Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 23:38:18 +0800 Subject: [PATCH 1/5] chore(specgit): bind delivery for issue 404 --- .specgit.yaml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 63243e620..9dbf6d7a7 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,9 +1,7 @@ version: 1 -delivery: release-train-remediation +delivery: headless-init-does context: kind: branch - branch: fix/401-release-train-remediation + branch: fix/404-headless-init-does issues: - - 401 - - 402 -pr: 403 + - 404 From 40d5104850a25735c243d4609b5aaed8df9ca9cf Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 21 Aug 2026 01:10:19 +0800 Subject: [PATCH 2/5] fix(opencode): route headless slash commands so /init stamps project initialization --- .../instance/httpapi/handlers/session.ts | 55 ++++++- .../test/cli/run/headless-init.test.ts | 150 ++++++++++++++++++ 2 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/test/cli/run/headless-init.test.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 111268890..3c7cb06b0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -48,11 +48,33 @@ const tryParseJson = (text: string) => catch: () => new HttpApiError.BadRequest({}), }) +// #404: headless clients (`opencode run "/init"`, SDK text prompts) deliver +// slash commands as plain text parts to POST /session/:id/message. The TUI +// routes them client-side before calling /command, but nothing server-side +// did, so SessionPrompt.command never ran and Command.Event.Executed never +// fired (project.time_initialized stayed NULL). Mirror the TUI's parse — +// first line carries /command + args, later lines join the arguments — and +// only ever route single-text-part prompts. +function parseCommandPayload(payload: typeof PromptPayload.Type) { + const part = payload.parts.length === 1 ? payload.parts[0] : undefined + const text = part?.type === "text" && part.text.startsWith("/") ? part.text : undefined + if (!text) return undefined + const firstLineEnd = text.indexOf("\n") + const firstLine = firstLineEnd === -1 ? text : text.slice(0, firstLineEnd) + const [token, ...firstLineArgs] = firstLine.split(" ") + const rest = firstLineEnd === -1 ? "" : text.slice(firstLineEnd + 1) + return { + command: token.slice(1), + arguments: firstLineArgs.join(" ") + (rest ? "\n" + rest : ""), + } +} + export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) => Effect.gen(function* () { const session = yield* Session.Service const shareSvc = yield* SessionShare.Service const promptSvc = yield* SessionPrompt.Service + const commands = yield* Command.Service const revertSvc = yield* SessionRevert.Service const compactSvc = yield* SessionCompaction.Service const runState = yield* SessionRunState.Service @@ -314,17 +336,36 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", return true }) - const prompt = Effect.fn("SessionHttpApi.prompt")(function* (ctx: { + // Text that names a registered command routes through SessionPrompt.command + // so headless entrypoints get the same Command.Event.Executed lifecycle + // (incl. project init stamping) as the TUI; a registry miss falls through + // to a plain prompt turn, preserving existing text-prompt semantics. + const promptOrCommand = Effect.fn("SessionHttpApi.promptOrCommand")(function* (ctx: { params: { sessionID: SessionID } payload: typeof PromptPayload.Type }) { - yield* requireSession(ctx.params.sessionID) - const message = yield* promptSvc - .prompt({ - ...ctx.payload, + const parsed = parseCommandPayload(ctx.payload) + const cmd = parsed ? yield* commands.get(parsed.command) : undefined + if (parsed && cmd) { + return yield* promptSvc.command({ sessionID: ctx.params.sessionID, + command: parsed.command, + arguments: parsed.arguments, + messageID: ctx.payload.messageID, + agent: ctx.payload.agent, + variant: ctx.payload.variant, + ...(ctx.payload.model ? { model: `${ctx.payload.model.providerID}/${ctx.payload.model.modelID}` } : {}), }) - .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + } + return yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }) + }) + + const prompt = Effect.fn("SessionHttpApi.prompt")(function* (ctx: { + params: { sessionID: SessionID } + payload: typeof PromptPayload.Type + }) { + yield* requireSession(ctx.params.sessionID) + const message = yield* promptOrCommand(ctx).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) return HttpServerResponse.stream(Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), { contentType: "application/json", }) @@ -335,7 +376,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", payload: typeof PromptPayload.Type }) { yield* requireSession(ctx.params.sessionID) - yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe( + yield* promptOrCommand(ctx).pipe( Effect.catchCause((cause) => Effect.gen(function* () { yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause }) diff --git a/packages/opencode/test/cli/run/headless-init.test.ts b/packages/opencode/test/cli/run/headless-init.test.ts new file mode 100644 index 000000000..3ff394e04 --- /dev/null +++ b/packages/opencode/test/cli/run/headless-init.test.ts @@ -0,0 +1,150 @@ +// Regression test for #404: headless /init never stamped project.time_initialized, +// leaving MEMORY fail-closed inert. Root cause: `opencode run "/init"` and serve +// text prompts deliver "/init" as a plain text part at POST /session/:id/message — +// slash routing existed only in the TUI (client-side), so SessionPrompt.command +// never ran, Command.Event.Executed never fired, and the project init listener in +// src/project/project.ts never stamped the row. +// +// Fix under test: the server-side prompt endpoints route single-text-part +// prompts that name a registered command through SessionPrompt.command. +// +// Harness notes (all arms): +// - The project row only exists for git worktrees — a bare temp dir resolves to +// the global project (src/project/project.ts fromDirectory), so each arm +// git-inits the fixture home first. +// - test/preload.ts sets OPENCODE_DB=":memory:" globally and the CLI fixture +// spawns children with the runner env merged in; overriding OPENCODE_DB to "" +// (falsy → default path) restores the file-backed DB under the isolated home. +// - run.ts resolves its directory from process.env.PWD (leaked from the runner +// by the fixture), so run arms pin PWD to the fixture home. +// - The row's worktree is Filesystem.resolve'd (realpath); on macOS the fixture +// home can still be a symlinked path (/var → /private/var), so both +// spellings are accepted when matching. +import { describe, expect } from "bun:test" +import { Database } from "bun:sqlite" +import { existsSync, realpathSync } from "node:fs" +import path from "node:path" +import { Effect, Schema } from "effect" +import { HttpBody, HttpClient } from "effect/unstable/http" +import { cliIt } from "../../lib/cli-process" +import { pollWithTimeout } from "../../lib/effect" + +const dbPath = (home: string) => path.join(home, ".local/share/opencode/opencode-local.db") + +const ProjectRow = Schema.Struct({ + worktree: Schema.String, + time_initialized: Schema.NullOr(Schema.Number), +}) + +const SessionRef = Schema.Struct({ id: Schema.String }) + +const gitInit = (home: string) => Effect.promise(() => Bun.$`git -C ${home} init -q`.quiet().text()) + +function worktreeCandidates(home: string) { + try { + return [home, realpathSync(home)] + } catch { + return [home] + } +} + +// Returns time_initialized for the fixture worktree, or undefined while the DB +// file / row / stamp is not there yet. +const stampOf = (home: string) => + Effect.sync(() => { + if (!existsSync(dbPath(home))) return undefined + const candidates = worktreeCandidates(home) + const db = new Database(dbPath(home), { readonly: true }) + try { + const rows = Schema.decodeUnknownSync(Schema.Array(ProjectRow))( + db.query("SELECT worktree, time_initialized FROM project").all(), + ) + return rows.find((row) => candidates.includes(row.worktree) && row.time_initialized !== null)?.time_initialized + } finally { + db.close() + } + }) + +const expectStamped = (home: string, what: string) => + pollWithTimeout(stampOf(home), `${what} did not stamp project.time_initialized`, "10 seconds").pipe( + Effect.flatMap((stamp) => Effect.sync(() => expect(stamp).toBeGreaterThan(0))), + ) + +describe("headless /init stamps project.time_initialized (#404)", () => { + // The #404 repro: "/init" as a plain text prompt through the run CLI. + // RED before the fix (the text path never reached SessionPrompt.command). + cliIt.live( + 'opencode run "/init" stamps the project row', + ({ llm, home, opencode }) => + Effect.gen(function* () { + yield* gitInit(home) + yield* llm.text("AGENTS.md initialized") + const result = yield* opencode.run("/init", { env: { PWD: home, OPENCODE_DB: "" } }) + opencode.expectExit(result, 0) + yield* expectStamped(home, 'opencode run "/init"') + }), + 180_000, + ) + + // Control: the explicit --command flag always used the /command endpoint. + cliIt.live( + "opencode run --command init stamps the project row", + ({ llm, home, opencode }) => + Effect.gen(function* () { + yield* gitInit(home) + yield* llm.text("AGENTS.md initialized") + const result = yield* opencode.run("", { command: "init", env: { PWD: home, OPENCODE_DB: "" } }) + opencode.expectExit(result, 0) + yield* expectStamped(home, "opencode run --command init") + }), + 180_000, + ) + + // Control: the /command endpoint (what the TUI uses) stamps in a fully + // headless serve process — proves the stamp chain works without a TUI. + cliIt.live( + "serve POST /session/:id/command stamps the project row", + ({ llm, home, opencode }) => + Effect.gen(function* () { + yield* gitInit(home) + const server = yield* opencode.serve({ env: { OPENCODE_DB: "" } }) + const client = yield* HttpClient.HttpClient + const headers = { "x-opencode-directory": encodeURIComponent(home) } + + yield* llm.text("AGENTS.md initialized") + const created = yield* client.post(`${server.url}/session`, { body: HttpBody.jsonUnsafe({}), headers }) + const session = Schema.decodeUnknownSync(SessionRef)(yield* created.json) + const res = yield* client.post(`${server.url}/session/${session.id}/command`, { + body: HttpBody.jsonUnsafe({ command: "init", arguments: "" }), + headers, + }) + expect(res.status).toBe(200) + yield* expectStamped(home, "serve /command init") + }), + 240_000, + ) + + // The #404 serve repro: "/init" as a plain text part at the message endpoint. + // RED before the fix (the stamp landed only via the /command endpoint). + cliIt.live( + 'serve POST /session/:id/message with text "/init" stamps the project row', + ({ llm, home, opencode }) => + Effect.gen(function* () { + yield* gitInit(home) + const server = yield* opencode.serve({ env: { OPENCODE_DB: "" } }) + const client = yield* HttpClient.HttpClient + const headers = { "x-opencode-directory": encodeURIComponent(home) } + + yield* llm.text("AGENTS.md initialized") + const created = yield* client.post(`${server.url}/session`, { body: HttpBody.jsonUnsafe({}), headers }) + const session = Schema.decodeUnknownSync(SessionRef)(yield* created.json) + const res = yield* client.post(`${server.url}/session/${session.id}/message`, { + body: HttpBody.jsonUnsafe({ parts: [{ type: "text", text: "/init" }] }), + headers, + }) + expect(res.status).toBe(200) + yield* expectStamped(home, 'serve message "/init"') + }), + 240_000, + ) +}) From 1a4a8046a36a9ea0b9cc6e3a746bf186fb095506 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 21 Aug 2026 02:03:15 +0800 Subject: [PATCH 3/5] docs(release): add v1.0.30 series notes --- .github/releases/v1.0.30.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/releases/v1.0.30.md diff --git a/.github/releases/v1.0.30.md b/.github/releases/v1.0.30.md new file mode 100644 index 000000000..f1a38e693 --- /dev/null +++ b/.github/releases/v1.0.30.md @@ -0,0 +1,33 @@ +## opencode {VERSION} + +{Prerelease/Stable} release from `{branch}` branch. Headless `/init` now stamps project initialization, so MEMORY works from `opencode run` and SDK text prompts, not just the TUI. + +--- + +### 🐛 Bug Fixes + +- **Headless `/init` never stamped project initialization, #404 (PR #405)**: `opencode run "/init"` and serve `POST /session/:id/message` text prompts delivered slash commands as plain text — command routing existed only in the TUI client, so `SessionPrompt.command` never ran, `Command.Event.Executed` never fired, and `project.time_initialized` stayed NULL, leaving MEMORY fail-closed inert with no hint. The server-side prompt endpoints (`prompt` and `promptAsync`) now route single-text-part prompts that name a registered command through the command lifecycle; a registry miss falls through to the plain prompt, preserving existing text-prompt semantics. The TUI `/command` path is unchanged. + +--- + +### 🧪 Test Summary + +``` +CI gates on the dev-to-main promotion: +Typecheck: pass +Unit Tests (linux): pass +E2E Tests (linux): pass +E2E Tests (windows): pass +SpecGit Acceptance: pass +``` + +--- + +### 🔍 Verification + +- The regression is pinned by `test/cli/run/headless-init.test.ts`: four subprocess arms — `opencode run "/init"` and serve message text `/init` (both RED on the parent commit), plus `run --command init` and serve `/command` controls — assert a non-NULL `time_initialized` in the isolated project database. +- End-to-end verification spawned the CLI from source on a fresh git repo with an isolated HOME and confirmed the stamp lands on the `opencode run "/init"` path; the fix was delivered through the specgit harness with an accepted verdict on PR #405. + +--- + +**Full changelog:** [`{previous_tag}`...`{current_tag}`](https://github.com/LeXwDeX/OpenCode-GraphAgent/compare/{previous_tag}...{current_tag}) From c9b96dffb343be6e7f8441c31b6b43d606f963c8 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 21 Aug 2026 02:04:06 +0800 Subject: [PATCH 4/5] docs(release): fix non-ASCII violation in v1.0.30 series notes --- .github/releases/v1.0.30.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/releases/v1.0.30.md b/.github/releases/v1.0.30.md index f1a38e693..daf10c72b 100644 --- a/.github/releases/v1.0.30.md +++ b/.github/releases/v1.0.30.md @@ -6,7 +6,7 @@ ### 🐛 Bug Fixes -- **Headless `/init` never stamped project initialization, #404 (PR #405)**: `opencode run "/init"` and serve `POST /session/:id/message` text prompts delivered slash commands as plain text — command routing existed only in the TUI client, so `SessionPrompt.command` never ran, `Command.Event.Executed` never fired, and `project.time_initialized` stayed NULL, leaving MEMORY fail-closed inert with no hint. The server-side prompt endpoints (`prompt` and `promptAsync`) now route single-text-part prompts that name a registered command through the command lifecycle; a registry miss falls through to the plain prompt, preserving existing text-prompt semantics. The TUI `/command` path is unchanged. +- **Headless `/init` never stamped project initialization, #404 (PR #405)**: `opencode run "/init"` and serve `POST /session/:id/message` text prompts delivered slash commands as plain text - command routing existed only in the TUI client, so `SessionPrompt.command` never ran, `Command.Event.Executed` never fired, and `project.time_initialized` stayed NULL, leaving MEMORY fail-closed inert with no hint. The server-side prompt endpoints (`prompt` and `promptAsync`) now route single-text-part prompts that name a registered command through the command lifecycle; a registry miss falls through to the plain prompt, preserving existing text-prompt semantics. The TUI `/command` path is unchanged. --- @@ -25,7 +25,7 @@ SpecGit Acceptance: pass ### 🔍 Verification -- The regression is pinned by `test/cli/run/headless-init.test.ts`: four subprocess arms — `opencode run "/init"` and serve message text `/init` (both RED on the parent commit), plus `run --command init` and serve `/command` controls — assert a non-NULL `time_initialized` in the isolated project database. +- The regression is pinned by `test/cli/run/headless-init.test.ts`: four subprocess arms - `opencode run "/init"` and serve message text `/init` (both RED on the parent commit), plus `run --command init` and serve `/command` controls - assert a non-NULL `time_initialized` in the isolated project database. - End-to-end verification spawned the CLI from source on a fresh git repo with an isolated HOME and confirmed the stamp lands on the `opencode run "/init"` path; the fix was delivered through the specgit harness with an accepted verdict on PR #405. --- From d626a718b6a09f080611aa63cdc79e57f0428fc4 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 21 Aug 2026 03:07:25 +0800 Subject: [PATCH 5/5] chore(specgit): record PR 405 binding for issue 404 delivery --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 9dbf6d7a7..09e2d450b 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: fix/404-headless-init-does issues: - 404 +pr: 405