From eb1449a50cbd4d66f49dff8d8a8342b382c2fdd6 Mon Sep 17 00:00:00 2001 From: Scott Ames Date: Fri, 18 Sep 2026 17:02:07 -0700 Subject: [PATCH 1/5] feat(agents): support OpenCode v2 alongside v1 Add a dependency-free dual-version plugin with session-scoped policy loading and explicit v2 compaction support. Preserve user-owned OpenCode settings and repair refresh, migration, and plugin cleanup behavior. Verify installed resources and outgoing model requests on OpenCode 1.18.29 and 2.0.6, and run the pinned host checks in CI. --- .github/workflows/pr_check.yml | 14 + AGENTS.md | 25 +- README.md | 4 + docs/contributing-agents.md | 20 +- docs/guide.md | 52 ++- docs/troubleshooting.md | 32 ++ embed.go | 2 +- embed_test.go | 1 - .../plugins/opencode/thts-integration.test.ts | 372 +++++++++++------- embedded/plugins/opencode/thts-integration.ts | 113 ++++-- embedded/settings/opencode.json | 6 - internal/agents/types.go | 2 +- internal/cmd/agents/init.go | 75 +++- internal/cmd/agents/init_test.go | 5 +- internal/cmd/agents/opencode.go | 117 ++++++ internal/cmd/agents/opencode_test.go | 350 ++++++++++++++++ internal/cmd/agents/uninit.go | 32 +- mise.lock | 3 +- mise.toml | 2 + scripts/verify-opencode-hosts.ts | 302 ++++++++++++++ scripts/verify-opencode-integration.sh | 75 ++++ 21 files changed, 1370 insertions(+), 234 deletions(-) delete mode 100644 embedded/settings/opencode.json create mode 100644 internal/cmd/agents/opencode.go create mode 100644 internal/cmd/agents/opencode_test.go create mode 100644 scripts/verify-opencode-hosts.ts create mode 100755 scripts/verify-opencode-integration.sh diff --git a/.github/workflows/pr_check.yml b/.github/workflows/pr_check.yml index efa823d..fe5521f 100644 --- a/.github/workflows/pr_check.yml +++ b/.github/workflows/pr_check.yml @@ -47,3 +47,17 @@ jobs: git config --global user.name "CI" - name: integration tests run: mise run test-integration + - name: OpenCode compatibility (v1.18.29 and v2.0.6) + run: | + temp_dir=$(mktemp -d) + trap 'rm -rf "$temp_dir"' EXIT + mkdir "$temp_dir/v1" "$temp_dir/v2" + curl --fail --location --silent --show-error \ + https://github.com/anomalyco/opencode/releases/download/v1.18.29/opencode-linux-x64.tar.gz \ + -o "$temp_dir/v1.tar.gz" + curl --fail --location --silent --show-error \ + https://opencode.ai/files/bin/2.0.6/opencode-linux-x64.tar.gz \ + -o "$temp_dir/v2.tar.gz" + tar -xzf "$temp_dir/v1.tar.gz" -C "$temp_dir/v1" + tar -xzf "$temp_dir/v2.tar.gz" -C "$temp_dir/v2" + bun scripts/verify-opencode-hosts.ts "$temp_dir/v1/opencode" "$temp_dir/v2/opencode" diff --git a/AGENTS.md b/AGENTS.md index 3fb9b01..96202d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ binary and copied to agent directories by `thts init agents`. | -------- | ------------ | -------------------------------- | -------------------- | --------------- | ---------------- | | Claude | `.claude/` | `skills/*.md` | `commands/` | `hooks/` | `settings.json` | | Codex | `.codex/` | `skills/*/SKILL.md` | `prompts/` (global) | None | `config.toml` | -| OpenCode | `.opencode/` | `skills/*/SKILL.md` | `commands/` | `plugins/` | `opencode.json` | +| OpenCode | `.opencode/` | `skills/*/SKILL.md` | `commands/` | `plugins/` | Not thts-managed | | Gemini | `.gemini/` | `skills/*/SKILL.md` | `commands/*.toml` | `hooks/` | `settings.json` | | Pi | `.pi/` | `skills/thts-integrate/SKILL.md` | `prompts/` | `extensions/` | Not thts-managed | | Droid | `.factory/` | `skills/*/SKILL.md` | `commands/` | `hooks/` | Not thts-managed | @@ -94,14 +94,14 @@ standalone `hooks.json`; do not create or modify its user-owned `settings.json`. ### Hook Support -| Agent | Hook Support | Hook Events | Settings File | -| -------- | --------------- | ------------------------------------ | --------------------- | -| Claude | Yes | `SessionStart`, `UserPromptSubmit` | `settings.local.json` | -| Gemini | Yes | `SessionStart`, `BeforeAgent` | `settings.local.json` | -| OpenCode | Yes (plugin) | `experimental.chat.system.transform` | N/A (plugin) | -| Pi | Yes (extension) | `before_agent_start` | N/A (extension) | -| Droid | Yes | `SessionStart`, `UserPromptSubmit` | `hooks.json` | -| Codex | No | N/A | N/A | +| Agent | Hook Support | Hook Events | Settings File | +| -------- | --------------- | ----------------------------------------------- | --------------------- | +| Claude | Yes | `SessionStart`, `UserPromptSubmit` | `settings.local.json` | +| Gemini | Yes | `SessionStart`, `BeforeAgent` | `settings.local.json` | +| OpenCode | Yes (plugin) | v1 system transform; v2 `context`, `compaction` | N/A (plugin) | +| Pi | Yes (extension) | `before_agent_start` | N/A (extension) | +| Droid | Yes | `SessionStart`, `UserPromptSubmit` | `hooks.json` | +| Codex | No | N/A | N/A | Pi's project `.pi/` resources require Pi project trust. Non-interactive Pi runs do not prompt, so use `pi --approve` to load them for one run or @@ -111,6 +111,12 @@ Droid snapshots hooks at session startup. Restart it or review/reload through `/hooks` after changes. `hooksDisabled` or enterprise `allowManagedHooksOnly` can prevent project or user thts hooks from running. +OpenCode supports v1.18.29+ and v2 (host verification pins v2.0.6). Its single +dependency-free plugin exports `id`, `server`, and `setup`. V2 policy follows +`session.location.directory`, including shared servers; do not append `subpath`. +Keep settings user-owned. Only an exact, manifest-owned legacy settings template +may be removed during migration. Customized files must survive refresh/uninit. + ### Embedded Files | File | Purpose | Agent Support | @@ -181,6 +187,7 @@ mise run test # Go and OpenCode plugin unit t go test ./... # Go unit tests only go test -tags=integration ./internal/cmd/... # Integration tests go test -tags=integration ./... # All tests +bun scripts/verify-opencode-hosts.ts /path/to/v1.18.29 /path/to/v2.0.6 # Real hosts ``` **Coverage targets:** config/git >70%, fs/thts >60% (all met) diff --git a/README.md b/README.md index cb57ce4..3b9b999 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,10 @@ thts sync -m "Added architecture notes" CLI, Pi, and Droid CLI to give them awareness of your thoughts directory. +OpenCode integration supports v1.18.29+ and v2 (verified with v2.0.6), using the +same plugin for project and global installs. See the +[OpenCode upgrade instructions](docs/guide.md#upgrading-opencode-integration). + ```bash thts init agents # Install for detected agents thts init agents -i # Interactive mode diff --git a/docs/contributing-agents.md b/docs/contributing-agents.md index 62fa1cb..f6d269d 100644 --- a/docs/contributing-agents.md +++ b/docs/contributing-agents.md @@ -247,6 +247,24 @@ Pi is the reference configuration for an extension-only agent: it uses and no thts-managed settings. Do not add an empty settings template merely to make `--with-settings` create `settings.json`. +OpenCode installs one self-contained TypeScript plugin with a default export +containing `id`, `server` (v1.18.29+), and `setup` (v2). Keep SDK imports type-only; +the installer does not manage npm dependencies. V2 registers `context` and +`compaction` hooks and resolves the session directory before loading policy. +OpenCode settings are user-owned, like Pi and Droid settings. + +Run `mise run test-opencode` for behavior tests and +`scripts/verify-opencode-integration.sh` for installer lifecycle checks. To test +real loaders and outgoing model requests without provider credentials: + +```bash +bun scripts/verify-opencode-hosts.ts /path/to/opencode-v1.18.29 /path/to/opencode-v2.0.6 +``` + +The host check uses temporary homes, a local mock model, and both overlapping +project/global resources and a second global-only project on each server. CI +downloads the pinned Linux binaries and runs this check. + Shell-hook agents normally merge an event map under the `hooks` key in settings. Set `HookConfigFile` when the harness instead uses a standalone event map, as Droid CLI does with `hooks.json`. Standalone hook configuration is @@ -274,7 +292,7 @@ func getPluginsFS(agentType agents.AgentType) fs.FS { Create a settings template only when thts should manage a default settings file. The template filename must match `SettingsTemplate`; `SettingsFile` remains the destination name. Leave `SettingsTemplate` empty when the agent's settings are -user-owned, as for Pi. +user-owned, as for OpenCode, Pi, and Droid. For testbot with `SettingsFile: "config.json"`: diff --git a/docs/guide.md b/docs/guide.md index 425e25e..b8e25fe 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -794,7 +794,9 @@ your thoughts directory and enable session continuity. - **Codex "prompts"**: Codex calls commands "prompts". They are global-only and invoked as `/prompts:` (e.g., `/prompts:thts-handoff`). - **OpenCode XDG**: OpenCode uses XDG for global config (`~/.config/opencode/`) - rather than a dot-folder in home. + rather than a dot-folder in home. The same plugin supports v1.18.29+ and v2, + verified with v1.18.29 and v2.0.6. OpenCode settings are user-owned, including + when `--with-settings` is used. - **Gemini commands**: Gemini stores commands as TOML and does not support native sub-agents. - **Pi resources**: Pi uses skills, Markdown prompt templates, and TypeScript @@ -892,10 +894,16 @@ Claude and Gemini: 2. Loading full instructions (~200 lines) only when keywords are detected 3. Keywords include: research, plan, decision, thoughts, handoff, notes, etc. -OpenCode uses an idempotent plugin that adds the generated instructions to each -model request without adding another copy when another plugin instance already -supplied it. OpenCode local-only mode installs the same project plugin, which is -covered by the managed thts patterns in `.gitignore`. +OpenCode uses an idempotent plugin that adds generated instructions to normal +agent requests and compaction without adding another copy when policy is already +present in the system instructions. V2 title and transient generation requests +do not receive the policy. On shared servers, the plugin uses the session's +working directory. `thts` must be available on the server's PATH. + +OpenCode local-only mode uses the same plugin, covered by the managed thts +patterns in `.gitignore`. With global hooks configured it uses the global plugin. +With hooks disabled, automatic injection is unavailable; enable hooks or select +shared/on-demand integration. OpenCode does not use an `AGENTS.local.md` fallback. Pi uses an idempotent TypeScript extension at `.pi/extensions/thts-integration.ts`. It adds the generated policy at @@ -920,8 +928,38 @@ integration, or review and reload hooks through `/hooks`. Factory's `hooksDisabled` setting or an enterprise `allowManagedHooksOnly` policy can prevent project or user thts hooks from running. -The OpenCode plugin also caches generated instructions for its session. Restart -OpenCode after changing thts categories or instruction configuration. +The OpenCode plugin caches generated instructions per working directory for the +plugin's lifetime, while checking project initialization before each injection. +Restart OpenCode after changing thts categories or instruction configuration. +For v2, restart the server as well as attached clients when a fresh plugin cache +is needed. + +#### Upgrading OpenCode Integration + +After upgrading thts, refresh existing project resources: + +```bash +thts init agents --agents opencode --refresh --dry-run # Preview only +thts init agents --agents opencode --refresh +``` + +For a global installation, reinstall the global components you use: + +```bash +thts init agents --agents opencode --global=all +``` + +Refresh any existing project installation afterward to remove its owned local +plugin when global hooks are selected. Refresh replaces thts-generated plugin, +skill, command, and subagent files; put custom resources beside them. Restart +OpenCode to load the update. V2 watches plugin files, but a server restart also +clears cached policy. + +OpenCode settings are no longer created or owned by thts. Upgrade and uninstall +remove an old manifest-owned `opencode.json` only if it exactly matches the +obsolete thts template. Customized settings are preserved and released from +thts ownership. If they retain the old `permissions.allow` object, follow the +[permissions migration guidance](troubleshooting.md#opencode-settings-errors). **Note:** Codex does not support hooks and will automatically fall back to always-on mode with a warning. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c4fc533..5d755cf 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2,6 +2,38 @@ ## Common Issues +### OpenCode Plugin Not Loading + +The dual-version plugin requires OpenCode v1.18.29+ or v2 (verified with v2.0.6). +Refresh installed resources after upgrading thts: + +```bash +thts init agents --agents opencode --refresh # Project installation +thts init agents --agents opencode --global=all # Global installation +``` + +Restart OpenCode afterward; for v2 shared servers, restart the server too. +Check `thts init --check` and `thts agent-instructions` from the session's project +directory on the server. The plugin quietly skips injection if either fails or +`thts` is missing from the server's PATH. In v2's active plugin list, its ID is +`thts.integration`. + +### OpenCode Settings Errors + +Older `thts init agents --with-settings` generated an `opencode.json` containing +`"permissions": { "allow": [] }`. That is not a valid current permissions shape. +If the file is customized, thts preserves it rather than rewriting your settings. + +Remove that obsolete empty permissions object, retaining your other settings. +For intentional permission rules, use the schema for your OpenCode version: + +- v1 uses `permission`, for example `"permission": { "edit": "ask" }`. +- v2 uses a rule array, for example + `"permissions": [{ "action": "edit", "resource": "*", "effect": "ask" }]`. + +See the [v2 permissions guide](https://opencode.ai/v2/docs/permissions). The thts +plugin itself does not require a settings file or a plugin config entry. + ### "Run `thts setup` first" You need to configure thts before using it in any project. diff --git a/embed.go b/embed.go index 4e608dc..ce3041a 100644 --- a/embed.go +++ b/embed.go @@ -28,7 +28,7 @@ var Instructions embed.FS var Templates embed.FS // Settings contains embedded default settings files for agents. -// Files are named by agent type: codex.toml, opencode.json, etc. +// Files are named by agent type: codex.toml, gemini.json, etc. // Claude settings are built dynamically and not embedded. // //go:embed embedded/settings/* diff --git a/embed_test.go b/embed_test.go index 02dedaa..a9a459b 100644 --- a/embed_test.go +++ b/embed_test.go @@ -317,7 +317,6 @@ func TestGetDefaultSettingsByTemplateIdentity(t *testing.T) { }{ {template: "codex.toml", want: "# Codex CLI configuration"}, {template: "gemini.json", want: `"contextFileName": "AGENTS.md"`}, - {template: "opencode.json", want: `"model": "anthropic/claude-sonnet-4-20250514"`}, } for _, tt := range tests { diff --git a/embedded/plugins/opencode/thts-integration.test.ts b/embedded/plugins/opencode/thts-integration.test.ts index bfba3da..954b194 100644 --- a/embedded/plugins/opencode/thts-integration.test.ts +++ b/embedded/plugins/opencode/thts-integration.test.ts @@ -1,168 +1,248 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; -import { ThtsIntegration } from "./thts-integration"; - -type Response = { - exitCode: number; - stdout?: string; -}; - -function createShell(responses: Response[]) { - const calls: string[] = []; - const directories: string[] = []; - const shell = (strings: TemplateStringsArray) => { - const command = strings.join(""); - calls.push(command); - const response = responses.shift(); - if (!response) { - throw new Error(`unexpected command: ${command}`); - } - - const output = { - exitCode: response.exitCode, - text: () => response.stdout ?? "", - }; - const result = Object.assign(Promise.resolve(output), { - cwd: (directory: string) => { - directories.push(directory); - return result; - }, - quiet: () => result, - nothrow: () => result, - }); - return result; - }; - - return { calls, directories, shell }; -} - -async function createPlugin(responses: Response[]) { - const fake = createShell(responses); - const hooks = await ThtsIntegration({ - directory: "/repo", - $: fake.shell, - } as never); - const transform = hooks["experimental.chat.system.transform"]; - if (!transform) { - throw new Error("missing system transform hook"); - } - return { ...fake, hooks, transform }; -} +import plugin from "./thts-integration"; +type Response = { exitCode: number; stdout?: string }; +type Event = { sessionID: string; system: { type: "text"; text: string }[] }; const success = { exitCode: 0 }; const instructions = { exitCode: 0, stdout: "# thts Integration Instructions\n\nPolicy", }; -describe("OpenCode thts integration", () => { - test("injects once across duplicate plugin instances", async () => { - const first = await createPlugin([success, instructions]); - const second = await createPlugin([]); - const output = { system: [] as string[] }; - - await first.transform({} as never, output); - await second.transform({} as never, output); - - expect(output.system).toHaveLength(1); - expect(output.system[0]).toContain(""); - expect(first.calls).toEqual([ - "thts init --check", - "thts agent-instructions", - ]); - expect(first.directories).toEqual(["/repo", "/repo"]); - expect(second.calls).toEqual([]); - - const legacyOutput = { - system: ["# thts Integration Instructions\n\nLegacy policy"], - }; - await second.transform({} as never, legacyOutput); - expect(legacyOutput.system).toHaveLength(1); - }); - - test("rechecks eligibility but reuses instructions for later requests", async () => { - const plugin = await createPlugin([success, instructions, success]); - - const first = { system: [] as string[] }; - await plugin.transform({} as never, first); - const afterCompaction = { system: [] as string[] }; - await plugin.transform({} as never, afterCompaction); +let root: string; +let oldPath: string | undefined; +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "thts-opencode-")); + oldPath = process.env.PATH; + await writeFile( + join(root, "thts"), + `#!${process.execPath} +const { readFileSync, writeFileSync, appendFileSync } = require("node:fs"); +const responses = JSON.parse(readFileSync("responses.json", "utf8")); +const response = responses.shift(); +appendFileSync("calls.txt", process.argv.slice(2).join(" ") + "\\n"); +writeFileSync("responses.json", JSON.stringify(responses)); +if (!response) process.exit(99); +process.stdout.write(response.stdout ?? ""); +process.exit(response.exitCode); +`, + { mode: 0o755 }, + ); + process.env.PATH = `${root}:${oldPath}`; +}); +afterEach(async () => { + process.env.PATH = oldPath; + await rm(root, { recursive: true, force: true }); +}); - expect(first.system).toHaveLength(1); - expect(afterCompaction.system).toHaveLength(1); - expect(plugin.calls).toEqual([ - "thts init --check", - "thts agent-instructions", - "thts init --check", - ]); - expect(plugin.hooks["experimental.session.compacting"]).toBeUndefined(); - }); +async function repo(name: string, responses: Response[]) { + const directory = join(root, name); + await mkdir(directory); + await writeFile(join(directory, "responses.json"), JSON.stringify(responses)); + await writeFile(join(directory, "calls.txt"), ""); + return directory; +} +async function calls(directory: string) { + return (await readFile(join(directory, "calls.txt"), "utf8")) + .split("\n") + .filter(Boolean); +} - test("recovers after initialization or instruction failures", async () => { - const afterInit = await createPlugin([ - { exitCode: 1 }, - success, - instructions, - ]); - const disabled = { system: [] as string[] }; - await afterInit.transform({} as never, disabled); - const enabled = { system: [] as string[] }; - await afterInit.transform({} as never, enabled); +async function v1(directory: string) { + const hooks = await plugin.server({ directory } as never); + const transform = hooks["experimental.chat.system.transform"]!; + return { + hooks, + async inject(system: string[]) { + await transform({} as never, { system }); + }, + }; +} +async function v2(directory: () => string | Promise) { + const hooks = new Map Promise>(); + const sessions: string[] = []; + await plugin.setup({ + // Deliberately different from the session's directory. + location: { directory: "/wrong-plugin-location" }, + session: { + async get({ sessionID }: { sessionID: string }) { + sessions.push(sessionID); + return { + location: { directory: await directory() }, + subpath: "ignored", + }; + }, + async hook(name: string, callback: (event: Event) => Promise) { + hooks.set(name, callback); + }, + }, + } as never); + return { hooks, sessions }; +} - expect(disabled.system).toEqual([]); - expect(enabled.system).toHaveLength(1); +for (const version of [1, 2]) { + describe(`OpenCode v${version}`, () => { + async function adapter(directory: string) { + if (version === 1) return v1(directory); + const instance = await v2(() => directory); + return { + async inject(system: string[]) { + const event: Event = { + sessionID: "session-a", + system: system.map((text) => ({ type: "text", text })), + }; + await instance.hooks.get("context")!(event); + system.splice( + 0, + system.length, + ...event.system.map((part) => part.text), + ); + }, + }; + } - const afterCommand = await createPlugin([ - success, - { exitCode: 1 }, - success, - instructions, - ]); - const failed = { system: [] as string[] }; - await afterCommand.transform({} as never, failed); - const recovered = { system: [] as string[] }; - await afterCommand.transform({} as never, recovered); + test("injects once across plugin instances and respects existing policy", async () => { + const directory = await repo("repo", [success, instructions]); + const first = await adapter(directory); + const second = await adapter(directory); + const system: string[] = ["Other instructions"]; + await first.inject(system); + await second.inject(system); + expect(system).toHaveLength(2); + expect(system[1]).toContain(""); + const legacy = ["# thts Integration Instructions\nLegacy policy"]; + await second.inject(legacy); + expect(legacy).toHaveLength(1); + expect(await calls(directory)).toEqual([ + "init --check", + "agent-instructions", + ]); + }); - expect(failed.system).toEqual([]); - expect(recovered.system).toHaveLength(1); + test("rechecks eligibility, caches policy, and invalidates on uninit", async () => { + const directory = await repo("repo", [ + success, + instructions, + success, + { exitCode: 1 }, + success, + { exitCode: 0, stdout: "Changed policy" }, + ]); + const instance = await adapter(directory); + for (const expected of ["Policy", "Policy", null, "Changed policy"]) { + const system: string[] = []; + await instance.inject(system); + if (expected) expect(system[0]).toContain(expected); + else expect(system).toEqual([]); + } + expect(await calls(directory)).toEqual([ + "init --check", + "agent-instructions", + "init --check", + "init --check", + "init --check", + "agent-instructions", + ]); + }); - const afterBlank = await createPlugin([ - success, - { exitCode: 0, stdout: " " }, - success, - instructions, - ]); - const blank = { system: [] as string[] }; - await afterBlank.transform({} as never, blank); - const afterBlankRetry = { system: [] as string[] }; - await afterBlank.transform({} as never, afterBlankRetry); + test("recovers from check, render, and empty-output failures", async () => { + const directory = await repo("repo", [ + { exitCode: 1 }, + success, + { exitCode: 1 }, + success, + { exitCode: 0, stdout: " " }, + success, + instructions, + ]); + const instance = await adapter(directory); + for (let i = 0; i < 4; i++) { + const system: string[] = []; + await instance.inject(system); + expect(system).toHaveLength(i === 3 ? 1 : 0); + } + }); - expect(blank.system).toEqual([]); - expect(afterBlankRetry.system).toHaveLength(1); + test("missing executable and missing cwd are quiet no-ops", async () => { + const directory = await repo("repo", [success, instructions]); + const instance = await adapter(directory); + process.env.PATH = ""; + const system: string[] = []; + await instance.inject(system); + expect(system).toEqual([]); + process.env.PATH = `${root}:${oldPath}`; + await instance.inject(system); + expect(system).toHaveLength(1); + await (await adapter(join(root, "missing"))).inject([]); + }); }); +} - test("stops injecting after the project is uninitialized", async () => { - const changedInstructions = { - exitCode: 0, - stdout: "# thts Integration Instructions\n\nChanged policy", - }; - const plugin = await createPlugin([ - success, - instructions, - { exitCode: 1 }, - success, - changedInstructions, +test("v2 registers work and compaction only; compaction shares policy cache", async () => { + const directory = await repo("repo", [success, instructions, success]); + const instance = await v2(() => directory); + expect([...instance.hooks.keys()]).toEqual(["context", "compaction"]); + for (const hook of instance.hooks.values()) { + const event: Event = { sessionID: "session-a", system: [] }; + await hook(event); + expect(event.system).toEqual([ + { + type: "text", + text: `\n${instructions.stdout}`, + }, ]); + } + expect(await calls(directory)).toEqual([ + "init --check", + "agent-instructions", + "init --check", + ]); +}); - const initialized = { system: [] as string[] }; - await plugin.transform({} as never, initialized); - const uninitialized = { system: [] as string[] }; - await plugin.transform({} as never, uninitialized); - const reinitialized = { system: [] as string[] }; - await plugin.transform({} as never, reinitialized); +test("v2 follows session moves and isolates directory caches", async () => { + const first = await repo("first", [success, instructions, success]); + const second = await repo("second", [ + success, + { exitCode: 0, stdout: "Other policy" }, + ]); + let directory = first; + const instance = await v2(() => directory); + for (const [cwd, expected] of [ + [first, "Policy"], + [second, "Other policy"], + [first, "Policy"], + ]) { + directory = cwd; + const event: Event = { sessionID: "moving-session", system: [] }; + await instance.hooks.get("context")!(event); + expect(event.system[0].text).toContain(expected); + } + expect(instance.sessions).toEqual(Array(3).fill("moving-session")); + expect(await calls(first)).toEqual([ + "init --check", + "agent-instructions", + "init --check", + ]); + expect(await calls(second)).toEqual(["init --check", "agent-instructions"]); +}); - expect(initialized.system).toHaveLength(1); - expect(uninitialized.system).toEqual([]); - expect(reinitialized.system[0]).toContain("Changed policy"); +test("v2 lookup failures are retryable and existing policy avoids lookup", async () => { + const directory = await repo("repo", [success, instructions]); + let fail = true; + const instance = await v2(() => { + if (fail) throw new Error("session unavailable"); + return directory; }); + const event: Event = { sessionID: "session-a", system: [] }; + await instance.hooks.get("context")!(event); + expect(event.system).toEqual([]); + fail = false; + await instance.hooks.get("context")!(event); + await instance.hooks.get("context")!(event); + expect(event.system).toHaveLength(1); + expect(instance.sessions).toHaveLength(2); }); diff --git a/embedded/plugins/opencode/thts-integration.ts b/embedded/plugins/opencode/thts-integration.ts index 26dc4a0..dda1e7a 100644 --- a/embedded/plugins/opencode/thts-integration.ts +++ b/embedded/plugins/opencode/thts-integration.ts @@ -1,57 +1,90 @@ -// thts integration plugin for OpenCode -// Makes thoughts/ instructions available once per model request. +// thts integration for OpenCode v1.18.29+ and v2. +// Keep this file self-contained: thts installs it without an npm package. -import type { Plugin } from "@opencode-ai/plugin"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { Plugin as V1Plugin } from "@opencode-ai/plugin"; +import type { Plugin } from "@opencode/plugin"; +import type { SessionContext } from "@opencode/plugin/promise/session"; +const exec = promisify(execFile); const THTS_MARKER = ""; const THTS_HEADING = "# thts Integration Instructions"; -export const ThtsIntegration: Plugin = async ({ directory, $ }) => { - let instructions: Promise | undefined; +function hasPolicy(content: string) { + return content.includes(THTS_MARKER) || content.includes(THTS_HEADING); +} - const getInstructions = async () => { - instructions ??= (async () => { - const result = await $`thts agent-instructions` - .cwd(directory) - .quiet() - .nothrow(); - if (result.exitCode !== 0) { - return null; - } +function createPolicyLoader() { + const policies = new Map>(); - return result.text().trim() || null; - })(); + const run = async (directory: string, args: string[]) => { + try { + const { stdout } = await exec("thts", args, { + cwd: directory, + timeout: 10_000, + encoding: "utf8", + }); + return stdout.trim(); + } catch { + return null; + } + }; - const content = await instructions; + return async (directory: string) => { + if ((await run(directory, ["init", "--check"])) === null) { + policies.delete(directory); + return null; + } + + let pending = policies.get(directory); + if (!pending) { + pending = run(directory, ["agent-instructions"]); + policies.set(directory, pending); + } + const content = await pending; if (!content) { - instructions = undefined; + if (policies.get(directory) === pending) policies.delete(directory); + return null; } - return content; + return `${THTS_MARKER}\n${content}`; }; +} +const server: V1Plugin = async ({ directory }) => { + const loadPolicy = createPolicyLoader(); return { "experimental.chat.system.transform": async (_input, output) => { - const alreadyPresent = output.system.some( - (content) => - content.includes(THTS_MARKER) || content.includes(THTS_HEADING), - ); - if (alreadyPresent) { - return; - } - - const enabled = await $`thts init --check` - .cwd(directory) - .quiet() - .nothrow(); - if (enabled.exitCode !== 0) { - instructions = undefined; - return; - } - - const content = await getInstructions(); - if (content) { - output.system.push(`${THTS_MARKER}\n${content}`); - } + if (output.system.some(hasPolicy)) return; + const content = await loadPolicy(directory); + if (content && !output.system.some(hasPolicy)) + output.system.push(content); }, }; }; + +async function setup(ctx: Plugin.Context) { + const loadPolicy = createPolicyLoader(); + const inject = async ( + event: Pick, + ) => { + if (event.system.some((part) => hasPolicy(part.text))) return; + let directory: string; + try { + const session = await ctx.session.get({ sessionID: event.sessionID }); + directory = session.location.directory; + } catch { + return; + } + const content = await loadPolicy(directory); + if (content && !event.system.some((part) => hasPolicy(part.text))) { + event.system.push({ type: "text", text: content }); + } + }; + await ctx.session.hook("context", inject); + await ctx.session.hook("compaction", inject); +} + +// V1 calls server(); V2 calls setup(). Plugin.define is an identity helper, +// so the structural definition avoids a runtime dependency on either SDK. +export default { id: "thts.integration", server, setup }; diff --git a/embedded/settings/opencode.json b/embedded/settings/opencode.json deleted file mode 100644 index c6ca3fe..0000000 --- a/embedded/settings/opencode.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "model": "anthropic/claude-sonnet-4-20250514", - "permissions": { - "allow": [] - } -} diff --git a/internal/agents/types.go b/internal/agents/types.go index 397aedd..1cf7c12 100644 --- a/internal/agents/types.go +++ b/internal/agents/types.go @@ -172,7 +172,7 @@ var AgentConfigs = map[AgentType]*AgentConfig{ CommandsGlobalOnly: false, GlobalUsesXDG: true, // OpenCode uses ~/.config/opencode/ for global SettingsFile: "opencode.json", - SettingsTemplate: "opencode.json", + SettingsTemplate: "", // OpenCode settings are user-owned SettingsFormat: "json", SupportsHooks: true, HooksDir: "", diff --git a/internal/cmd/agents/init.go b/internal/cmd/agents/init.go index 19cb8dc..c781e51 100644 --- a/internal/cmd/agents/init.go +++ b/internal/cmd/agents/init.go @@ -251,7 +251,7 @@ func runAgentsInit(cmd *cobra.Command, args []string) error { existingAgents := detectExistingAgentManifests(targetDir) // Handle --refresh flag or prompt for action if agents exist - if len(existingAgents) > 0 && !initForce { + if len(existingAgents) > 0 && (!initForce || initRefresh) { if initRefresh { return refreshAgentSetup(targetDir, existingAgents) } @@ -546,7 +546,7 @@ func buildInstallationPlan(projectDir string, agentType agents.AgentType, level if agentConfig.PluginsDir != "" && hooksMode == config.ComponentModeLocal { relPath := filepath.Join(agentConfig.PluginsDir, "thts-integration.ts") plan.pluginFiles = append(plan.pluginFiles, relPath) - } else if !usesGlobalPiRuntimeAdapter(agentType, agentConfig, hooksMode) { + } else if agentType != agents.AgentOpenCode && !usesGlobalPiRuntimeAdapter(agentType, agentConfig, hooksMode) { if agentConfig.Type == agents.AgentClaude { plan.instructionsFile = "CLAUDE.local.md" } else if agentConfig.Type == agents.AgentDroid { @@ -740,7 +740,7 @@ func initAgent(projectDir string, agentType agents.AgentType, level IntegrationL level = normalizeIntegrationLevel(level) if existing, err := loadManifest(agentDir); err == nil { previousManifest = existing - } else if agentType == agents.AgentDroid && !os.IsNotExist(err) { + } else if (agentType == agents.AgentDroid || agentType == agents.AgentOpenCode) && !os.IsNotExist(err) { return fmt.Errorf("read previous manifest: %w", err) } if agentType == agents.AgentDroid { @@ -761,6 +761,10 @@ func initAgent(projectDir string, agentType agents.AgentType, level IntegrationL } return reconcileErr } + } else if agentType == agents.AgentOpenCode && previousManifest != nil { + if err := reconcileOpenCodeIntegration(projectDir, agentDir, previousManifest, level, hooksMode); err != nil { + return err + } } else if previousManifest != nil && normalizeIntegrationLevel(previousManifest.IntegrationLevel) == IntegrationHook && (level != IntegrationHook || hooksMode != config.ComponentModeLocal) { if err := removeProjectHookIntegration(projectDir, agentDir, agentConfig, previousManifest); err != nil { return fmt.Errorf("remove previous hook integration: %w", err) @@ -772,6 +776,10 @@ func initAgent(projectDir string, agentType agents.AgentType, level IntegrationL IntegrationLevel: level, Files: []string{}, } + if agentType == agents.AgentOpenCode && previousManifest != nil { + manifest.Files = slices.Clone(previousManifest.Files) + manifest.Modifications = previousManifest.Modifications + } if previousManifest != nil && previousManifest.Modifications.Hooks != nil && level == IntegrationHook && hooksMode == config.ComponentModeLocal { manifest.Modifications.Hooks = previousManifest.Modifications.Hooks manifest.Modifications.Gitignore = previousManifest.Modifications.Gitignore @@ -880,16 +888,21 @@ func initAgent(projectDir string, agentType agents.AgentType, level IntegrationL fmt.Printf("%s\n", ui.InfoF(" %s: using global installation", runtimeAdapterDisplayName(agentType))) case config.ComponentModeLocal: if err := setupHookIntegration(projectDir, agentDir, agentType, agentConfig, manifest); err != nil { - if agentType == agents.AgentDroid { - return fmt.Errorf("setup Droid hook integration: %w", err) + if agentType == agents.AgentDroid || agentType == agents.AgentOpenCode { + return fmt.Errorf("setup %s hook integration: %w", agentType, err) } fmt.Println(ui.WarningF(" Could not setup hook integration: %v", err)) } } } else if level == IntegrationAgentsContentLocal && agentConfig.PluginsDir != "" && hooksMode == config.ComponentModeLocal { if err := setupHookIntegration(projectDir, agentDir, agentType, agentConfig, manifest); err != nil { + if agentType == agents.AgentOpenCode { + return fmt.Errorf("setup OpenCode local plugin: %w", err) + } fmt.Println(ui.WarningF(" Could not setup local plugin integration: %v", err)) } + } else if agentType == agents.AgentOpenCode && level == IntegrationAgentsContentLocal { + reportOpenCodeRuntimeMode(hooksMode) } else if !usesGlobalPiRuntimeAdapter(agentType, agentConfig, hooksMode) { // Traditional integration (markers or config) instMod, gitignorePatterns, err := setupIntegrationLevel(projectDir, agentDir, agentConfig, level) @@ -907,7 +920,7 @@ func initAgent(projectDir string, agentType agents.AgentType, level IntegrationL fmt.Println(ui.WarningF(" Could not setup integration: %v", err)) } else { if instMod != nil { - if agentType == agents.AgentDroid && previousManifest != nil && + if (agentType == agents.AgentDroid || agentType == agents.AgentOpenCode) && previousManifest != nil && previousManifest.Modifications.InstructionsMD != nil && previousManifest.Modifications.InstructionsMD.Action == "created" && filepath.Clean(previousManifest.Modifications.InstructionsMD.Path) == filepath.Clean(instMod.Path) { @@ -937,11 +950,14 @@ func initAgent(projectDir string, agentType agents.AgentType, level IntegrationL fmt.Println(ui.SuccessF(" Created %s", agentConfig.SettingsFile)) } } + if agentType == agents.AgentOpenCode && level == IntegrationHook && hooksMode == config.ComponentModeDisabled { + reportOpenCodeRuntimeMode(hooksMode) + } // Write manifest if err := writeManifest(agentDir, manifest); err != nil { - if agentType == agents.AgentDroid { - return fmt.Errorf("write Droid manifest: %w", err) + if agentType == agents.AgentDroid || agentType == agents.AgentOpenCode { + return fmt.Errorf("write %s manifest: %w", agentType, err) } fmt.Println(ui.WarningF(" Could not write manifest: %v", err)) } @@ -2502,6 +2518,23 @@ func detectExistingAgentManifests(projectDir string) []agents.AgentType { // This re-copies skills, commands, agents, and updates instructions without // prompting for integration level (preserves existing level from manifest). func refreshAgentSetup(projectDir string, agentTypes []agents.AgentType) error { + if initAgents != "" { + selected, err := agents.ParseAgentTypes(initAgents) + if err != nil { + return err + } + agentTypes = slices.DeleteFunc(slices.Clone(agentTypes), func(agentType agents.AgentType) bool { + return !slices.Contains(selected, agentType) + }) + if len(agentTypes) == 0 { + return fmt.Errorf("no initialized agents match --agents %s", initAgents) + } + } + if initDryRun { + fmt.Println(ui.InfoF("Would refresh: %s", strings.Join(agents.AgentTypesToStrings(agentTypes), ", "))) + fmt.Println(ui.Info("Dry run complete. No files were changed.")) + return nil + } fmt.Println(ui.Header("Refreshing Agent Integration")) fmt.Println() @@ -2515,8 +2548,8 @@ func refreshAgentSetup(projectDir string, agentTypes []agents.AgentType) error { // Load existing manifest to get integration level manifest, err := loadManifest(agentDir) if err != nil { - if agentType == agents.AgentDroid { - return fmt.Errorf("read Droid manifest: %w", err) + if agentType == agents.AgentDroid || agentType == agents.AgentOpenCode { + return fmt.Errorf("read %s manifest: %w", agentType, err) } fmt.Println(ui.WarningF("Could not read manifest for %s: %v", agentType, err)) continue @@ -2527,6 +2560,14 @@ func refreshAgentSetup(projectDir string, agentTypes []agents.AgentType) error { var filesUpdated int level := normalizeIntegrationLevel(manifest.IntegrationLevel) hooksMode := resolveAgentComponentMode(cfg, globalManifest, agentType, "hooks") + if agentType == agents.AgentOpenCode { + if err := reconcileOpenCodeIntegration(projectDir, agentDir, manifest, level, hooksMode); err != nil { + return err + } + if level == IntegrationHook || level == IntegrationAgentsContentLocal { + reportOpenCodeRuntimeMode(hooksMode) + } + } if agentType == agents.AgentDroid { if err := validateDroidProjectResourceCollisions(agentDir, agentConfig, cfg, globalManifest, manifest, level); err != nil { return err @@ -2662,6 +2703,13 @@ func refreshAgentSetup(projectDir string, agentTypes []agents.AgentType) error { } } case IntegrationAgentsContent: + if agentType == agents.AgentOpenCode && manifest.Modifications.InstructionsMD == nil { + mod, _, err := setupIntegrationLevel(projectDir, agentDir, agentConfig, level) + if err != nil { + return err + } + manifest.Modifications.InstructionsMD = mod + } // Refresh marker block content if err := refreshIntegration(projectDir, agentDir, agentConfig, cfg); err != nil { if agentType == agents.AgentDroid { @@ -2694,6 +2742,9 @@ func refreshAgentSetup(projectDir string, agentTypes []agents.AgentType) error { } else if agentConfig.PluginsDir != "" && (level == IntegrationHook || level == IntegrationAgentsContentLocal) && hooksMode == config.ComponentModeLocal { copied, err := copyPluginsToManifest(agentDir, agentType, agentConfig, manifest) if err != nil { + if agentType == agents.AgentOpenCode { + return fmt.Errorf("refresh OpenCode plugin: %w", err) + } fmt.Println(ui.WarningF(" Could not update plugins: %v", err)) } else if copied > 0 { filesUpdated += copied @@ -2709,8 +2760,8 @@ func refreshAgentSetup(projectDir string, agentTypes []agents.AgentType) error { // Update manifest timestamp manifest.CreatedAt = time.Now().UTC().Format(time.RFC3339) if err := writeManifest(agentDir, manifest); err != nil { - if agentType == agents.AgentDroid { - return fmt.Errorf("update Droid manifest: %w", err) + if agentType == agents.AgentDroid || agentType == agents.AgentOpenCode { + return fmt.Errorf("update %s manifest: %w", agentType, err) } fmt.Println(ui.WarningF(" Could not update manifest: %v", err)) } diff --git a/internal/cmd/agents/init_test.go b/internal/cmd/agents/init_test.go index 9756d80..4fb36d6 100644 --- a/internal/cmd/agents/init_test.go +++ b/internal/cmd/agents/init_test.go @@ -21,7 +21,6 @@ func TestWriteAgentSettingsUsesEmbeddedTemplateAtAgentDestination(t *testing.T) }{ {agentType: internalagents.AgentCodex, destination: "config.toml"}, {agentType: internalagents.AgentGemini, destination: "settings.json"}, - {agentType: internalagents.AgentOpenCode, destination: "opencode.json"}, } { t.Run(string(tt.agentType), func(t *testing.T) { cfg := internalagents.GetConfig(tt.agentType) @@ -357,8 +356,8 @@ func TestLocalContentPlanMatchesPluginHookMode(t *testing.T) { wantInstructions bool }{ {name: "local hooks install plugin", hooksMode: config.ComponentModeLocal, wantPlugin: true}, - {name: "global hooks use local instructions", hooksMode: config.ComponentModeGlobal, wantInstructions: true}, - {name: "disabled hooks use local instructions", hooksMode: config.ComponentModeDisabled, wantInstructions: true}, + {name: "global hooks use global plugin", hooksMode: config.ComponentModeGlobal}, + {name: "disabled hooks have no fallback", hooksMode: config.ComponentModeDisabled}, } { t.Run(tt.name, func(t *testing.T) { t.Setenv("THTS_CONFIG_PATH", filepath.Join(t.TempDir(), "config.yaml")) diff --git a/internal/cmd/agents/opencode.go b/internal/cmd/agents/opencode.go new file mode 100644 index 0000000..396e148 --- /dev/null +++ b/internal/cmd/agents/opencode.go @@ -0,0 +1,117 @@ +package agents + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + + "github.com/scottames/thts/internal/agents" + "github.com/scottames/thts/internal/config" + "github.com/scottames/thts/internal/ui" +) + +// Only the exact old generated file is safe to delete. Customized settings, +// JSONC, and symlinks remain user-owned, even if an old manifest claims them. +const legacyOpenCodeSettings = `{ + "model": "anthropic/claude-sonnet-4-20250514", + "permissions": { + "allow": [] + } +} +` + +func isLegacyOpenCodeSettings(agentDir string, manifest *Manifest) (bool, error) { + if !manifest.SettingsCreated && !slices.Contains(manifest.Files, "opencode.json") { + return false, nil + } + path := filepath.Join(agentDir, "opencode.json") + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + if info.Mode()&os.ModeSymlink != 0 { + return false, nil + } + if !info.Mode().IsRegular() { + return false, fmt.Errorf("expected regular OpenCode settings file: %s", path) + } + content, err := os.ReadFile(path) + return string(content) == legacyOpenCodeSettings, err +} + +func migrateOpenCodeSettings(agentDir string, manifest *Manifest) error { + owned := manifest.SettingsCreated || slices.Contains(manifest.Files, "opencode.json") + if !owned { + return nil + } + remove, err := isLegacyOpenCodeSettings(agentDir, manifest) + if err != nil { + return err + } + path := filepath.Join(agentDir, "opencode.json") + if remove { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + fmt.Println(ui.Info("Removed obsolete thts-generated OpenCode settings")) + } else if _, err := os.Lstat(path); err == nil { + fmt.Println(ui.InfoF("Preserved user-owned settings: %s", path)) + content, _ := os.ReadFile(path) + var settings struct { + Permissions map[string]json.RawMessage `json:"permissions"` + } + if json.Unmarshal(content, &settings) == nil && settings.Permissions["allow"] != nil { + fmt.Println(ui.Warning("Preserved obsolete permissions.allow object; migrate it using the OpenCode permissions guide.")) + } + } + manifest.SettingsCreated = false + manifest.Files = removeStringValue(manifest.Files, "opencode.json") + return nil +} + +func reportOpenCodeRuntimeMode(mode config.ComponentMode) { + switch mode { + case config.ComponentModeGlobal: + fmt.Println(ui.Info("OpenCode: using global plugin")) + case config.ComponentModeDisabled: + fmt.Println(ui.Info("OpenCode plugin disabled: automatic injection is unavailable. Enable hooks or select shared/on-demand integration.")) + } +} + +func reconcileOpenCodeIntegration(projectDir, agentDir string, manifest *Manifest, level IntegrationLevel, mode config.ComponentMode) error { + // Save after reconciliation, including partial progress on failure, so failed + // cleanup never loses the remaining ownership record. + err := reconcileOpenCodeFiles(projectDir, agentDir, manifest, level, mode) + if saveErr := writeManifest(agentDir, manifest); saveErr != nil { + return errors.Join(err, fmt.Errorf("save OpenCode migration: %w", saveErr)) + } + return err +} + +func reconcileOpenCodeFiles(projectDir, agentDir string, manifest *Manifest, level IntegrationLevel, mode config.ComponentMode) error { + if err := migrateOpenCodeSettings(agentDir, manifest); err != nil { + return err + } + keepPlugin := (level == IntegrationHook || level == IntegrationAgentsContentLocal) && mode == config.ComponentModeLocal + const plugin = "plugins/thts-integration.ts" + if !keepPlugin && slices.Contains(manifest.Files, filepath.FromSlash(plugin)) { + if err := os.Remove(filepath.Join(agentDir, plugin)); err != nil && !os.IsNotExist(err) { + return err + } + manifest.Files = removeStringValue(manifest.Files, filepath.FromSlash(plugin)) + cleanEmptyDirs(filepath.Join(agentDir, "plugins")) + } + if mod := manifest.Modifications.InstructionsMD; mod != nil && (level != IntegrationAgentsContent || mod.IntegrationType == "config") { + if err := removeThtsIntegration(mod, agents.AgentOpenCode, projectDir, nil); err != nil && !os.IsNotExist(err) { + return err + } + manifest.Modifications.InstructionsMD = nil + } + return migrateLegacyLocalInstructions(projectDir, agentDir, agents.GetConfig(agents.AgentOpenCode), manifest) +} diff --git a/internal/cmd/agents/opencode_test.go b/internal/cmd/agents/opencode_test.go new file mode 100644 index 0000000..ee235e8 --- /dev/null +++ b/internal/cmd/agents/opencode_test.go @@ -0,0 +1,350 @@ +package agents + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + + thtsfiles "github.com/scottames/thts" + internalagents "github.com/scottames/thts/internal/agents" + "github.com/scottames/thts/internal/config" +) + +func setupOpenCodeTest(t *testing.T) { + t.Helper() + t.Setenv("THTS_CONFIG_PATH", filepath.Join(t.TempDir(), "config.yaml")) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + oldAgents, oldDryRun, oldSettings, oldRefresh, oldForce := initAgents, initDryRun, initWithSettings, initRefresh, initForce + initAgents, initDryRun, initWithSettings, initRefresh, initForce = "", false, false, false, false + t.Cleanup(func() { + initAgents, initDryRun, initWithSettings, initRefresh, initForce = oldAgents, oldDryRun, oldSettings, oldRefresh, oldForce + }) +} + +func TestOpenCodeSettingsAreUserOwned(t *testing.T) { + setupOpenCodeTest(t) + initWithSettings = true + for _, content := range []string{"", `{"model":"user/model"}`, "// user config\n{}"} { + t.Run(content, func(t *testing.T) { + project := t.TempDir() + agentDir := filepath.Join(project, ".opencode") + if err := os.MkdirAll(agentDir, 0755); err != nil { + t.Fatal(err) + } + path := filepath.Join(agentDir, "opencode.json") + if strings.HasPrefix(content, "//") { + path += "c" + } + if content != "" { + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + if err := initAgent(project, internalagents.AgentOpenCode, IntegrationHook); err != nil { + t.Fatal(err) + } + manifest, err := loadManifest(agentDir) + if err != nil || manifest.SettingsCreated || slices.Contains(manifest.Files, "opencode.json") { + t.Fatalf("unexpected settings ownership: %+v, %v", manifest, err) + } + if err := refreshAgentSetup(project, []internalagents.AgentType{internalagents.AgentOpenCode}); err != nil { + t.Fatal(err) + } + if err := Uninit(project, true, []internalagents.AgentType{internalagents.AgentOpenCode}); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if content == "" { + if !os.IsNotExist(err) { + t.Fatalf("settings unexpectedly created: %v", err) + } + } else if err != nil || string(got) != content { + t.Fatalf("user settings changed: %q, %v", got, err) + } + }) + } +} + +func TestOpenCodeLegacySettingsMigration(t *testing.T) { + for _, operation := range []string{"refresh", "reinit", "uninit"} { + for _, customized := range []bool{false, true} { + t.Run(operation+map[bool]string{false: "/exact", true: "/custom"}[customized], func(t *testing.T) { + setupOpenCodeTest(t) + project := t.TempDir() + if err := initAgent(project, internalagents.AgentOpenCode, IntegrationHook); err != nil { + t.Fatal(err) + } + agentDir := filepath.Join(project, ".opencode") + manifest, err := loadManifest(agentDir) + if err != nil { + t.Fatal(err) + } + manifest.SettingsCreated = true + manifest.Files = append(manifest.Files, "opencode.json") + if err := writeManifest(agentDir, manifest); err != nil { + t.Fatal(err) + } + content := legacyOpenCodeSettings + if customized { + content = strings.Replace(content, "claude-sonnet-4-20250514", "custom-model", 1) + } + path := filepath.Join(agentDir, "opencode.json") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + switch operation { + case "refresh": + err = refreshAgentSetup(project, []internalagents.AgentType{internalagents.AgentOpenCode}) + case "reinit": + err = initAgent(project, internalagents.AgentOpenCode, IntegrationHook) + case "uninit": + err = Uninit(project, true, []internalagents.AgentType{internalagents.AgentOpenCode}) + } + if err != nil { + t.Fatal(err) + } + if operation != "uninit" { + updated, err := loadManifest(agentDir) + if err != nil || updated.SettingsCreated || slices.Contains(updated.Files, "opencode.json") { + t.Fatalf("settings ownership retained: %+v, %v", updated, err) + } + if err := Uninit(project, true, []internalagents.AgentType{internalagents.AgentOpenCode}); err != nil { + t.Fatal(err) + } + } + got, err := os.ReadFile(path) + if customized { + if err != nil || string(got) != content { + t.Fatalf("custom settings changed: %q, %v", got, err) + } + } else if !os.IsNotExist(err) { + t.Fatalf("legacy settings retained: %v", err) + } + }) + } + } +} + +func TestOpenCodeSettingsSymlinkAndCleanupFailure(t *testing.T) { + setupOpenCodeTest(t) + agentDir := t.TempDir() + target := filepath.Join(t.TempDir(), "settings.json") + if err := os.WriteFile(target, []byte(legacyOpenCodeSettings), 0644); err != nil { + t.Fatal(err) + } + path := filepath.Join(agentDir, "opencode.json") + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + manifest := &Manifest{SettingsCreated: true, Files: []string{"opencode.json"}} + if err := migrateOpenCodeSettings(agentDir, manifest); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(path); err != nil { + t.Fatalf("settings symlink removed: %v", err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(path, 0755); err != nil { + t.Fatal(err) + } + manifest.SettingsCreated = true + manifest.Files = []string{"opencode.json"} + if err := migrateOpenCodeSettings(agentDir, manifest); err == nil { + t.Fatal("expected cleanup error for non-file") + } + if !manifest.SettingsCreated || !slices.Contains(manifest.Files, "opencode.json") { + t.Fatal("failed settings cleanup lost ownership") + } +} + +func TestOpenCodePluginTransitions(t *testing.T) { + for _, previous := range []IntegrationLevel{IntegrationHook, IntegrationAgentsContentLocal} { + for _, target := range []IntegrationLevel{IntegrationHook, IntegrationAgentsContentLocal, IntegrationAgentsContent, IntegrationOnDemand} { + for _, mode := range []config.ComponentMode{config.ComponentModeLocal, config.ComponentModeGlobal, config.ComponentModeDisabled} { + t.Run(string(previous)+"/"+string(target)+"/"+string(mode), func(t *testing.T) { + setupOpenCodeTest(t) + project := t.TempDir() + if err := initAgent(project, internalagents.AgentOpenCode, previous); err != nil { + t.Fatal(err) + } + if err := config.Save(&config.Config{Agents: &config.AgentsConfig{PerAgent: map[string]*config.AgentComponentModes{ + "opencode": {Hooks: mode}, + }}}); err != nil { + t.Fatal(err) + } + if err := initAgent(project, internalagents.AgentOpenCode, target); err != nil { + t.Fatal(err) + } + if err := refreshAgentSetup(project, []internalagents.AgentType{internalagents.AgentOpenCode}); err != nil { + t.Fatal(err) + } + agentDir := filepath.Join(project, ".opencode") + _, err := os.Stat(filepath.Join(agentDir, "plugins", "thts-integration.ts")) + want := mode == config.ComponentModeLocal && (target == IntegrationHook || target == IntegrationAgentsContentLocal) + if (err == nil) != want { + t.Fatalf("plugin exists = %t, want %t (%v)", err == nil, want, err) + } + if _, err := os.Stat(filepath.Join(agentDir, "AGENTS.local.md")); !os.IsNotExist(err) { + t.Fatalf("unsupported local fallback exists: %v", err) + } + }) + } + } + } +} + +func TestOpenCodeRefreshRemovesOwnedPluginOnly(t *testing.T) { + for _, owned := range []bool{false, true} { + t.Run(map[bool]string{false: "unowned", true: "owned"}[owned], func(t *testing.T) { + setupOpenCodeTest(t) + project := t.TempDir() + if err := initAgent(project, internalagents.AgentOpenCode, IntegrationAgentsContentLocal); err != nil { + t.Fatal(err) + } + agentDir := filepath.Join(project, ".opencode") + manifest, err := loadManifest(agentDir) + if err != nil { + t.Fatal(err) + } + if !owned { + manifest.Files = removeStringValue(manifest.Files, "plugins/thts-integration.ts") + if err := writeManifest(agentDir, manifest); err != nil { + t.Fatal(err) + } + } + if err := config.Save(&config.Config{Agents: &config.AgentsConfig{PerAgent: map[string]*config.AgentComponentModes{ + "opencode": {Hooks: config.ComponentModeGlobal}, + }}}); err != nil { + t.Fatal(err) + } + if err := refreshAgentSetup(project, []internalagents.AgentType{internalagents.AgentOpenCode}); err != nil { + t.Fatal(err) + } + _, err = os.Stat(filepath.Join(agentDir, "plugins", "thts-integration.ts")) + if (err == nil) == owned { + t.Fatalf("plugin removal did not honor ownership: %v", err) + } + }) + } +} + +func TestRefreshCLISelectionAndDryRun(t *testing.T) { + setupOpenCodeTest(t) + project := t.TempDir() + t.Chdir(project) + for _, agent := range []internalagents.AgentType{internalagents.AgentOpenCode, internalagents.AgentPi} { + if err := initAgent(project, agent, IntegrationHook); err != nil { + t.Fatal(err) + } + } + plugin := filepath.Join(project, ".opencode", "plugins", "thts-integration.ts") + pi := filepath.Join(project, ".pi", "extensions", "thts-integration.ts") + for _, path := range []string{plugin, pi} { + if err := os.WriteFile(path, []byte("stale"), 0644); err != nil { + t.Fatal(err) + } + } + initAgents, initRefresh, initDryRun = "opencode", true, true + if err := runAgentsInit(InitCmd, nil); err != nil { + t.Fatal(err) + } + for _, path := range []string{plugin, pi} { + if got, err := os.ReadFile(path); err != nil || string(got) != "stale" { + t.Fatalf("dry-run wrote %s: %q, %v", path, got, err) + } + } + initDryRun = false + if err := runAgentsInit(InitCmd, nil); err != nil { + t.Fatal(err) + } + want, err := thtsfiles.OpenCodePlugins.ReadFile("embedded/plugins/opencode/thts-integration.ts") + if err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(plugin); err != nil || string(got) != string(want) { + t.Fatalf("plugin not refreshed: %v", err) + } + if got, err := os.ReadFile(pi); err != nil || string(got) != "stale" { + t.Fatalf("refresh touched unselected Pi: %q, %v", got, err) + } +} + +func TestOpenCodeRemovalRetainsFailedCleanup(t *testing.T) { + setupOpenCodeTest(t) + project := t.TempDir() + if err := initAgent(project, internalagents.AgentOpenCode, IntegrationHook); err != nil { + t.Fatal(err) + } + agentDir := filepath.Join(project, ".opencode") + plugin := filepath.Join(agentDir, "plugins", "thts-integration.ts") + if err := os.Remove(plugin); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(plugin, 0755); err != nil { + t.Fatal(err) + } + blocker := filepath.Join(plugin, "blocker") + if err := os.WriteFile(blocker, []byte("preserve"), 0644); err != nil { + t.Fatal(err) + } + if err := Uninit(project, true, []internalagents.AgentType{internalagents.AgentOpenCode}); err == nil { + t.Fatal("expected failed cleanup") + } + manifest, err := loadManifest(agentDir) + if err != nil || !slices.Equal(manifest.Files, []string{"plugins/thts-integration.ts"}) { + t.Fatalf("failed cleanup lost ownership or retained removed paths: %+v, %v", manifest, err) + } + if err := os.Remove(blocker); err != nil { + t.Fatal(err) + } + if err := Uninit(project, true, []internalagents.AgentType{internalagents.AgentOpenCode}); err != nil { + t.Fatal(err) + } +} + +func TestOpenCodeRefreshMigratesLegacyConfigInstructions(t *testing.T) { + setupOpenCodeTest(t) + project := t.TempDir() + agentDir := filepath.Join(project, ".opencode") + if err := os.MkdirAll(agentDir, 0755); err != nil { + t.Fatal(err) + } + settings := filepath.Join(project, "opencode.json") + if err := os.WriteFile(settings, []byte(`{"model":"user/model","instructions":[".opencode/thts-instructions.md","keep.md"]}`), 0644); err != nil { + t.Fatal(err) + } + manifest := &Manifest{ + Agent: "opencode", IntegrationLevel: IntegrationAgentsContent, + Modifications: ManifestModifications{InstructionsMD: &InstructionsMDModification{ + Path: settings, Action: "modified", IntegrationType: "config", ConfigKey: "instructions", + }}, + } + if err := writeManifest(agentDir, manifest); err != nil { + t.Fatal(err) + } + for range 2 { + if err := refreshAgentSetup(project, []internalagents.AgentType{internalagents.AgentOpenCode}); err != nil { + t.Fatal(err) + } + } + got, err := os.ReadFile(settings) + if err != nil || strings.Contains(string(got), "thts-instructions.md") || !strings.Contains(string(got), "keep.md") || !strings.Contains(string(got), "user/model") { + t.Fatalf("config migration changed user values or retained obsolete entry: %s, %v", got, err) + } + policy, err := os.ReadFile(filepath.Join(project, "AGENTS.md")) + if err != nil || strings.Count(string(policy), ThtsMarkerStart) != 1 { + t.Fatalf("shared policy not migrated: %s, %v", policy, err) + } + if err := initAgent(project, internalagents.AgentOpenCode, IntegrationOnDemand); err != nil { + t.Fatal(err) + } + policy, err = os.ReadFile(filepath.Join(project, "AGENTS.md")) + if err == nil && strings.Contains(string(policy), ThtsMarkerStart) { + t.Fatal("on-demand transition retained shared policy") + } +} diff --git a/internal/cmd/agents/uninit.go b/internal/cmd/agents/uninit.go index 2084915..ece821e 100644 --- a/internal/cmd/agents/uninit.go +++ b/internal/cmd/agents/uninit.go @@ -252,6 +252,9 @@ func buildRemovalPlan(projectDir string, agentType agents.AgentType) (*removalPl // Try to load manifest manifest, err := loadManifest(agentDir) if err != nil { + if agentType == agents.AgentOpenCode && !os.IsNotExist(err) { + return nil, err + } // Droid support has no legacy manifestless installations. Inferring // ownership from native Factory filenames could delete user resources. if agentType == agents.AgentDroid { @@ -269,6 +272,16 @@ func buildRemovalPlan(projectDir string, agentType agents.AgentType) (*removalPl } plan.manifest = manifest + if agentType == agents.AgentOpenCode { + remove, err := isLegacyOpenCodeSettings(agentDir, manifest) + if err != nil { + return nil, err + } + if !remove { + manifest.Files = removeStringValue(manifest.Files, "opencode.json") + manifest.SettingsCreated = false + } + } plan.filesToRemove = slices.Clone(manifest.Files) plan.modifications = manifest.Modifications @@ -572,6 +585,13 @@ func isPathSafeForRemoval(relativePath, baseDir string) bool { // performRemoval removes all thts integration files and reverts modifications. func performRemoval(plan *removalPlan, removingAgents map[agents.AgentType]bool) error { cfg := agents.GetConfig(plan.agentType) + retainProgress := plan.manifest != nil && (plan.agentType == agents.AgentDroid || plan.agentType == agents.AgentOpenCode) + if plan.agentType == agents.AgentOpenCode && plan.manifest != nil { + if err := migrateOpenCodeSettings(plan.agentDir, plan.manifest); err != nil { + return err + } + plan.filesToRemove = removeStringValue(plan.filesToRemove, "opencode.json") + } var warnings []string droidHooksRemoved := false if plan.agentType == agents.AgentDroid && plan.modifications.Hooks != nil { @@ -590,7 +610,7 @@ func performRemoval(plan *removalPlan, removingAgents map[agents.AgentType]bool) } // Validate path is safe to remove if !isPathSafeForRemoval(f, plan.agentDir) { - if plan.agentType == agents.AgentDroid { + if retainProgress { warnings = append(warnings, fmt.Sprintf("refused to remove unsafe manifest path %s", f)) } continue @@ -602,7 +622,7 @@ func performRemoval(plan *removalPlan, removingAgents map[agents.AgentType]bool) } else if err == nil { fmt.Println(ui.SuccessF("Removed %s", f)) } - if plan.agentType == agents.AgentDroid && (err == nil || os.IsNotExist(err)) { + if retainProgress && (err == nil || os.IsNotExist(err)) { plan.manifest.Files = removeStringValue(plan.manifest.Files, f) } } @@ -664,7 +684,7 @@ func performRemoval(plan *removalPlan, removingAgents map[agents.AgentType]bool) if err := removeThtsIntegration(plan.modifications.InstructionsMD, plan.agentType, plan.projectDir, removingAgents); err != nil { warnings = append(warnings, fmt.Sprintf("failed to clean instruction file: %v", err)) } else { - if plan.agentType == agents.AgentDroid { + if retainProgress { plan.manifest.Modifications.InstructionsMD = nil } fmt.Println(ui.Success("Removed thts integration from instruction file")) @@ -680,11 +700,11 @@ func performRemoval(plan *removalPlan, removingAgents map[agents.AgentType]bool) } else if removed { fmt.Println(ui.SuccessF("Removed %s from .gitignore", pattern)) } - if plan.agentType == agents.AgentDroid && err == nil { + if retainProgress && err == nil { plan.manifest.Modifications.Gitignore.Patterns = removeStringValue(plan.manifest.Modifications.Gitignore.Patterns, pattern) } } - if plan.agentType == agents.AgentDroid && len(plan.manifest.Modifications.Gitignore.Patterns) == 0 { + if retainProgress && len(plan.manifest.Modifications.Gitignore.Patterns) == 0 { plan.manifest.Modifications.Gitignore = nil } } @@ -694,7 +714,7 @@ func performRemoval(plan *removalPlan, removingAgents map[agents.AgentType]bool) for _, w := range warnings { fmt.Printf(" %s\n", w) } - if plan.agentType == agents.AgentDroid { + if retainProgress { if err := writeManifest(plan.agentDir, plan.manifest); err != nil { warnings = append(warnings, fmt.Sprintf("failed to retain manifest: %v", err)) } diff --git a/mise.lock b/mise.lock index 589aa5f..e3dda00 100644 --- a/mise.lock +++ b/mise.lock @@ -1,6 +1,6 @@ # @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html -lockfile_version = 1 +lockfile_version = 2 [[tools."aqua:goreleaser/goreleaser"]] version = "2.18.1" @@ -173,5 +173,6 @@ url = "https://dl.google.com/go/go1.27.1.windows-amd64.zip" [[tools.trunk]] version = "1.3.4" +aube = { path = ".mise/locks/trunk/1.3.4", digest = "sha256:af09c5952cbbdf895bb558cb7ac8f0c2d09885d497419c257dbe0a6efab81f8f" } backend = "npm:@trunkio/launcher" specifiers = ["1.3.4"] diff --git a/mise.toml b/mise.toml index c0628a5..e0419b8 100644 --- a/mise.toml +++ b/mise.toml @@ -25,6 +25,7 @@ run = """ go test -tags=integration ./internal/cmd/... scripts/verify-pi-integration.sh scripts/verify-droid-integration.sh +scripts/verify-opencode-integration.sh """ [tasks.test-all] @@ -33,6 +34,7 @@ run = """ go test -tags=integration ./internal/cmd/... scripts/verify-pi-integration.sh scripts/verify-droid-integration.sh +scripts/verify-opencode-integration.sh """ [tasks.test-coverage] diff --git a/scripts/verify-opencode-hosts.ts b/scripts/verify-opencode-hosts.ts new file mode 100644 index 0000000..6ca68c4 --- /dev/null +++ b/scripts/verify-opencode-hosts.ts @@ -0,0 +1,302 @@ +// Actual host verification, with isolated homes and a local mock model (no credentials). +// Usage: bun scripts/verify-opencode-hosts.ts /path/to/v1.18.29 /path/to/v2.0.6 +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const binaries = process.argv.slice(2).map((path) => resolve(path)); +assert.equal(binaries.length, 2, "Provide v1.18.29 and v2.0.6 binary paths"); +const root = await mkdtemp(join(tmpdir(), "thts-opencode-hosts-")); +const thts = join(root, "thts"); +type ModelRequest = { messages: { role: string; content: unknown }[] }; +const requests: ModelRequest[] = []; +const model = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + requests.push(await request.json()); + const chunk = { + id: "fixture", + object: "chat.completion.chunk", + created: 1, + model: "test", + choices: [ + { + index: 0, + delta: { role: "assistant", content: "Verified." }, + finish_reason: null, + }, + ], + }; + const finish = { + ...chunk, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 100, completion_tokens: 2, total_tokens: 102 }, + }; + return new Response( + `data: ${JSON.stringify(chunk)}\n\ndata: ${JSON.stringify(finish)}\n\ndata: [DONE]\n\n`, + { + headers: { "content-type": "text/event-stream" }, + }, + ); + }, +}); + +function run(command: string[], cwd: string, env = process.env) { + const result = Bun.spawnSync(command, { + cwd, + env, + stdout: "pipe", + stderr: "pipe", + }); + assert.equal( + result.exitCode, + 0, + `${command.join(" ")}: ${result.stderr.toString()}`, + ); + return result.stdout.toString(); +} + +async function until(check: () => Promise, label: string) { + for (let attempt = 0; attempt < 150; attempt++) { + if (await check()) return; + await Bun.sleep(200); + } + throw new Error(`Timed out: ${label}`); +} + +try { + run( + ["go", "build", "-o", thts, "./cmd/thts"], + resolve(import.meta.dir, ".."), + ); + for (const [index, binary] of binaries.entries()) { + const v2 = index === 1; + const version = v2 ? "2.0.6" : "1.18.29"; + assert.equal( + run([binary, "--version"], root).trim(), + v2 ? `opencode v${version}` : version, + ); + const base = join(root, version); + const bin = join(base, "bin"); + const home = join(base, "home"); + const projects = [join(base, "overlap"), join(base, "global-only")]; + for (const directory of [bin, home, ...projects]) + await mkdir(directory, { recursive: true }); + await writeFile( + join(bin, "thts"), + `#!${process.execPath} +if (process.argv.slice(2).join(" ") === "agent-instructions") { + console.log("# thts Integration Instructions\\nHOST-POLICY:" + process.cwd()); +} +`, + { mode: 0o755 }, + ); + // Do not inherit credentials, user config overrides, or external skills. + const env = { + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, + XDG_CONFIG_HOME: join(base, "config"), + XDG_DATA_HOME: join(base, "data"), + XDG_CACHE_HOME: join(base, "cache"), + XDG_STATE_HOME: join(base, "state"), + THTS_CONFIG_PATH: join(base, "thts.yaml"), + OPENCODE_SERVER_PASSWORD: "fixture-only", + OPENCODE_DISABLE_DEFAULT_PLUGINS: "1", + OPENCODE_DISABLE_EXTERNAL_SKILLS: "1", + OPENCODE_DISABLE_UPDATE_CHECK: "1", + OPENCODE_DISABLE_MODELS_FETCH: "1", + DO_NOT_TRACK: "1", + CI: "true", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + }; + const modelInfo = { + name: "Test", + limit: { context: 128000, output: 1000 }, + }; + const settings = { + baseURL: `http://127.0.0.1:${model.port}/v1`, + apiKey: "fixture", + }; + const config = v2 + ? { + model: "test/test", + providers: { + test: { + name: "Test", + package: "@opencode/ai/providers/openai-compatible", + settings, + models: { test: modelInfo }, + }, + }, + } + : { + model: "test/test", + small_model: "test/test", + provider: { + test: { + name: "Test", + npm: "@ai-sdk/openai-compatible", + options: settings, + models: { test: modelInfo }, + }, + }, + }; + for (const directory of projects) { + run(["git", "init", "-q"], directory, env); + await writeFile(join(directory, "opencode.json"), JSON.stringify(config)); + } + run([thts, "init", "agents", "--agents", "opencode"], projects[0], env); + run( + [thts, "init", "agents", "--agents", "opencode", "--global=all"], + projects[0], + env, + ); + + // Let the OS choose a port; parse only the address, never server log credentials. + const child = Bun.spawn( + [binary, "serve", "--hostname", "127.0.0.1", "--port", "0"], + { + cwd: projects[0], + env, + stdout: "pipe", + stderr: "pipe", + }, + ); + const stderr = new Response(child.stderr).text(); + let address = ""; + const output = (async () => { + for await (const chunk of child.stdout) { + address ||= + new TextDecoder() + .decode(chunk) + .match(/http:\/\/127\.0\.0\.1:\d+/)?.[0] ?? ""; + } + })(); + try { + await until(async () => Boolean(address), `${version} server startup`); + async function api(path: string, directory: string, body?: unknown) { + const response = await fetch(`${address}${path}`, { + method: body === undefined ? "GET" : "POST", + headers: { + "content-type": "application/json", + "x-opencode-directory": directory, + authorization: `Basic ${btoa("opencode:fixture-only")}`, + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(30_000), + }); + const text = await response.text(); + assert.ok( + response.ok, + `${version} ${path}: ${response.status} ${text}`, + ); + const parsed = text ? JSON.parse(text) : undefined; + return v2 ? parsed?.data : parsed; + } + const prefix = v2 ? "/api" : ""; + for (const directory of projects) { + if (v2) { + await until( + async () => + (await api("/api/plugin", directory)).some( + (entry: { id: string; state: { status: string } }) => + entry.id === "thts.integration" && + entry.state.status === "active", + ), + "thts plugin activation", + ); + } + for (const [kind, names] of [ + ["agent", ["thoughts-locator", "thoughts-analyzer"]], + ["skill", ["thts-integrate"]], + ["command", ["thts-handoff", "thts-resume"]], + ] as const) { + const resources = await api(`${prefix}/${kind}`, directory); + for (const name of names) + assert.ok( + resources.some( + (entry: { id?: string; name: string }) => + entry.id === name || entry.name === name, + ), + `${version}: missing ${name}`, + ); + } + const session = await api(`${prefix}/session`, directory, { + title: "Verification", + model: { providerID: "test", id: "test" }, + ...(v2 ? { location: { directory } } : {}), + }); + for (const compact of [false, true]) { + const before = requests.length; + const operation = compact + ? v2 + ? "compact" + : "summarize" + : v2 + ? "prompt" + : "message"; + const body = compact + ? v2 + ? {} + : { providerID: "test", modelID: "test" } + : v2 + ? { text: "Say verified." } + : { + parts: [{ type: "text", text: "Say verified." }], + model: { providerID: "test", modelID: "test" }, + }; + await api( + `${prefix}/session/${session.id}/${operation}`, + directory, + body, + ); + if (v2) + await api( + `/api/experimental/session/${session.id}/wait`, + directory, + {}, + ); + assert.ok( + requests.length > before, + `${version}: ${operation} made no model request`, + ); + for (const request of requests.slice(before)) { + const system = JSON.stringify( + request.messages.filter( + (message) => + message.role === "system" || message.role === "developer", + ), + ); + assert.equal( + system.split("").length - 1, + 1, + `${version}: ${operation} policy count`, + ); + assert.ok( + system.includes(`HOST-POLICY:${directory}`), + `${version}: wrong project policy`, + ); + } + } + console.log( + `PASS: ${version} ${directory === projects[0] ? "global/project overlap" : "global-only second project"}: resources, prompt, compaction`, + ); + } + } finally { + child.kill(); + await child.exited; + await output; + const errors = await stderr; + assert.ok( + !errors.includes("failed to load plugin"), + `${version}: plugin load failure`, + ); + } + } +} finally { + model.stop(true); + await rm(root, { recursive: true, force: true }); +} diff --git a/scripts/verify-opencode-integration.sh b/scripts/verify-opencode-integration.sh new file mode 100755 index 0000000..01b17de --- /dev/null +++ b/scripts/verify-opencode-integration.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +pass() { printf 'PASS: %s\n' "$1"; } +fail() { printf 'FAIL: %s\n' "$1"; exit 1; } +require_file() { [[ -f "$1" ]] || fail "expected file: $1"; } +require_absent() { [[ ! -e "$1" ]] || fail "expected absent path: $1"; } + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +repo_root=$(git -C "$script_dir/.." rev-parse --show-toplevel) +temp_dir=$(mktemp -d "${TMPDIR:-/tmp}/thts-opencode-verify.XXXXXX") +trap 'rm -rf "$temp_dir"' EXIT +binary="$temp_dir/thts" +go -C "$repo_root" build -o "$binary" ./cmd/thts + +export HOME="$temp_dir/home" +export XDG_CONFIG_HOME="$temp_dir/config" +export XDG_STATE_HOME="$temp_dir/state" +export THTS_CONFIG_PATH="$XDG_CONFIG_HOME/thts/config.yaml" +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 GIT_TERMINAL_PROMPT=0 +mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_STATE_HOME" "$temp_dir/project" +cd "$temp_dir/project" + +output=$("$binary" init agents --agents opencode --with-settings --dry-run) +[[ "$output" == *"has no thts-managed settings"* ]] || fail "dry-run omitted settings ownership" +require_absent .opencode +pass "fresh dry-run creates nothing" + +mkdir -p .opencode +printf '{"model":"user/model"}\n' >.opencode/opencode.json +cp .opencode/opencode.json "$temp_dir/user-settings" +"$binary" init agents --agents opencode --with-settings >/dev/null +for path in plugins/thts-integration.ts skills/thts-integrate/SKILL.md commands/thts-handoff.md commands/thts-resume.md agents/thoughts-locator.md agents/thoughts-analyzer.md; do + require_file ".opencode/$path" +done +cmp .opencode/opencode.json "$temp_dir/user-settings" +require_absent .opencode/AGENTS.local.md +pass "project install preserves settings and installs all resources" + +"$binary" init agents --agents pi --force >/dev/null +printf 'stale opencode\n' >.opencode/plugins/thts-integration.ts +printf 'stale pi\n' >.pi/extensions/thts-integration.ts +cp .opencode/thts-manifest.json "$temp_dir/manifest" +output=$("$binary" init agents --agents opencode --refresh --dry-run) +[[ "$output" == *"Would refresh: opencode"* ]] || fail "refresh ignored agent selection" +cmp .opencode/thts-manifest.json "$temp_dir/manifest" +[[ $(<.opencode/plugins/thts-integration.ts) == "stale opencode" ]] || fail "dry-run changed plugin" +"$binary" init agents --agents opencode --refresh >/dev/null +cmp .opencode/plugins/thts-integration.ts "$repo_root/embedded/plugins/opencode/thts-integration.ts" +[[ $(<.pi/extensions/thts-integration.ts) == "stale pi" ]] || fail "refresh changed another agent" +pass "targeted refresh and refresh dry-run honor selection" + +"$binary" init agents --agents pi --global=all >/dev/null +"$binary" init agents --agents opencode --global=all >/dev/null +global="$XDG_CONFIG_HOME/opencode" +require_file "$global/plugins/thts-integration.ts" +require_absent "$global/opencode.json" +"$binary" init agents --agents opencode --refresh >/dev/null +require_absent .opencode/plugins/thts-integration.ts +require_file "$global/plugins/thts-integration.ts" +pass "global transition removes the owned project plugin" + +printf 'unrelated plugin\n' >"$global/plugins/user.ts" +"$binary" uninit agents --agents opencode --global --force >/dev/null +require_absent "$global/plugins/thts-integration.ts" +require_file "$global/plugins/user.ts" +require_file "$HOME/.pi/agent/extensions/thts-integration.ts" +pass "global removal preserves unrelated files and other agents" + +"$binary" uninit agents --agents opencode --force >/dev/null +require_absent .opencode/thts-manifest.json +require_absent .opencode/skills/thts-integrate/SKILL.md +cmp .opencode/opencode.json "$temp_dir/user-settings" +require_file .pi/extensions/thts-integration.ts +pass "project removal preserves user settings and other agents" From 5e3e3aee72705f595426b08728a98f0c62e446d8 Mon Sep 17 00:00:00 2001 From: Scott Ames Date: Sat, 19 Sep 2026 19:39:06 -0700 Subject: [PATCH 2/5] ci(trunk): ignore mise locks --- .trunk/trunk.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 405e212..9642020 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -24,6 +24,7 @@ lint: - linters: [markdownlint, prettier] paths: - CHANGELOG.md + - .mise/**/*.yaml enabled: - gofmt@1.20.4 - osv-scanner@2.6.0 From e20628436c4e6cb161c55f92abbf0e0ccf5a668a Mon Sep 17 00:00:00 2001 From: "scottames-github-bot[bot]" <162828115+scottames-github-bot[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 02:40:11 +0000 Subject: [PATCH 3/5] chore(mise): lock --- .mise/locks/trunk/1.3.4/aube-lock.yaml | 105 +++++++++++++++++++++++++ .mise/locks/trunk/1.3.4/package.json | 7 ++ mise.lock | 60 ++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 .mise/locks/trunk/1.3.4/aube-lock.yaml create mode 100644 .mise/locks/trunk/1.3.4/package.json diff --git a/.mise/locks/trunk/1.3.4/aube-lock.yaml b/.mise/locks/trunk/1.3.4/aube-lock.yaml new file mode 100644 index 0000000..d742e72 --- /dev/null +++ b/.mise/locks/trunk/1.3.4/aube-lock.yaml @@ -0,0 +1,105 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@trunkio/launcher': + specifier: 1.3.4 + version: 1.3.4 + +packages: + + '@trunkio/launcher@1.3.4': + resolution: {integrity: sha512-4LCsFVvZtKht7EkbOq5gDsRLIBOH05ycNxm1Vrv+YzY+uOK2HueLBcLU8oejV9v01LTtWjfLJxonIgTSo7lwng==} + engines: {node: '>=18.0.0'} + hasBin: true + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + '@trunkio/launcher@1.3.4': + dependencies: + semver: 7.8.5 + tar: 6.2.1 + yaml: 2.9.1 + + chownr@2.0.0: {} + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp@1.0.4: {} + + semver@7.8.5: {} + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + yallist@4.0.0: {} + + yaml@2.9.1: {} diff --git a/.mise/locks/trunk/1.3.4/package.json b/.mise/locks/trunk/1.3.4/package.json new file mode 100644 index 0000000..ee9d50e --- /dev/null +++ b/.mise/locks/trunk/1.3.4/package.json @@ -0,0 +1,7 @@ +{ + "name": "mise-npm-install", + "private": true, + "dependencies": { + "@trunkio/launcher": "1.3.4" + } +} diff --git a/mise.lock b/mise.lock index e3dda00..1892802 100644 --- a/mise.lock +++ b/mise.lock @@ -25,12 +25,24 @@ url = "https://github.com/goreleaser/goreleaser/releases/download/v2.18.1/gorele url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/546377081" provenance = "github-attestations" +[tools."aqua:goreleaser/goreleaser"."platforms.linux-x64-baseline"] +checksum = "sha256:0c6122af0ad8fd65638889bf7d3757148b2f80eeff9f079682f0655df66ec8e8" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.18.1/goreleaser_Linux_x86_64.tar.gz" +url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/546377081" +provenance = "github-attestations" + [tools."aqua:goreleaser/goreleaser"."platforms.linux-x64-musl"] checksum = "sha256:0c6122af0ad8fd65638889bf7d3757148b2f80eeff9f079682f0655df66ec8e8" url = "https://github.com/goreleaser/goreleaser/releases/download/v2.18.1/goreleaser_Linux_x86_64.tar.gz" url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/546377081" provenance = "github-attestations" +[tools."aqua:goreleaser/goreleaser"."platforms.linux-x64-musl-baseline"] +checksum = "sha256:0c6122af0ad8fd65638889bf7d3757148b2f80eeff9f079682f0655df66ec8e8" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.18.1/goreleaser_Linux_x86_64.tar.gz" +url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/546377081" +provenance = "github-attestations" + [tools."aqua:goreleaser/goreleaser"."platforms.macos-arm64"] checksum = "sha256:d0af1f86c439a59ab06c54c505f66fea1a166093dc91a7c0c8aebe3da1cc13b7" url = "https://github.com/goreleaser/goreleaser/releases/download/v2.18.1/goreleaser_Darwin_all.tar.gz" @@ -43,12 +55,24 @@ url = "https://github.com/goreleaser/goreleaser/releases/download/v2.18.1/gorele url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/546377101" provenance = "github-attestations" +[tools."aqua:goreleaser/goreleaser"."platforms.macos-x64-baseline"] +checksum = "sha256:d0af1f86c439a59ab06c54c505f66fea1a166093dc91a7c0c8aebe3da1cc13b7" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.18.1/goreleaser_Darwin_all.tar.gz" +url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/546377101" +provenance = "github-attestations" + [tools."aqua:goreleaser/goreleaser"."platforms.windows-x64"] checksum = "sha256:dbb3112f619c4827311b726b3c98047bce69af1ecee99984d55d450c04c471fc" url = "https://github.com/goreleaser/goreleaser/releases/download/v2.18.1/goreleaser_Windows_x86_64.zip" url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/546377158" provenance = "github-attestations" +[tools."aqua:goreleaser/goreleaser"."platforms.windows-x64-baseline"] +checksum = "sha256:dbb3112f619c4827311b726b3c98047bce69af1ecee99984d55d450c04c471fc" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.18.1/goreleaser_Windows_x86_64.zip" +url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/546377158" +provenance = "github-attestations" + [[tools."aqua:int128/ghcp"]] version = "1.15.3" backend = "aqua:int128/ghcp" @@ -69,11 +93,21 @@ checksum = "sha256:537b71c1382fc95c73702d4ba0fc1a2465842a1c20f9987d75b5c7581ba11 url = "https://github.com/int128/ghcp/releases/download/v1.15.3/ghcp_linux_amd64.zip" url_api = "https://api.github.com/repos/int128/ghcp/releases/assets/394091706" +[tools."aqua:int128/ghcp"."platforms.linux-x64-baseline"] +checksum = "sha256:537b71c1382fc95c73702d4ba0fc1a2465842a1c20f9987d75b5c7581ba115ea" +url = "https://github.com/int128/ghcp/releases/download/v1.15.3/ghcp_linux_amd64.zip" +url_api = "https://api.github.com/repos/int128/ghcp/releases/assets/394091706" + [tools."aqua:int128/ghcp"."platforms.linux-x64-musl"] checksum = "sha256:537b71c1382fc95c73702d4ba0fc1a2465842a1c20f9987d75b5c7581ba115ea" url = "https://github.com/int128/ghcp/releases/download/v1.15.3/ghcp_linux_amd64.zip" url_api = "https://api.github.com/repos/int128/ghcp/releases/assets/394091706" +[tools."aqua:int128/ghcp"."platforms.linux-x64-musl-baseline"] +checksum = "sha256:537b71c1382fc95c73702d4ba0fc1a2465842a1c20f9987d75b5c7581ba115ea" +url = "https://github.com/int128/ghcp/releases/download/v1.15.3/ghcp_linux_amd64.zip" +url_api = "https://api.github.com/repos/int128/ghcp/releases/assets/394091706" + [tools."aqua:int128/ghcp"."platforms.macos-arm64"] checksum = "sha256:83c8fb1e167adbfbe767288e9679d1a34b8a59241e56f84379ff9b9e1ae709f2" url = "https://github.com/int128/ghcp/releases/download/v1.15.3/ghcp_darwin_arm64.zip" @@ -84,11 +118,21 @@ checksum = "sha256:5553fba0e479ec6ae55a479399e64fe78a1b0242e85b8be2c65e2ba6a1d4a url = "https://github.com/int128/ghcp/releases/download/v1.15.3/ghcp_darwin_amd64.zip" url_api = "https://api.github.com/repos/int128/ghcp/releases/assets/394091693" +[tools."aqua:int128/ghcp"."platforms.macos-x64-baseline"] +checksum = "sha256:5553fba0e479ec6ae55a479399e64fe78a1b0242e85b8be2c65e2ba6a1d4aef9" +url = "https://github.com/int128/ghcp/releases/download/v1.15.3/ghcp_darwin_amd64.zip" +url_api = "https://api.github.com/repos/int128/ghcp/releases/assets/394091693" + [tools."aqua:int128/ghcp"."platforms.windows-x64"] checksum = "sha256:42b101dfaa7e7a66585ecbf262c2c2111bbd00e7cdb063b554bc7df467744cb1" url = "https://github.com/int128/ghcp/releases/download/v1.15.3/ghcp_windows_amd64.zip" url_api = "https://api.github.com/repos/int128/ghcp/releases/assets/394091732" +[tools."aqua:int128/ghcp"."platforms.windows-x64-baseline"] +checksum = "sha256:42b101dfaa7e7a66585ecbf262c2c2111bbd00e7cdb063b554bc7df467744cb1" +url = "https://github.com/int128/ghcp/releases/download/v1.15.3/ghcp_windows_amd64.zip" +url_api = "https://api.github.com/repos/int128/ghcp/releases/assets/394091732" + [[tools.bun]] version = "1.4.2" backend = "core:bun" @@ -155,10 +199,18 @@ url = "https://dl.google.com/go/go1.27.1.linux-arm64.tar.gz" checksum = "sha256:63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445" url = "https://dl.google.com/go/go1.27.1.linux-amd64.tar.gz" +[tools.go."platforms.linux-x64-baseline"] +checksum = "sha256:63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445" +url = "https://dl.google.com/go/go1.27.1.linux-amd64.tar.gz" + [tools.go."platforms.linux-x64-musl"] checksum = "sha256:63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445" url = "https://dl.google.com/go/go1.27.1.linux-amd64.tar.gz" +[tools.go."platforms.linux-x64-musl-baseline"] +checksum = "sha256:63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445" +url = "https://dl.google.com/go/go1.27.1.linux-amd64.tar.gz" + [tools.go."platforms.macos-arm64"] checksum = "sha256:ee215d57e0ec269c60cc9ceca68e6bda321ba9ee5afe24f4b0988703c2d87d12" url = "https://dl.google.com/go/go1.27.1.darwin-arm64.tar.gz" @@ -167,10 +219,18 @@ url = "https://dl.google.com/go/go1.27.1.darwin-arm64.tar.gz" checksum = "sha256:8f8f52c6649542cf027bbc9b9c68d1ec042f9f34808a40413f0b8b3f66f3caa4" url = "https://dl.google.com/go/go1.27.1.darwin-amd64.tar.gz" +[tools.go."platforms.macos-x64-baseline"] +checksum = "sha256:8f8f52c6649542cf027bbc9b9c68d1ec042f9f34808a40413f0b8b3f66f3caa4" +url = "https://dl.google.com/go/go1.27.1.darwin-amd64.tar.gz" + [tools.go."platforms.windows-x64"] checksum = "sha256:a3911b5e0e1b1053f25ed0675f4c1c6aad1e2bfcf253df2b9be4caabd2edd95d" url = "https://dl.google.com/go/go1.27.1.windows-amd64.zip" +[tools.go."platforms.windows-x64-baseline"] +checksum = "sha256:a3911b5e0e1b1053f25ed0675f4c1c6aad1e2bfcf253df2b9be4caabd2edd95d" +url = "https://dl.google.com/go/go1.27.1.windows-amd64.zip" + [[tools.trunk]] version = "1.3.4" aube = { path = ".mise/locks/trunk/1.3.4", digest = "sha256:af09c5952cbbdf895bb558cb7ac8f0c2d09885d497419c257dbe0a6efab81f8f" } From 0b03918920f62b7230c89e3e3d637bfc79e288e1 Mon Sep 17 00:00:00 2001 From: Scott Ames Date: Sun, 20 Sep 2026 13:01:46 -0700 Subject: [PATCH 4/5] fix(agents): address OpenCode concurrency and host verification races Discard pending policy results after directory cache invalidation or replacement. Buffer host startup output until the complete server port is available. Cover both adapters with gated concurrency regressions, exercise stdout chunk boundaries, and document cache invalidation behavior. --- docs/guide.md | 2 + .../plugins/opencode/thts-integration.test.ts | 61 ++++++++++++++++++- embedded/plugins/opencode/thts-integration.ts | 3 +- scripts/verify-opencode-hosts.ts | 33 ++++++++-- 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/docs/guide.md b/docs/guide.md index b8e25fe..626bc1e 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -930,6 +930,8 @@ prevent project or user thts hooks from running. The OpenCode plugin caches generated instructions per working directory for the plugin's lifetime, while checking project initialization before each injection. +If a check fails, cached instructions and any pending render for that directory +are discarded. Restart OpenCode after changing thts categories or instruction configuration. For v2, restart the server as well as attached clients when a fresh plugin cache is needed. diff --git a/embedded/plugins/opencode/thts-integration.test.ts b/embedded/plugins/opencode/thts-integration.test.ts index 954b194..2d063b3 100644 --- a/embedded/plugins/opencode/thts-integration.test.ts +++ b/embedded/plugins/opencode/thts-integration.test.ts @@ -1,11 +1,18 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { + mkdtemp, + mkdir, + readFile, + rm, + watch, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import plugin from "./thts-integration"; -type Response = { exitCode: number; stdout?: string }; +type Response = { exitCode: number; stdout?: string; waitForRelease?: boolean }; type Event = { sessionID: string; system: { type: "text"; text: string }[] }; const success = { exitCode: 0 }; const instructions = { @@ -21,12 +28,16 @@ beforeEach(async () => { await writeFile( join(root, "thts"), `#!${process.execPath} -const { readFileSync, writeFileSync, appendFileSync } = require("node:fs"); +const { readFileSync, writeFileSync, appendFileSync, existsSync } = require("node:fs"); const responses = JSON.parse(readFileSync("responses.json", "utf8")); const response = responses.shift(); appendFileSync("calls.txt", process.argv.slice(2).join(" ") + "\\n"); writeFileSync("responses.json", JSON.stringify(responses)); if (!response) process.exit(99); +if (response.waitForRelease) { + writeFileSync("render-started", ""); + while (!existsSync("release-render")) await Bun.sleep(5); +} process.stdout.write(response.stdout ?? ""); process.exit(response.exitCode); `, @@ -149,6 +160,50 @@ for (const version of [1, 2]) { ]); }); + for (const replace of [false, true]) { + test(`discards an in-flight policy after cache ${replace ? "replacement" : "deletion"}`, async () => { + const directory = await repo("repo", [ + success, + { ...instructions, waitForRelease: true }, + { exitCode: 1 }, + success, + { exitCode: 0, stdout: "Fresh policy" }, + success, + ]); + const instance = await adapter(directory); + const events = watch(directory, { signal: AbortSignal.timeout(4000) }); + const stale: string[] = []; + const pending = instance.inject(stale); + const fresh: string[] = []; + try { + for await (const event of events) { + if (event.filename === "render-started") break; + } + const ineligible: string[] = []; + await instance.inject(ineligible); + expect(ineligible).toEqual([]); + if (replace) await instance.inject(fresh); + } finally { + await writeFile(join(directory, "release-render"), ""); + await pending; + } + expect(stale).toEqual([]); + if (!replace) await instance.inject(fresh); + expect(fresh[0]).toContain("Fresh policy"); + const cached: string[] = []; + await instance.inject(cached); + expect(cached).toEqual(fresh); + expect(await calls(directory)).toEqual([ + "init --check", + "agent-instructions", + "init --check", + "init --check", + "agent-instructions", + "init --check", + ]); + }); + } + test("recovers from check, render, and empty-output failures", async () => { const directory = await repo("repo", [ { exitCode: 1 }, diff --git a/embedded/plugins/opencode/thts-integration.ts b/embedded/plugins/opencode/thts-integration.ts index dda1e7a..ae4e6e6 100644 --- a/embedded/plugins/opencode/thts-integration.ts +++ b/embedded/plugins/opencode/thts-integration.ts @@ -43,8 +43,9 @@ function createPolicyLoader() { policies.set(directory, pending); } const content = await pending; + if (policies.get(directory) !== pending) return null; if (!content) { - if (policies.get(directory) === pending) policies.delete(directory); + policies.delete(directory); return null; } return `${THTS_MARKER}\n${content}`; diff --git a/scripts/verify-opencode-hosts.ts b/scripts/verify-opencode-hosts.ts index 6ca68c4..3751c02 100644 --- a/scripts/verify-opencode-hosts.ts +++ b/scripts/verify-opencode-hosts.ts @@ -5,6 +5,33 @@ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +function createServerAddressParser() { + const decoder = new TextDecoder(); + let stdout = ""; + return (chunk: Uint8Array) => { + stdout += decoder.decode(chunk, { stream: true }); + return stdout.match(/http:\/\/127\.0\.0\.1:\d+(?=\s)/)?.[0] ?? ""; + }; +} + +// Exercise every chunk boundary, including inside the port and before its delimiter. +const expectedAddress = "http://127.0.0.1:43210"; +const startup = new TextEncoder().encode( + `Server listening on ${expectedAddress}\n`, +); +for (let split = 0; split < startup.length; split++) { + const parse = createServerAddressParser(); + assert.equal(parse(startup.subarray(0, split)), ""); + assert.equal(parse(startup.subarray(split)), expectedAddress); +} +const parseByte = createServerAddressParser(); +for (let index = 0; index < startup.length; index++) { + assert.equal( + parseByte(startup.subarray(index, index + 1)), + index === startup.length - 1 ? expectedAddress : "", + ); +} + const binaries = process.argv.slice(2).map((path) => resolve(path)); assert.equal(binaries.length, 2, "Provide v1.18.29 and v2.0.6 binary paths"); const root = await mkdtemp(join(tmpdir(), "thts-opencode-hosts-")); @@ -168,11 +195,9 @@ if (process.argv.slice(2).join(" ") === "agent-instructions") { const stderr = new Response(child.stderr).text(); let address = ""; const output = (async () => { + const parse = createServerAddressParser(); for await (const chunk of child.stdout) { - address ||= - new TextDecoder() - .decode(chunk) - .match(/http:\/\/127\.0\.0\.1:\d+/)?.[0] ?? ""; + address ||= parse(chunk); } })(); try { From 83b9200956f24afbdcc455efdc78f20a5b735256 Mon Sep 17 00:00:00 2001 From: Scott Ames Date: Tue, 22 Sep 2026 11:09:13 -0700 Subject: [PATCH 5/5] fix: cleanup extras not really needed --- .github/workflows/pr_check.yml | 14 -- AGENTS.md | 10 +- README.md | 4 +- docs/contributing-agents.md | 18 -- docs/guide.md | 9 +- docs/troubleshooting.md | 3 +- scripts/verify-opencode-hosts.ts | 327 ------------------------------- 7 files changed, 17 insertions(+), 368 deletions(-) delete mode 100644 scripts/verify-opencode-hosts.ts diff --git a/.github/workflows/pr_check.yml b/.github/workflows/pr_check.yml index fe5521f..efa823d 100644 --- a/.github/workflows/pr_check.yml +++ b/.github/workflows/pr_check.yml @@ -47,17 +47,3 @@ jobs: git config --global user.name "CI" - name: integration tests run: mise run test-integration - - name: OpenCode compatibility (v1.18.29 and v2.0.6) - run: | - temp_dir=$(mktemp -d) - trap 'rm -rf "$temp_dir"' EXIT - mkdir "$temp_dir/v1" "$temp_dir/v2" - curl --fail --location --silent --show-error \ - https://github.com/anomalyco/opencode/releases/download/v1.18.29/opencode-linux-x64.tar.gz \ - -o "$temp_dir/v1.tar.gz" - curl --fail --location --silent --show-error \ - https://opencode.ai/files/bin/2.0.6/opencode-linux-x64.tar.gz \ - -o "$temp_dir/v2.tar.gz" - tar -xzf "$temp_dir/v1.tar.gz" -C "$temp_dir/v1" - tar -xzf "$temp_dir/v2.tar.gz" -C "$temp_dir/v2" - bun scripts/verify-opencode-hosts.ts "$temp_dir/v1/opencode" "$temp_dir/v2/opencode" diff --git a/AGENTS.md b/AGENTS.md index 96202d4..805bb86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,8 +111,9 @@ Droid snapshots hooks at session startup. Restart it or review/reload through `/hooks` after changes. `hooksDisabled` or enterprise `allowManagedHooksOnly` can prevent project or user thts hooks from running. -OpenCode supports v1.18.29+ and v2 (host verification pins v2.0.6). Its single -dependency-free plugin exports `id`, `server`, and `setup`. V2 policy follows +OpenCode targets v2, retaining best-effort legacy compatibility with v1.18.29+ +through its existing API adapter. Its single dependency-free plugin exports +`id`, `server`, and `setup`. V2 policy follows `session.location.directory`, including shared servers; do not append `subpath`. Keep settings user-owned. Only an exact, manifest-owned legacy settings template may be removed during migration. Customized files must survive refresh/uninit. @@ -187,9 +188,12 @@ mise run test # Go and OpenCode plugin unit t go test ./... # Go unit tests only go test -tags=integration ./internal/cmd/... # Integration tests go test -tags=integration ./... # All tests -bun scripts/verify-opencode-hosts.ts /path/to/v1.18.29 /path/to/v2.0.6 # Real hosts ``` +OpenCode adapter behavior is covered by unit tests; installation lifecycle is +covered by `scripts/verify-opencode-integration.sh`. Smoke-test v2 manually when +changing the integration or upgrading across a significant API change. + **Coverage targets:** config/git >70%, fs/thts >60% (all met) #### Verification Scripts diff --git a/README.md b/README.md index 3b9b999..eb30f9c 100644 --- a/README.md +++ b/README.md @@ -138,8 +138,8 @@ thts sync -m "Added architecture notes" CLI, Pi, and Droid CLI to give them awareness of your thoughts directory. -OpenCode integration supports v1.18.29+ and v2 (verified with v2.0.6), using the -same plugin for project and global installs. See the +OpenCode integration targets v2, retaining best-effort legacy compatibility with +v1.18.29+. The same plugin handles project and global installs. See the [OpenCode upgrade instructions](docs/guide.md#upgrading-opencode-integration). ```bash diff --git a/docs/contributing-agents.md b/docs/contributing-agents.md index f6d269d..1e5c026 100644 --- a/docs/contributing-agents.md +++ b/docs/contributing-agents.md @@ -247,24 +247,6 @@ Pi is the reference configuration for an extension-only agent: it uses and no thts-managed settings. Do not add an empty settings template merely to make `--with-settings` create `settings.json`. -OpenCode installs one self-contained TypeScript plugin with a default export -containing `id`, `server` (v1.18.29+), and `setup` (v2). Keep SDK imports type-only; -the installer does not manage npm dependencies. V2 registers `context` and -`compaction` hooks and resolves the session directory before loading policy. -OpenCode settings are user-owned, like Pi and Droid settings. - -Run `mise run test-opencode` for behavior tests and -`scripts/verify-opencode-integration.sh` for installer lifecycle checks. To test -real loaders and outgoing model requests without provider credentials: - -```bash -bun scripts/verify-opencode-hosts.ts /path/to/opencode-v1.18.29 /path/to/opencode-v2.0.6 -``` - -The host check uses temporary homes, a local mock model, and both overlapping -project/global resources and a second global-only project on each server. CI -downloads the pinned Linux binaries and runs this check. - Shell-hook agents normally merge an event map under the `hooks` key in settings. Set `HookConfigFile` when the harness instead uses a standalone event map, as Droid CLI does with `hooks.json`. Standalone hook configuration is diff --git a/docs/guide.md b/docs/guide.md index 626bc1e..c768c14 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -50,6 +50,7 @@ - [Global vs Project Configuration](#global-vs-project-configuration) - [Prerequisites](#prerequisites) - [Integration Levels](#integration-levels) + - [Upgrading OpenCode Integration](#upgrading-opencode-integration) - [Pi Project Trust](#pi-project-trust) - [Customizing Hook Keywords](#customizing-hook-keywords) - [Claude Plan Directive](#claude-plan-directive) @@ -794,9 +795,11 @@ your thoughts directory and enable session continuity. - **Codex "prompts"**: Codex calls commands "prompts". They are global-only and invoked as `/prompts:` (e.g., `/prompts:thts-handoff`). - **OpenCode XDG**: OpenCode uses XDG for global config (`~/.config/opencode/`) - rather than a dot-folder in home. The same plugin supports v1.18.29+ and v2, - verified with v1.18.29 and v2.0.6. OpenCode settings are user-owned, including - when `--with-settings` is used. + rather than a dot-folder in home. + - The plugin targets v2, retaining best-effort legacy compatibility with + v1.18.29+ through its existing API adapter. + - OpenCode settings are user-owned, including + when `--with-settings` is used. - **Gemini commands**: Gemini stores commands as TOML and does not support native sub-agents. - **Pi resources**: Pi uses skills, Markdown prompt templates, and TypeScript diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5d755cf..a3fa9e0 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4,7 +4,8 @@ ### OpenCode Plugin Not Loading -The dual-version plugin requires OpenCode v1.18.29+ or v2 (verified with v2.0.6). +OpenCode v2 is the actively supported version. Legacy v1.18.29+ compatibility is +best-effort; upgrade to v2 if you encounter a v1-specific issue. Refresh installed resources after upgrading thts: ```bash diff --git a/scripts/verify-opencode-hosts.ts b/scripts/verify-opencode-hosts.ts deleted file mode 100644 index 3751c02..0000000 --- a/scripts/verify-opencode-hosts.ts +++ /dev/null @@ -1,327 +0,0 @@ -// Actual host verification, with isolated homes and a local mock model (no credentials). -// Usage: bun scripts/verify-opencode-hosts.ts /path/to/v1.18.29 /path/to/v2.0.6 -import assert from "node:assert/strict"; -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; - -function createServerAddressParser() { - const decoder = new TextDecoder(); - let stdout = ""; - return (chunk: Uint8Array) => { - stdout += decoder.decode(chunk, { stream: true }); - return stdout.match(/http:\/\/127\.0\.0\.1:\d+(?=\s)/)?.[0] ?? ""; - }; -} - -// Exercise every chunk boundary, including inside the port and before its delimiter. -const expectedAddress = "http://127.0.0.1:43210"; -const startup = new TextEncoder().encode( - `Server listening on ${expectedAddress}\n`, -); -for (let split = 0; split < startup.length; split++) { - const parse = createServerAddressParser(); - assert.equal(parse(startup.subarray(0, split)), ""); - assert.equal(parse(startup.subarray(split)), expectedAddress); -} -const parseByte = createServerAddressParser(); -for (let index = 0; index < startup.length; index++) { - assert.equal( - parseByte(startup.subarray(index, index + 1)), - index === startup.length - 1 ? expectedAddress : "", - ); -} - -const binaries = process.argv.slice(2).map((path) => resolve(path)); -assert.equal(binaries.length, 2, "Provide v1.18.29 and v2.0.6 binary paths"); -const root = await mkdtemp(join(tmpdir(), "thts-opencode-hosts-")); -const thts = join(root, "thts"); -type ModelRequest = { messages: { role: string; content: unknown }[] }; -const requests: ModelRequest[] = []; -const model = Bun.serve({ - hostname: "127.0.0.1", - port: 0, - async fetch(request) { - requests.push(await request.json()); - const chunk = { - id: "fixture", - object: "chat.completion.chunk", - created: 1, - model: "test", - choices: [ - { - index: 0, - delta: { role: "assistant", content: "Verified." }, - finish_reason: null, - }, - ], - }; - const finish = { - ...chunk, - choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - usage: { prompt_tokens: 100, completion_tokens: 2, total_tokens: 102 }, - }; - return new Response( - `data: ${JSON.stringify(chunk)}\n\ndata: ${JSON.stringify(finish)}\n\ndata: [DONE]\n\n`, - { - headers: { "content-type": "text/event-stream" }, - }, - ); - }, -}); - -function run(command: string[], cwd: string, env = process.env) { - const result = Bun.spawnSync(command, { - cwd, - env, - stdout: "pipe", - stderr: "pipe", - }); - assert.equal( - result.exitCode, - 0, - `${command.join(" ")}: ${result.stderr.toString()}`, - ); - return result.stdout.toString(); -} - -async function until(check: () => Promise, label: string) { - for (let attempt = 0; attempt < 150; attempt++) { - if (await check()) return; - await Bun.sleep(200); - } - throw new Error(`Timed out: ${label}`); -} - -try { - run( - ["go", "build", "-o", thts, "./cmd/thts"], - resolve(import.meta.dir, ".."), - ); - for (const [index, binary] of binaries.entries()) { - const v2 = index === 1; - const version = v2 ? "2.0.6" : "1.18.29"; - assert.equal( - run([binary, "--version"], root).trim(), - v2 ? `opencode v${version}` : version, - ); - const base = join(root, version); - const bin = join(base, "bin"); - const home = join(base, "home"); - const projects = [join(base, "overlap"), join(base, "global-only")]; - for (const directory of [bin, home, ...projects]) - await mkdir(directory, { recursive: true }); - await writeFile( - join(bin, "thts"), - `#!${process.execPath} -if (process.argv.slice(2).join(" ") === "agent-instructions") { - console.log("# thts Integration Instructions\\nHOST-POLICY:" + process.cwd()); -} -`, - { mode: 0o755 }, - ); - // Do not inherit credentials, user config overrides, or external skills. - const env = { - HOME: home, - PATH: `${bin}:/usr/bin:/bin`, - XDG_CONFIG_HOME: join(base, "config"), - XDG_DATA_HOME: join(base, "data"), - XDG_CACHE_HOME: join(base, "cache"), - XDG_STATE_HOME: join(base, "state"), - THTS_CONFIG_PATH: join(base, "thts.yaml"), - OPENCODE_SERVER_PASSWORD: "fixture-only", - OPENCODE_DISABLE_DEFAULT_PLUGINS: "1", - OPENCODE_DISABLE_EXTERNAL_SKILLS: "1", - OPENCODE_DISABLE_UPDATE_CHECK: "1", - OPENCODE_DISABLE_MODELS_FETCH: "1", - DO_NOT_TRACK: "1", - CI: "true", - GIT_CONFIG_GLOBAL: "/dev/null", - GIT_CONFIG_NOSYSTEM: "1", - }; - const modelInfo = { - name: "Test", - limit: { context: 128000, output: 1000 }, - }; - const settings = { - baseURL: `http://127.0.0.1:${model.port}/v1`, - apiKey: "fixture", - }; - const config = v2 - ? { - model: "test/test", - providers: { - test: { - name: "Test", - package: "@opencode/ai/providers/openai-compatible", - settings, - models: { test: modelInfo }, - }, - }, - } - : { - model: "test/test", - small_model: "test/test", - provider: { - test: { - name: "Test", - npm: "@ai-sdk/openai-compatible", - options: settings, - models: { test: modelInfo }, - }, - }, - }; - for (const directory of projects) { - run(["git", "init", "-q"], directory, env); - await writeFile(join(directory, "opencode.json"), JSON.stringify(config)); - } - run([thts, "init", "agents", "--agents", "opencode"], projects[0], env); - run( - [thts, "init", "agents", "--agents", "opencode", "--global=all"], - projects[0], - env, - ); - - // Let the OS choose a port; parse only the address, never server log credentials. - const child = Bun.spawn( - [binary, "serve", "--hostname", "127.0.0.1", "--port", "0"], - { - cwd: projects[0], - env, - stdout: "pipe", - stderr: "pipe", - }, - ); - const stderr = new Response(child.stderr).text(); - let address = ""; - const output = (async () => { - const parse = createServerAddressParser(); - for await (const chunk of child.stdout) { - address ||= parse(chunk); - } - })(); - try { - await until(async () => Boolean(address), `${version} server startup`); - async function api(path: string, directory: string, body?: unknown) { - const response = await fetch(`${address}${path}`, { - method: body === undefined ? "GET" : "POST", - headers: { - "content-type": "application/json", - "x-opencode-directory": directory, - authorization: `Basic ${btoa("opencode:fixture-only")}`, - }, - body: body === undefined ? undefined : JSON.stringify(body), - signal: AbortSignal.timeout(30_000), - }); - const text = await response.text(); - assert.ok( - response.ok, - `${version} ${path}: ${response.status} ${text}`, - ); - const parsed = text ? JSON.parse(text) : undefined; - return v2 ? parsed?.data : parsed; - } - const prefix = v2 ? "/api" : ""; - for (const directory of projects) { - if (v2) { - await until( - async () => - (await api("/api/plugin", directory)).some( - (entry: { id: string; state: { status: string } }) => - entry.id === "thts.integration" && - entry.state.status === "active", - ), - "thts plugin activation", - ); - } - for (const [kind, names] of [ - ["agent", ["thoughts-locator", "thoughts-analyzer"]], - ["skill", ["thts-integrate"]], - ["command", ["thts-handoff", "thts-resume"]], - ] as const) { - const resources = await api(`${prefix}/${kind}`, directory); - for (const name of names) - assert.ok( - resources.some( - (entry: { id?: string; name: string }) => - entry.id === name || entry.name === name, - ), - `${version}: missing ${name}`, - ); - } - const session = await api(`${prefix}/session`, directory, { - title: "Verification", - model: { providerID: "test", id: "test" }, - ...(v2 ? { location: { directory } } : {}), - }); - for (const compact of [false, true]) { - const before = requests.length; - const operation = compact - ? v2 - ? "compact" - : "summarize" - : v2 - ? "prompt" - : "message"; - const body = compact - ? v2 - ? {} - : { providerID: "test", modelID: "test" } - : v2 - ? { text: "Say verified." } - : { - parts: [{ type: "text", text: "Say verified." }], - model: { providerID: "test", modelID: "test" }, - }; - await api( - `${prefix}/session/${session.id}/${operation}`, - directory, - body, - ); - if (v2) - await api( - `/api/experimental/session/${session.id}/wait`, - directory, - {}, - ); - assert.ok( - requests.length > before, - `${version}: ${operation} made no model request`, - ); - for (const request of requests.slice(before)) { - const system = JSON.stringify( - request.messages.filter( - (message) => - message.role === "system" || message.role === "developer", - ), - ); - assert.equal( - system.split("").length - 1, - 1, - `${version}: ${operation} policy count`, - ); - assert.ok( - system.includes(`HOST-POLICY:${directory}`), - `${version}: wrong project policy`, - ); - } - } - console.log( - `PASS: ${version} ${directory === projects[0] ? "global/project overlap" : "global-only second project"}: resources, prompt, compaction`, - ); - } - } finally { - child.kill(); - await child.exited; - await output; - const errors = await stderr; - assert.ok( - !errors.includes("failed to load plugin"), - `${version}: plugin load failure`, - ); - } - } -} finally { - model.stop(true); - await rm(root, { recursive: true, force: true }); -}