From 8dfee7cd50546673c9316e7c87c7c9f935bed854 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 23:04:06 -0700 Subject: [PATCH 1/4] chore(quest): claim quest/m0/kix/opencode.md Quest-Claim: 91eed9ed7411e89d From 539c90786e7ce443d107232c5c74504c219f0b34 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 23:46:51 -0700 Subject: [PATCH 2/4] feat(runner): drive opencode accounts through opencode serve A runner now offers opencode accounts alongside Claude Code ones, covering OpenCode Go, ChatGPT plans, and API-key providers. Each account signs in with `opencode auth login` in its own XDG home; each turn runs `opencode serve` on localhost behind a fresh password inside the job's devShell and drives one session through the SDK, with the kix tools as a remote MCP server and opencode's question tool denied. Limits abort the turn and pause the account. The flake pins opencode for the runner and checks the SDK matches it. Completes quest/m0/kix/opencode.md. Co-Authored-By: Claude Opus 5.5 --- README.md | 30 +-- bun.lock | 15 ++ flake.nix | 16 +- package.json | 1 + quest/m0/kix/README.md | 8 +- quest/m0/kix/opencode.md | 26 --- quest/m1/cloud/funding.md | 1 - src/cli/main.ts | 22 ++- src/runner/adapter.ts | 43 ++++- src/runner/claude.ts | 36 ++-- src/runner/cli.ts | 62 ++++-- src/runner/home.ts | 5 +- src/runner/opencode.ts | 385 ++++++++++++++++++++++++++++++++++++++ src/runner/runner.ts | 2 +- test/runner.test.ts | 294 ++++++++++++++++++++++++++++- 15 files changed, 845 insertions(+), 101 deletions(-) delete mode 100644 quest/m0/kix/opencode.md create mode 100644 src/runner/opencode.ts diff --git a/README.md b/README.md index 65f9531c..f00dd7f8 100644 --- a/README.md +++ b/README.md @@ -495,22 +495,28 @@ signals the agent and waits until its `start` or `resume` has reported. ## Run kix jobs on your machines `quest runner` turns a Linux or macOS machine with Nix into a -[kix.quest](https://kix.quest) runner, driving Claude Code with that machine's -own logins: +[kix.quest](https://kix.quest) runner, driving Claude Code or opencode with +that machine's own logins: ```sh quest runner login --name laptop # prints a link to approve while signed in to kix quest runner account add # Claude Code's own sign-in, in an isolated home quest runner account add --console --label work +quest runner account add opencode # opencode's sign-in: OpenCode Go, ChatGPT, or an API key +quest runner account add opencode --model openai/gpt-5.5 quest runner account list quest runner --slots 2 # runs jobs in the foreground until interrupted ``` `login` makes a token that never leaves the machine; the link carries only its hash, and kix connects the runner once you approve it. Each account signs in -through `claude auth login` in its own `CLAUDE_CONFIG_DIR`, so kix stores only -an opaque account id, its kind, and a label. kix.quest/runners shows each -runner's presence and sets the order jobs use your accounts in. +through its CLI's own flow in its own home: `claude auth login` in a +`CLAUDE_CONFIG_DIR`, or `opencode auth login` in a set of XDG directories, so +kix stores only an opaque account id, its kind, and a label. An opencode +account uses only the providers it signed in to, with `--model` or opencode's +default. Use Claude Code for Claude subscriptions, since opencode cannot sign +in to them. kix.quest/runners shows each runner's presence and sets the order +jobs use your accounts in. Every job runs in its own checkout inside the project's `nix develop` shell, with the flake's `nixConfig` honored; a project without a flake gets a bootstrap @@ -522,12 +528,14 @@ token. kix publishes as its GitHub App, so the runner never pushes. A job that h pauses for you to pick another account; the runner never switches on its own. On SIGINT or SIGTERM, running work is published and its jobs move to another runner. Until the job sandbox ships, runners take only trusted revisions, -agents run with `--permission-mode bypassPermissions`, and a job resumes with -another maintainer's answer only once you approve it in the inbox. - -The flake's `runner` package is a `quest` whose Claude Code is pinned by this -repository's lock (`nix run github:kixelated/quest#runner -- runner`); -otherwise `claude` comes from `QUEST_RUNNER_CLAUDE` or `PATH`. State lives in `$QUEST_RUNNER_HOME`, by default +agents run with every tool allowed (Claude Code's `bypassPermissions`), and a +job resumes with another maintainer's answer only once you approve it in the +inbox. opencode runs as `opencode serve` on localhost behind a password made for each turn. + +The flake's `runner` package is a `quest` whose Claude Code and opencode are +pinned by this repository's lock (`nix run github:kixelated/quest#runner -- runner`); +otherwise `claude` and `opencode` come from `QUEST_RUNNER_CLAUDE` and +`QUEST_RUNNER_OPENCODE`, or `PATH`. State lives in `$QUEST_RUNNER_HOME`, by default `$XDG_DATA_HOME/quest/runner`. To keep a runner up, run `quest runner` as a user service, such as a systemd user unit with `Restart=on-failure`. diff --git a/bun.lock b/bun.lock index ba2088a6..e822aaf7 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "quest", "dependencies": { + "@opencode-ai/sdk": "1.18.34", "commander": "15.0.0", "mdast-util-from-markdown": "2.1.0", "mdast-util-gfm-strikethrough": "2.0.1", @@ -201,6 +202,8 @@ "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.34", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-R9QWUpcngtdQ6FCixz7NC6n1WsSho6CEuRUzYHz/CzZcp3miSklDzXkm0oDwXVCnuEu+ANoeM0f6RDp51kOMzQ=="], + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], "@oxc-project/types": ["@oxc-project/types@0.152.0", "", {}, "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw=="], @@ -359,6 +362,8 @@ "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], @@ -387,6 +392,8 @@ "hono": ["hono@4.13.13", "", {}, "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], @@ -495,6 +502,8 @@ "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -527,6 +536,10 @@ "sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" } }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], @@ -563,6 +576,8 @@ "vitest": ["vitest@5.0.3", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.3", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.2.3", "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", "tinybench": "^6.1.4", "tinyexec": "^1.3.0", "tinyglobby": "^0.2.17", "why-is-node-running": "3.2.1" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.3", "@vitest/browser-preview": "5.0.3", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.3", "@vitest/coverage-v8": "5.0.3", "@vitest/ui": "5.0.3", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": "vitest.mjs" }, "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "why-is-node-running": ["why-is-node-running@3.2.1", "", { "bin": "cli.js" }, "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ=="], "workerd": ["workerd@1.20261012.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20261012.1", "@cloudflare/workerd-darwin-arm64": "1.20261012.1", "@cloudflare/workerd-linux-64": "1.20261012.1", "@cloudflare/workerd-linux-arm64": "1.20261012.1", "@cloudflare/workerd-windows-64": "1.20261012.1" }, "bin": "bin/workerd" }, "sha512-tEHoMPsMga0seVjxfsR8Pd3f8IRqYwkq++dTUKI5Gdgj6zuSDluYwisydq+dn90SsIACwBquJUQ1nBpcOqaWTA=="], diff --git a/flake.nix b/flake.nix index ba92332a..f9353698 100644 --- a/flake.nix +++ b/flake.nix @@ -35,12 +35,17 @@ paths = [ quest ]; nativeBuildInputs = [ pkgs.makeWrapper ]; postBuild = '' - wrapProgram $out/bin/quest --set-default QUEST_RUNNER_CLAUDE ${pkgs.lib.getExe pkgs.claude-code} + wrapProgram $out/bin/quest \ + --set-default QUEST_RUNNER_CLAUDE ${pkgs.lib.getExe pkgs.claude-code} \ + --set-default QUEST_RUNNER_OPENCODE ${pkgs.lib.getExe pkgs.opencode} ''; meta = quest.meta; }; + manifest = pkgs.lib.importJSON ./package.json; # package.json's packageManager pins Bun for CI runners without Nix. - bunVersion = pkgs.lib.removePrefix "bun@" (pkgs.lib.importJSON ./package.json).packageManager; + bunVersion = pkgs.lib.removePrefix "bun@" manifest.packageManager; + # The runner drives the pinned opencode through the SDK of the same release. + opencodeSdk = manifest.dependencies."@opencode-ai/sdk"; in { packages = { @@ -73,6 +78,13 @@ fi touch "$out" ''; + opencode-sdk = pkgs.runCommand "opencode-sdk" { } '' + if [ "${pkgs.opencode.version}" != "${opencodeSdk}" ]; then + echo "package.json pins @opencode-ai/sdk ${opencodeSdk}, but the runner pins opencode ${pkgs.opencode.version}" >&2 + exit 1 + fi + touch "$out" + ''; quest-tree = pkgs.runCommand "quest-tree" { nativeBuildInputs = [ quest ]; } '' quest --root ${self} check touch "$out" diff --git a/package.json b/package.json index 2d882f1c..fd0febc9 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "fix": "prettier --write ." }, "dependencies": { + "@opencode-ai/sdk": "1.18.34", "commander": "15.0.0", "mdast-util-from-markdown": "2.1.0", "mdast-util-gfm-strikethrough": "2.0.1", diff --git a/quest/m0/kix/README.md b/quest/m0/kix/README.md index 8da0e5e8..f8128571 100644 --- a/quest/m0/kix/README.md +++ b/quest/m0/kix/README.md @@ -107,6 +107,13 @@ fixes what it finds: - A rejected `rate_limit_event` and its `resetsAt` arrive when a plan runs out, so the account sits out until its reset instead of the one-hour fallback. +- opencode accounts (shipped with stubbed servers and a free model) sign in + to an OpenCode Go plan and a ChatGPT plan, the latter through its headless + method on a remote runner, and run a job on opencode's default model or + `--model`. Their limits pause the account until the reset: OpenCode Go's + `account_rate_limit` retry, and ChatGPT's `usage_limit_reached` error with + `resets_at`. If ChatGPT plans fall short through opencode, plan the Codex + adapter the 2026-10-15 decision allows. ## Required @@ -115,7 +122,6 @@ fixes what it finds: - [Operator setup](/quest/m0/kix/operator-setup.md) - fork namespace, pilot installations, and a verified test PR - [Per-project forks](/quest/m0/kix/forks.md) - each project publishes through its own fork, recorded by repository ID - [Job sandbox](/quest/m0/kix/sandbox.md) - OS-sandboxed commands, with untrusted PR devShells built in a VM on Linux runners -- [opencode adapter](/quest/m0/kix/opencode.md) - OpenCode Go, ChatGPT plans, and API-key providers through opencode - [Merge jobs](/quest/m0/kix/merge-job.md) - a merge pick fixes conflicts, CI, and reviews on a runner, then lands the PR - [Agent activity](/quest/m0/kix/activity.md) - agent cards and map highlights built from checkpoint summaries - [Web planning](/quest/m0/kix/planning.md) - issue-to-quest interviews produce draft planning PRs diff --git a/quest/m0/kix/opencode.md b/quest/m0/kix/opencode.md deleted file mode 100644 index 6880b2a8..00000000 --- a/quest/m0/kix/opencode.md +++ /dev/null @@ -1,26 +0,0 @@ -# [M] Drive opencode from the runner - -## Goal - -A runner offers opencode accounts alongside Claude Code ones, so a -maintainer can run jobs with an OpenCode Go plan, a ChatGPT plan, or an API -key for any provider opencode supports. Jobs behave the same as Claude Code -jobs in the UI: questions, checkpoint summaries, and results. - -## Plan - -- Decided 2026-10-15: Claude Code and opencode are the two m0 adapters. - opencode covers OpenCode Go, ChatGPT/Codex plans, and API-key providers. Add - a separate Codex adapter only if opencode's ChatGPT support falls short. -- Drive the headless `opencode serve` HTTP/OpenAPI server through its - TypeScript SDK and Server-Sent Events, bound to localhost with server - authentication. Pin opencode through the runner's Nix closure. -- Give each account its own isolated opencode config/data home. opencode - dropped Claude subscription login in March 2026, so Claude subscriptions - stay on the Claude Code adapter. -- Verify that structured questions, the checkpoint status tool, resume, and - cancellation map onto the shared adapter interface without - provider-specific behavior leaking into the service. -- Sources: [OpenCode Go](https://opencode.ai/docs/go/), - [server](https://opencode.ai/docs/server/), and - [Sign in with ChatGPT](https://help.openai.com/articles/20001542). diff --git a/quest/m1/cloud/funding.md b/quest/m1/cloud/funding.md index 19941b42..08c029e0 100644 --- a/quest/m1/cloud/funding.md +++ b/quest/m1/cloud/funding.md @@ -54,7 +54,6 @@ cloud runners; donor allocations follow working cloud runners separately. ## Required - [Job sandbox](/quest/m0/kix/sandbox.md) - the boundary keeping provider credentials outside job commands -- [opencode adapter](/quest/m0/kix/opencode.md) - the supported API-provider adapter ## Related diff --git a/src/cli/main.ts b/src/cli/main.ts index 913501db..d5a97bbb 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -3,7 +3,7 @@ import { realpathSync } from "node:fs"; import { relative, resolve, sep } from "node:path"; -import { Command, CommanderError, InvalidArgumentError, Option } from "commander"; +import { Argument, Command, CommanderError, InvalidArgumentError, Option } from "commander"; import { description, version } from "../../package.json"; import { @@ -28,6 +28,7 @@ import { removeAccount, runRunner, } from "../runner/cli"; +import type { AdapterName } from "../runner/adapter"; import { held, reach, target, upstream, upstreamHead } from "./claims"; import { gate } from "./gate"; import { gitHubDependencies, GitHubInputError, mergedHead, pullRequest, remoteUrl } from "./github"; @@ -705,7 +706,7 @@ export function main(argv: string[], out: Output): number | Promise { "Turn this Linux or macOS machine into a kix runner. `quest runner login` connects it to your kix " + "account, `quest runner account add` signs agent accounts in with their own CLIs, and `quest runner` " + "runs routed jobs in the foreground until interrupted.\n\n" + - "Each account signs in through Claude Code's own flow in an isolated home (CLAUDE_CONFIG_DIR); kix " + + "Each account signs in through its CLI's own flow, Claude Code or opencode, in an isolated home; kix " + "stores only an opaque account id, its kind, and its label, and never sees its tokens. Every job runs " + "inside its project's `nix develop` shell. State lives in $QUEST_RUNNER_HOME, by default " + "$XDG_DATA_HOME/quest/runner.", @@ -730,11 +731,18 @@ export function main(argv: string[], out: Output): number | Promise { const account = runner.command("account").description("Add, list, and remove this runner's agent accounts"); account .command("add") - .description("Sign a Claude Code account in with `claude auth login` in a new isolated home") - .option("--console", "Sign in with an Anthropic Console account (API billing) instead of a subscription") - .option("--label