From 21cea054efdbd6150c7dde80db4d508d624a3c27 Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Sat, 25 Jul 2026 19:33:23 -0300 Subject: [PATCH 1/8] feat: add OpenCode Navigator orchestration - add native session and managed worktree tools - enforce project scope, limits, and lifecycle safety - document configuration and cover runtime behavior --- README.md | 28 +- bun.lock | 23 +- kompass.jsonc | 15 + kompass.schema.json | 45 +- packages/core/index.ts | 2 + packages/core/kompass.jsonc | 15 + packages/core/lib/config.ts | 93 ++- packages/core/test/config.test.ts | 35 + packages/opencode/.opencode/kompass.jsonc | 9 +- packages/opencode/README.md | 28 +- packages/opencode/index.ts | 43 +- packages/opencode/kompass.jsonc | 15 + packages/opencode/navigator.ts | 651 ++++++++++++++++++ packages/opencode/package.json | 7 +- packages/opencode/scripts/build.ts | 6 +- packages/opencode/scripts/compile.ts | 2 +- packages/opencode/test/navigator.test.ts | 368 ++++++++++ .../opencode/test/tool-registration.test.ts | 46 ++ .../content/docs/docs/adapters/opencode.mdx | 30 + .../src/content/docs/docs/config/overview.mdx | 1 + .../src/content/docs/docs/config/schema.mdx | 9 + .../docs/docs/reference/tools/index.mdx | 44 ++ .../docs/docs/reference/tools/navigator.mdx | 28 + packages/web/src/pages/index.astro | 2 +- 24 files changed, 1519 insertions(+), 26 deletions(-) create mode 100644 packages/opencode/navigator.ts create mode 100644 packages/opencode/test/navigator.test.ts create mode 100644 packages/web/src/content/docs/docs/reference/tools/navigator.mdx diff --git a/README.md b/README.md index 57782ef..adb8e41 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Kompass keeps AI coding agents on course with token-efficient, composable workfl - Commands cover direct work (`/ask`, `/commit`, `/merge`, `/skill/create`, `/skill/optimize`), orchestration (`/dev`, `/ship`, `/todo`, `/pr/fix/loop`), ticket planning/sync, and PR review/shipping flows. `/branch/inline`, `/commit/inline`, `/commit-and-push/inline`, `/pr/create/inline`, and `/ship/inline` reuse the invoking session instead of starting a subtask. - Agents are intentionally narrow: `worker` handles implementation and multi-step workflows, `planner` is no-edit planning, and `reviewer` is a no-edit review specialist. -- Structured tools keep workflows grounded in repo and GitHub state: `changes_load`, `pr_load`, `pr_load_review`, `pr_sync`, `ticket_load`, `ticket_sync`. +- Structured tools keep workflows grounded in repo and GitHub state: `changes_load`, `pr_load`, `pr_load_review`, `pr_sync`, `ticket_load`, `ticket_sync`. OpenCode users can opt into the eight Navigator session and worktree tools. - Reusable command-template components live in `packages/core/components/` and are documented in the components reference. ## Prerequisites @@ -52,6 +52,32 @@ Kompass loads the bundled base config, then optional home-directory overrides, t The recommended project override path is `.opencode/kompass.jsonc`. +## Kompass Navigator + +Navigator is an opt-in OpenCode capability for orchestrating native sessions in the current checkout and OpenCode-managed Git worktrees. It uses the OpenCode server and SDK directly, returns immediately after admitting prompts, supports parallel sessions, and discovers native sessions and worktrees again after plugin restarts. It requires OpenCode `1.17.12` or newer. + +Enable all eight Navigator tools with: + +```jsonc +{ + "adapters": { + "opencode": { + "navigator": { + "enabled": true, + "maxConcurrentSessions": 8, + "maxReadChars": 20000, + "maxOutputCharsPerItem": 4000, + "maxWaitMs": 120000 + } + } + } +} +``` + +The default runtime names are `kompass_worktree_list`, `kompass_session_create`, `kompass_session_list`, `kompass_session_read`, `kompass_session_send`, `kompass_session_wait`, `kompass_session_interrupt`, and `kompass_worktree_remove`. Disable or alias any logical name through `tools`; an alias is registered exactly as configured. + +Navigator accepts only sessions from the current OpenCode project and only worktrees returned by OpenCode. It rejects self-targeting lifecycle calls, arbitrary directories, main-checkout removal, unmanaged worktrees, and removal while a worktree has active sessions. It never force-removes or automatically cleans up resources after a partial failure. + ## Workspace This repository is the Kompass development workspace. diff --git a/bun.lock b/bun.lock index ca6898b..7365dbe 100644 --- a/bun.lock +++ b/bun.lock @@ -18,14 +18,15 @@ }, "packages/opencode": { "name": "@kompassdev/opencode", - "version": "0.13.2", + "version": "0.16.1", "devDependencies": { - "@opencode-ai/plugin": "^1.16.2", - "@opencode-ai/sdk": "^1.16.2", + "@opencode-ai/plugin": "^1.17.12", + "@opencode-ai/sdk": "^1.17.12", "yaml": "^2.8.2", }, "peerDependencies": { - "@opencode-ai/plugin": "^1.16.2", + "@opencode-ai/plugin": "^1.17.12", + "@opencode-ai/sdk": "^1.17.12", }, }, "packages/web": { @@ -42,6 +43,8 @@ }, }, "packages": { + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + "@astrojs/check": ["@astrojs/check@0.9.8", "", { "dependencies": { "@astrojs/language-server": "^2.16.5", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-LDng8446QLS5ToKjRHd3bgUdirvemVVExV7nRyJfW2wV36xuv7vDxwy5NWN9zqeSEDgg0Tv84sP+T3yEq+Zlkw=="], "@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], @@ -226,9 +229,9 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.16.2", "", { "dependencies": { "@opencode-ai/sdk": "1.16.2", "effect": "4.0.0-beta.74", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.3.2", "@opentui/keymap": ">=0.3.2", "@opentui/solid": ">=0.3.2" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-FaZhVXrbz93xsdGLCtarRDTeqFt8AkLfh8B34tFBj6G4HXVmKSgBwVXmtELKKC+08xMtawBC9hshiMbXryv6cg=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.5", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.5", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-o1loQw5lh3zK7dgTN25Zh4tK+bW7BdszyDwdSumj0ahaR1lXWjYjVbAZuOQxeEQfkr266cqNi2x8UrrlkI0n7A=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.16.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.5", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-7KgMvP5/1oxbhHj6kYBtPSTEdFKYpUeEYOzBTKdzSaRpapUpFFdn6Hkus3rr0rljO0kukWZIgRd3DrVBwTULGA=="], "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], @@ -490,7 +493,7 @@ "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], - "effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], + "effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], "emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="], @@ -640,6 +643,8 @@ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], @@ -1068,7 +1073,7 @@ "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "yaml-language-server": ["yaml-language-server@1.20.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.7.1" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA=="], @@ -1110,8 +1115,6 @@ "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "effect/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], diff --git a/kompass.jsonc b/kompass.jsonc index 6260572..42d4a91 100644 --- a/kompass.jsonc +++ b/kompass.jsonc @@ -53,6 +53,14 @@ "pr_sync": { "enabled": true }, "ticket_load": { "enabled": true }, "ticket_sync": { "enabled": true }, + "worktree_list": { "enabled": true }, + "session_create": { "enabled": true }, + "session_list": { "enabled": true }, + "session_read": { "enabled": true }, + "session_send": { "enabled": true }, + "session_wait": { "enabled": true }, + "session_interrupt": { "enabled": true }, + "worktree_remove": { "enabled": true }, }, "components": { @@ -78,6 +86,13 @@ "adapters": { "opencode": { "agentMode": "all", + "navigator": { + "enabled": false, + "maxConcurrentSessions": 8, + "maxReadChars": 20000, + "maxOutputCharsPerItem": 4000, + "maxWaitMs": 120000, + }, }, }, } diff --git a/kompass.schema.json b/kompass.schema.json index 71d60cc..a3b1478 100644 --- a/kompass.schema.json +++ b/kompass.schema.json @@ -235,6 +235,30 @@ }, "ticket_load": { "$ref": "#/$defs/toolConfig" + }, + "worktree_list": { + "$ref": "#/$defs/toolConfig" + }, + "session_create": { + "$ref": "#/$defs/toolConfig" + }, + "session_list": { + "$ref": "#/$defs/toolConfig" + }, + "session_read": { + "$ref": "#/$defs/toolConfig" + }, + "session_send": { + "$ref": "#/$defs/toolConfig" + }, + "session_wait": { + "$ref": "#/$defs/toolConfig" + }, + "session_interrupt": { + "$ref": "#/$defs/toolConfig" + }, + "worktree_remove": { + "$ref": "#/$defs/toolConfig" } } }, @@ -327,6 +351,17 @@ "agentMode": { "type": "string", "enum": ["subagent", "primary", "all"] + }, + "navigator": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean", "default": false }, + "maxConcurrentSessions": { "type": "integer", "minimum": 1, "maximum": 8, "default": 8 }, + "maxReadChars": { "type": "integer", "minimum": 1, "default": 20000 }, + "maxOutputCharsPerItem": { "type": "integer", "minimum": 1, "default": 4000 }, + "maxWaitMs": { "type": "integer", "minimum": 1, "default": 120000 } + } } } } @@ -432,7 +467,15 @@ "pr_load_review", "pr_sync", "ticket_sync", - "ticket_load" + "ticket_load", + "worktree_list", + "session_create", + "session_list", + "session_read", + "session_send", + "session_wait", + "session_interrupt", + "worktree_remove" ] } } diff --git a/packages/core/index.ts b/packages/core/index.ts index fdda70c..5ae9986 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -8,6 +8,7 @@ export { getConfiguredToolNames, getConfiguredToolName, getEnabledToolNames, + NAVIGATOR_TOOL_NAMES, loadKompassConfig, mergeWithDefaults, } from "./lib/config.ts"; @@ -17,6 +18,7 @@ export type { CommandName, KompassConfig, MergedKompassConfig, + NavigatorConfig, ToolName, ToolConfig, } from "./lib/config.ts"; diff --git a/packages/core/kompass.jsonc b/packages/core/kompass.jsonc index 6260572..42d4a91 100644 --- a/packages/core/kompass.jsonc +++ b/packages/core/kompass.jsonc @@ -53,6 +53,14 @@ "pr_sync": { "enabled": true }, "ticket_load": { "enabled": true }, "ticket_sync": { "enabled": true }, + "worktree_list": { "enabled": true }, + "session_create": { "enabled": true }, + "session_list": { "enabled": true }, + "session_read": { "enabled": true }, + "session_send": { "enabled": true }, + "session_wait": { "enabled": true }, + "session_interrupt": { "enabled": true }, + "worktree_remove": { "enabled": true }, }, "components": { @@ -78,6 +86,13 @@ "adapters": { "opencode": { "agentMode": "all", + "navigator": { + "enabled": false, + "maxConcurrentSessions": 8, + "maxReadChars": 20000, + "maxOutputCharsPerItem": 4000, + "maxWaitMs": 120000, + }, }, }, } diff --git a/packages/core/lib/config.ts b/packages/core/lib/config.ts index 97ec9f3..157fdd9 100644 --- a/packages/core/lib/config.ts +++ b/packages/core/lib/config.ts @@ -20,6 +20,25 @@ export const DEFAULT_TOOL_NAMES = [ "pr_sync", "ticket_sync", "ticket_load", + "worktree_list", + "session_create", + "session_list", + "session_read", + "session_send", + "session_wait", + "session_interrupt", + "worktree_remove", +] as const; + +export const NAVIGATOR_TOOL_NAMES = [ + "worktree_list", + "session_create", + "session_list", + "session_read", + "session_send", + "session_wait", + "session_interrupt", + "worktree_remove", ] as const; export const DEFAULT_COMMAND_NAMES = [ @@ -145,6 +164,14 @@ export interface KompassConfig { pr_sync?: ToolConfig; ticket_sync?: ToolConfig; ticket_load?: ToolConfig; + worktree_list?: ToolConfig; + session_create?: ToolConfig; + session_list?: ToolConfig; + session_read?: ToolConfig; + session_send?: ToolConfig; + session_wait?: ToolConfig; + session_interrupt?: ToolConfig; + worktree_remove?: ToolConfig; }; components?: { "change-summary"?: ComponentConfig; @@ -171,6 +198,7 @@ export interface KompassConfig { adapters?: { opencode?: { agentMode?: "subagent" | "primary" | "all"; + navigator?: Partial; }; }; } @@ -198,6 +226,14 @@ export interface MergedKompassConfig { pr_sync: ToolConfig; ticket_sync: ToolConfig; ticket_load: ToolConfig; + worktree_list: ToolConfig; + session_create: ToolConfig; + session_list: ToolConfig; + session_read: ToolConfig; + session_send: ToolConfig; + session_wait: ToolConfig; + session_interrupt: ToolConfig; + worktree_remove: ToolConfig; }; components: { enabled: string[]; @@ -209,10 +245,19 @@ export interface MergedKompassConfig { adapters: { opencode: { agentMode: "subagent" | "primary" | "all"; + navigator: NavigatorConfig; }; }; } +export interface NavigatorConfig { + enabled: boolean; + maxConcurrentSessions: number; + maxReadChars: number; + maxOutputCharsPerItem: number; + maxWaitMs: number; +} + const BUNDLED_CONFIG_CANDIDATES = [path.resolve(__dirname, "..", "kompass.jsonc")] as const; const PROJECT_CONFIG_FILES = [ @@ -481,6 +526,14 @@ const defaultToolConfig: Record = { pr_sync: { enabled: true }, ticket_sync: { enabled: true }, ticket_load: { enabled: true }, + worktree_list: { enabled: true }, + session_create: { enabled: true }, + session_list: { enabled: true }, + session_read: { enabled: true }, + session_send: { enabled: true }, + session_wait: { enabled: true }, + session_interrupt: { enabled: true }, + worktree_remove: { enabled: true }, }; function getToggleEntry( @@ -535,8 +588,14 @@ function getComponentPath( return entry?.path ?? config?.components?.paths?.[name]; } -export function getEnabledToolNames(tools: MergedKompassConfig["tools"]): ToolName[] { - return DEFAULT_TOOL_NAMES.filter((toolName) => tools[toolName].enabled !== false); +export function getEnabledToolNames( + tools: MergedKompassConfig["tools"], + navigatorEnabled = false, +): ToolName[] { + const navigatorTools = new Set(NAVIGATOR_TOOL_NAMES); + return DEFAULT_TOOL_NAMES.filter((toolName) => + tools[toolName].enabled !== false && (navigatorEnabled || !navigatorTools.has(toolName)) + ); } export function getConfiguredToolName( @@ -582,6 +641,27 @@ export function mergeWithDefaults( const { enabled: _workerEnabled, ...workerOverrides } = config?.agents?.worker ?? {}; const { enabled: _reviewerEnabled, ...reviewerOverrides } = config?.agents?.reviewer ?? {}; const { enabled: _plannerEnabled, ...plannerOverrides } = config?.agents?.planner ?? {}; + const navigator = { + enabled: config?.adapters?.opencode?.navigator?.enabled ?? false, + maxConcurrentSessions: + config?.adapters?.opencode?.navigator?.maxConcurrentSessions ?? 8, + maxReadChars: config?.adapters?.opencode?.navigator?.maxReadChars ?? 20_000, + maxOutputCharsPerItem: + config?.adapters?.opencode?.navigator?.maxOutputCharsPerItem ?? 4_000, + maxWaitMs: config?.adapters?.opencode?.navigator?.maxWaitMs ?? 120_000, + }; + + for (const [name, value] of Object.entries(navigator).filter(([name]) => name !== "enabled") as Array<[string, number]>) { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`adapters.opencode.navigator.${name} must be a positive integer`); + } + } + if (navigator.maxConcurrentSessions > 8) { + throw new Error("adapters.opencode.navigator.maxConcurrentSessions cannot exceed 8"); + } + if (navigator.maxOutputCharsPerItem > navigator.maxReadChars) { + throw new Error("adapters.opencode.navigator.maxOutputCharsPerItem cannot exceed maxReadChars"); + } return { shared: { @@ -629,6 +709,14 @@ export function mergeWithDefaults( pr_sync: { ...defaultToolConfig.pr_sync, ...config?.tools?.pr_sync }, ticket_sync: { ...defaultToolConfig.ticket_sync, ...config?.tools?.ticket_sync }, ticket_load: { ...defaultToolConfig.ticket_load, ...config?.tools?.ticket_load }, + worktree_list: { ...defaultToolConfig.worktree_list, ...config?.tools?.worktree_list }, + session_create: { ...defaultToolConfig.session_create, ...config?.tools?.session_create }, + session_list: { ...defaultToolConfig.session_list, ...config?.tools?.session_list }, + session_read: { ...defaultToolConfig.session_read, ...config?.tools?.session_read }, + session_send: { ...defaultToolConfig.session_send, ...config?.tools?.session_send }, + session_wait: { ...defaultToolConfig.session_wait, ...config?.tools?.session_wait }, + session_interrupt: { ...defaultToolConfig.session_interrupt, ...config?.tools?.session_interrupt }, + worktree_remove: { ...defaultToolConfig.worktree_remove, ...config?.tools?.worktree_remove }, }, components: { enabled: getEnabledNames( @@ -653,6 +741,7 @@ export function mergeWithDefaults( config?.adapters?.opencode?.agentMode ?? config?.defaults?.agentMode ?? "all", + navigator, }, }, }; diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index 24c5459..0417c47 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -229,6 +229,41 @@ describe("config loading", () => { }); describe("object-based config", () => { + test("merges Navigator defaults and validates limits", () => { + const defaults = mergeWithDefaults(null); + assert.deepEqual(defaults.adapters.opencode.navigator, { + enabled: false, + maxConcurrentSessions: 8, + maxReadChars: 20_000, + maxOutputCharsPerItem: 4_000, + maxWaitMs: 120_000, + }); + + const configured = mergeWithDefaults({ adapters: { opencode: { navigator: { + enabled: true, + maxConcurrentSessions: 3, + maxReadChars: 5_000, + } } } }); + assert.equal(configured.adapters.opencode.navigator.enabled, true); + assert.equal(configured.adapters.opencode.navigator.maxConcurrentSessions, 3); + assert.equal(configured.adapters.opencode.navigator.maxReadChars, 5_000); + assert.throws( + () => mergeWithDefaults({ adapters: { opencode: { navigator: { maxConcurrentSessions: 9 } } } }), + /cannot exceed 8/, + ); + assert.throws( + () => mergeWithDefaults({ adapters: { opencode: { navigator: { maxWaitMs: 0 } } } }), + /positive integer/, + ); + assert.throws( + () => mergeWithDefaults({ adapters: { opencode: { navigator: { + maxReadChars: 100, + maxOutputCharsPerItem: 101, + } } } }), + /cannot exceed maxReadChars/, + ); + }); + test("supports command, agent, and component entry toggles", () => { const config = mergeWithDefaults({ shared: { diff --git a/packages/opencode/.opencode/kompass.jsonc b/packages/opencode/.opencode/kompass.jsonc index fcd25f6..1c1c65f 100644 --- a/packages/opencode/.opencode/kompass.jsonc +++ b/packages/opencode/.opencode/kompass.jsonc @@ -125,7 +125,14 @@ }, "adapters": { "opencode": { - "agentMode": "all" + "agentMode": "all", + "navigator": { + "enabled": false, + "maxConcurrentSessions": 8, + "maxReadChars": 20000, + "maxOutputCharsPerItem": 4000, + "maxWaitMs": 120000 + } } } } diff --git a/packages/opencode/README.md b/packages/opencode/README.md index 57782ef..adb8e41 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -20,7 +20,7 @@ Kompass keeps AI coding agents on course with token-efficient, composable workfl - Commands cover direct work (`/ask`, `/commit`, `/merge`, `/skill/create`, `/skill/optimize`), orchestration (`/dev`, `/ship`, `/todo`, `/pr/fix/loop`), ticket planning/sync, and PR review/shipping flows. `/branch/inline`, `/commit/inline`, `/commit-and-push/inline`, `/pr/create/inline`, and `/ship/inline` reuse the invoking session instead of starting a subtask. - Agents are intentionally narrow: `worker` handles implementation and multi-step workflows, `planner` is no-edit planning, and `reviewer` is a no-edit review specialist. -- Structured tools keep workflows grounded in repo and GitHub state: `changes_load`, `pr_load`, `pr_load_review`, `pr_sync`, `ticket_load`, `ticket_sync`. +- Structured tools keep workflows grounded in repo and GitHub state: `changes_load`, `pr_load`, `pr_load_review`, `pr_sync`, `ticket_load`, `ticket_sync`. OpenCode users can opt into the eight Navigator session and worktree tools. - Reusable command-template components live in `packages/core/components/` and are documented in the components reference. ## Prerequisites @@ -52,6 +52,32 @@ Kompass loads the bundled base config, then optional home-directory overrides, t The recommended project override path is `.opencode/kompass.jsonc`. +## Kompass Navigator + +Navigator is an opt-in OpenCode capability for orchestrating native sessions in the current checkout and OpenCode-managed Git worktrees. It uses the OpenCode server and SDK directly, returns immediately after admitting prompts, supports parallel sessions, and discovers native sessions and worktrees again after plugin restarts. It requires OpenCode `1.17.12` or newer. + +Enable all eight Navigator tools with: + +```jsonc +{ + "adapters": { + "opencode": { + "navigator": { + "enabled": true, + "maxConcurrentSessions": 8, + "maxReadChars": 20000, + "maxOutputCharsPerItem": 4000, + "maxWaitMs": 120000 + } + } + } +} +``` + +The default runtime names are `kompass_worktree_list`, `kompass_session_create`, `kompass_session_list`, `kompass_session_read`, `kompass_session_send`, `kompass_session_wait`, `kompass_session_interrupt`, and `kompass_worktree_remove`. Disable or alias any logical name through `tools`; an alias is registered exactly as configured. + +Navigator accepts only sessions from the current OpenCode project and only worktrees returned by OpenCode. It rejects self-targeting lifecycle calls, arbitrary directories, main-checkout removal, unmanaged worktrees, and removal while a worktree has active sessions. It never force-removes or automatically cleans up resources after a partial failure. + ## Workspace This repository is the Kompass development workspace. diff --git a/packages/opencode/index.ts b/packages/opencode/index.ts index b26527f..1fb3d7c 100644 --- a/packages/opencode/index.ts +++ b/packages/opencode/index.ts @@ -1,5 +1,6 @@ import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin"; import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"; +import { createOpencodeClient as createV2Client, type OpencodeClient as V2Client } from "@opencode-ai/sdk/v2"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; @@ -18,6 +19,7 @@ import { import { loadMergedKompassConfig } from "./cache.ts"; import { applyAgentsConfig, applyCommandsConfig } from "./config.ts"; import { createPluginLogger, getErrorDetails, type PluginLogger } from "./logging.ts"; +import { createNavigatorTools, getNavigatorCompatibilityWarning } from "./navigator.ts"; import { getConfiguredOpenCodeToolName, } from "./tool-names.ts"; @@ -310,17 +312,29 @@ const opencodeToolCreators: Record = { export async function createOpenCodeTools( client: PluginInput["client"], projectRoot: string, + navigator?: { client: V2Client; projectID: string }, ): Promise> { const config = await loadMergedKompassConfig(projectRoot); const tools: Record = {}; const logger = createPluginLogger(client, projectRoot); const shell = createNodeShell(projectRoot); - for (const toolName of getEnabledToolNames(config.tools)) { + const navigatorEnabled = config.adapters.opencode.navigator.enabled; + const navigatorTools: Record = navigatorEnabled && navigator + ? createNavigatorTools({ + client: navigator.client, + projectID: navigator.projectID, + checkout: projectRoot, + config: config.adapters.opencode.navigator, + }) + : {}; + + for (const toolName of getEnabledToolNames(config.tools, navigatorEnabled)) { const creator = opencodeToolCreators[toolName as keyof typeof opencodeToolCreators]; - if (creator) { + const navigatorTool = navigatorTools[toolName]; + if (creator || navigatorTool) { const registeredName = getConfiguredOpenCodeToolName(toolName, config.tools[toolName].name); - tools[registeredName] = creator(client, config, projectRoot, shell); + tools[registeredName] = navigatorTool ?? creator(client, config, projectRoot, shell); logger.info("Loaded Kompass tool", { tool: toolName, registeredName, @@ -343,7 +357,28 @@ export const OpenCodeCompassPlugin: Plugin = async (input: PluginInput) => { async function createToolsSafely() { try { - return await createOpenCodeTools(client, worktree); + const config = await loadMergedKompassConfig(worktree); + let navigator: { client: V2Client; projectID: string } | undefined; + if (config.adapters.opencode.navigator.enabled) { + const projectID = getString(input.project?.id); + if (!input.serverUrl || !projectID) { + logger.warn("Navigator requires OpenCode 1.17.12 or newer; Navigator tools were not registered"); + } else { + navigator = { + client: createV2Client({ + baseUrl: input.serverUrl.toString(), + directory: worktree, + }), + projectID, + }; + const warning = await getNavigatorCompatibilityWarning(navigator.client); + if (warning) { + logger.warn(`${warning}; Navigator tools were not registered`); + navigator = undefined; + } + } + } + return await createOpenCodeTools(client, worktree, navigator); } catch (error) { logger.warn("Skipping Kompass tool registration", { ...getErrorDetails(error), diff --git a/packages/opencode/kompass.jsonc b/packages/opencode/kompass.jsonc index 6260572..42d4a91 100644 --- a/packages/opencode/kompass.jsonc +++ b/packages/opencode/kompass.jsonc @@ -53,6 +53,14 @@ "pr_sync": { "enabled": true }, "ticket_load": { "enabled": true }, "ticket_sync": { "enabled": true }, + "worktree_list": { "enabled": true }, + "session_create": { "enabled": true }, + "session_list": { "enabled": true }, + "session_read": { "enabled": true }, + "session_send": { "enabled": true }, + "session_wait": { "enabled": true }, + "session_interrupt": { "enabled": true }, + "worktree_remove": { "enabled": true }, }, "components": { @@ -78,6 +86,13 @@ "adapters": { "opencode": { "agentMode": "all", + "navigator": { + "enabled": false, + "maxConcurrentSessions": 8, + "maxReadChars": 20000, + "maxOutputCharsPerItem": 4000, + "maxWaitMs": 120000, + }, }, }, } diff --git a/packages/opencode/navigator.ts b/packages/opencode/navigator.ts new file mode 100644 index 0000000..cbbf19d --- /dev/null +++ b/packages/opencode/navigator.ts @@ -0,0 +1,651 @@ +import type { ToolContext, ToolDefinition } from "@opencode-ai/plugin/tool"; +import { tool } from "@opencode-ai/plugin/tool"; +import type { + OpencodeClient, + SessionMessage, + SessionV2Info, + Worktree, +} from "@opencode-ai/sdk/v2/client"; +import path from "node:path"; + +import type { NavigatorConfig } from "../core/index.ts"; + +export type NavigatorClient = Pick; + +export interface SessionSummary { + sessionID: string; + projectID: string; + directory: string; + title: string; + agent?: string; + model?: { providerID: string; modelID: string }; + createdAt: number; + updatedAt: number; + state: "active" | "idle"; +} + +export interface MessageSummary { + id: string; + type: string; + createdAt?: number; + completedAt?: number; + agent?: string; + model?: { providerID: string; modelID: string }; + items: Array<{ + type: string; + text?: string; + name?: string; + status?: string; + output?: string; + truncated?: boolean; + originalChars?: number; + returnedChars?: number; + }>; +} + +type NavigatorToolName = + | "worktree_list" + | "session_create" + | "session_list" + | "session_read" + | "session_send" + | "session_wait" + | "session_interrupt" + | "worktree_remove"; + +type NavigatorContext = { + checkout: string; + projectID: string; + config: NavigatorConfig; +}; + +type NativeWorktree = { directory: string; name: string; branch?: string }; + +function failResponse(error: unknown, operation: string): never { + const message = error instanceof Error + ? error.message + : typeof error === "object" && error && "message" in error + ? String(error.message) + : JSON.stringify(error); + throw new Error(`${operation} failed${message ? `: ${message}` : ""}`); +} + +function responseData(response: { data?: T; error?: unknown }, operation: string): T { + if (response.error !== undefined) failResponse(response.error, operation); + if (response.data === undefined) throw new Error(`${operation} returned no data`); + return response.data; +} + +function envelopeData(response: { data?: { data: T }; error?: unknown }, operation: string): T { + return responseData(response, operation).data; +} + +function normalizeDirectory(directory: string) { + return path.resolve(directory); +} + +function sameDirectory(left: string, right: string) { + return normalizeDirectory(left) === normalizeDirectory(right); +} + +function normalizeWorktree(value: string | Worktree): NativeWorktree { + if (typeof value === "string") { + return { directory: value, name: path.basename(value) }; + } + return { directory: value.directory, name: value.name, ...(value.branch ? { branch: value.branch } : {}) }; +} + +async function listManagedWorktrees(client: NavigatorClient, checkout: string) { + const values = responseData( + await client.worktree.list({ directory: checkout }), + "OpenCode worktree list", + ) as Array; + return values.map(normalizeWorktree).filter((item) => !sameDirectory(item.directory, checkout)); +} + +async function activeSessionIDs(client: NavigatorClient) { + const active = envelopeData( + await client.v2.session.active(), + "OpenCode active session list", + ); + return new Set(Object.keys(active)); +} + +async function getOwnedSession( + client: NavigatorClient, + projectID: string, + sessionID: string, +): Promise { + const session = await getSession(client, sessionID); + if (session.projectID !== projectID) { + throw new Error(`Session ${sessionID} does not belong to the current OpenCode project`); + } + return session; +} + +async function getSession(client: NavigatorClient, sessionID: string): Promise { + return envelopeData( + await client.v2.session.get({ sessionID }), + `OpenCode session ${sessionID}`, + ); +} + +function summarizeSession(session: SessionV2Info, active: Set): SessionSummary { + return { + sessionID: session.id, + projectID: session.projectID, + directory: session.location.directory, + title: session.title, + ...(session.agent ? { agent: session.agent } : {}), + ...(session.model + ? { model: { providerID: session.model.providerID, modelID: session.model.id } } + : {}), + createdAt: session.time.created, + updatedAt: session.time.updated, + state: active.has(session.id) ? "active" : "idle", + }; +} + +function assertNotCallingSession(sessionID: string, context: ToolContext, operation: string) { + if (sessionID === context.sessionID) { + throw new Error(`Navigator cannot target the calling session for ${operation}`); + } +} + +function toolStatus(message: Extract["content"][number]) { + return message.type === "tool" ? message.state.status : undefined; +} + +function summarizeMessages( + messages: SessionMessage[], + options: { includeOutputs: boolean; maxItemChars: number; maxTotalChars: number }, +) { + let remaining = options.maxTotalChars; + let originalChars = 0; + let returnedChars = 0; + let omittedItems = 0; + + function bounded(value: string | undefined) { + if (value === undefined) return { omitted: true } as const; + originalChars += value.length; + const limit = Math.min(options.maxItemChars, remaining); + if (limit <= 0) { + omittedItems += 1; + return { omitted: true } as const; + } + const text = value.slice(0, limit); + remaining -= text.length; + returnedChars += text.length; + return { + omitted: false, + text, + ...(text.length < value.length + ? { truncated: { originalChars: value.length, returnedChars: text.length } } + : {}), + } as const; + } + + const summaries: MessageSummary[] = messages.map((message) => { + const items: MessageSummary["items"] = []; + const addText = (type: string, value: string | undefined) => { + const result = bounded(value); + if (!result.omitted) items.push({ + type, + text: result.text, + ...(result.truncated + ? { truncated: true, originalChars: result.truncated.originalChars, returnedChars: result.truncated.returnedChars } + : {}), + }); + }; + + if (message.type === "assistant") { + for (const item of message.content) { + if (item.type === "text" || item.type === "reasoning") { + addText(item.type, item.text); + continue; + } + const summary: MessageSummary["items"][number] = { + type: "tool", + name: item.name, + status: toolStatus(item), + }; + if (options.includeOutputs && (item.state.status === "completed" || item.state.status === "error")) { + const raw = item.state.status === "error" + ? item.state.error.message + : JSON.stringify(item.state.result ?? item.state.structured); + const result = bounded(raw); + if (!result.omitted) { + summary.output = result.text; + if (result.truncated) { + summary.truncated = true; + summary.originalChars = result.truncated.originalChars; + summary.returnedChars = result.truncated.returnedChars; + } + } + } + items.push(summary); + } + } else if (message.type === "shell") { + addText("command", message.command); + if (options.includeOutputs) { + const result = bounded(message.output); + if (!result.omitted) { + items.push({ + type: "output", + output: result.text, + ...(result.truncated + ? { truncated: true, originalChars: result.truncated.originalChars, returnedChars: result.truncated.returnedChars } + : {}), + }); + } + } + } else if (message.type === "compaction") { + addText("summary", message.summary); + addText("recent", message.recent); + } else if ("text" in message) { + addText("text", message.text); + } else if (message.type === "agent-switched") { + addText("agent", message.agent); + } else if (message.type === "model-switched") { + addText("model", `${message.model.providerID}/${message.model.id}`); + } + + return { + id: message.id, + type: message.type, + createdAt: "time" in message ? message.time.created : undefined, + ...(message.type === "assistant" && message.time.completed ? { completedAt: message.time.completed } : {}), + ...(message.type === "assistant" ? { agent: message.agent } : {}), + ...(message.type === "assistant" + ? { model: { providerID: message.model.providerID, modelID: message.model.id } } + : {}), + items, + }; + }); + + return { + messages: summaries, + truncation: { + truncated: originalChars > returnedChars, + originalChars, + returnedChars, + maxChars: options.maxTotalChars, + omittedItems, + }, + }; +} + +async function readSession( + client: NavigatorClient, + navigator: NavigatorContext, + args: { + sessionID: string; + cursor?: string; + turnLimit?: number; + includeOutputs?: boolean; + maxOutputCharsPerItem?: number; + }, +) { + const [session, active] = await Promise.all([ + getOwnedSession(client, navigator.projectID, args.sessionID), + activeSessionIDs(client), + ]); + const limit = Math.min(args.turnLimit ?? 10, 100); + const payload = responseData( + await client.v2.session.messages({ + sessionID: args.sessionID, + limit, + order: args.cursor ? undefined : "desc", + cursor: args.cursor, + }), + `OpenCode messages for session ${args.sessionID}`, + ); + const formatted = summarizeMessages(payload.data, { + includeOutputs: args.includeOutputs ?? false, + maxItemChars: Math.min( + args.maxOutputCharsPerItem ?? navigator.config.maxOutputCharsPerItem, + navigator.config.maxOutputCharsPerItem, + ), + maxTotalChars: navigator.config.maxReadChars, + }); + return { + session: summarizeSession(session, active), + messages: formatted.messages, + ...(payload.cursor.next ? { cursor: payload.cursor.next } : {}), + truncation: formatted.truncation, + }; +} + +function json(value: unknown) { + return JSON.stringify(value); +} + +export function createNavigatorTools( + input: NavigatorContext & { client: NavigatorClient }, +): Record { + const { client, ...navigator } = input; + return { + worktree_list: tool({ + description: "List the current OpenCode project checkout and its managed native worktrees.", + args: {}, + async execute() { + return json({ + checkout: { directory: navigator.checkout }, + worktrees: await listManagedWorktrees(client, navigator.checkout), + }); + }, + }), + + session_create: tool({ + description: "Create and asynchronously prompt a native OpenCode session in the checkout or a managed worktree.", + args: { + prompt: tool.schema.string().min(1), + environment: tool.schema.discriminatedUnion("type", [ + tool.schema.object({ type: tool.schema.literal("checkout") }), + tool.schema.object({ + type: tool.schema.literal("existing_worktree"), + directory: tool.schema.string().min(1), + }), + tool.schema.object({ + type: tool.schema.literal("new_worktree"), + name: tool.schema.string().min(1).optional(), + startCommand: tool.schema.string().min(1).optional(), + }), + ]), + agent: tool.schema.string().min(1).optional(), + model: tool.schema.object({ + providerID: tool.schema.string().min(1), + modelID: tool.schema.string().min(1), + }).optional(), + }, + async execute(args) { + const active = await activeSessionIDs(client); + const activeSessions = await Promise.all([...active].map((sessionID) => getSession(client, sessionID))); + const activeOwnedCount = activeSessions.filter((session) => session.projectID === navigator.projectID).length; + if (activeOwnedCount >= navigator.config.maxConcurrentSessions) { + throw new Error(`Navigator allows at most ${navigator.config.maxConcurrentSessions} concurrent sessions`); + } + + let directory = navigator.checkout; + let createdWorktree: NativeWorktree | undefined; + if (args.environment.type === "existing_worktree") { + const requestedDirectory = args.environment.directory; + const worktrees = await listManagedWorktrees(client, navigator.checkout); + const requested = worktrees.find((item) => sameDirectory(item.directory, requestedDirectory)); + if (!requested) throw new Error("The requested directory is not a managed OpenCode worktree for this project"); + directory = requested.directory; + } else if (args.environment.type === "new_worktree") { + const worktreesBefore = await listManagedWorktrees(client, navigator.checkout); + try { + const worktree = responseData( + await client.worktree.create({ + directory: navigator.checkout, + worktreeCreateInput: { + ...(args.environment.name ? { name: args.environment.name } : {}), + ...(args.environment.startCommand ? { startCommand: args.environment.startCommand } : {}), + }, + }), + "OpenCode worktree create", + ); + createdWorktree = normalizeWorktree(worktree); + directory = createdWorktree.directory; + } catch (error) { + const worktreesAfter = await listManagedWorktrees(client, navigator.checkout).catch(() => []); + const before = new Set(worktreesBefore.map((item) => normalizeDirectory(item.directory))); + const partial = worktreesAfter.filter((item) => !before.has(normalizeDirectory(item.directory))); + throw new Error( + `Navigator could not create the requested worktree: ${error instanceof Error ? error.message : String(error)}` + + (partial.length ? `. Worktrees created and not removed: ${partial.map((item) => item.directory).join(", ")}` : ""), + ); + } + } + + let session: SessionV2Info; + try { + session = envelopeData( + await client.v2.session.create({ + ...(args.agent ? { agent: args.agent } : {}), + ...(args.model ? { model: { providerID: args.model.providerID, id: args.model.modelID } } : {}), + location: { directory }, + }), + "OpenCode session create", + ); + } catch (error) { + throw new Error( + `Navigator session creation failed: ${error instanceof Error ? error.message : String(error)}` + + (createdWorktree ? `. Worktree created: ${createdWorktree.name} (${createdWorktree.directory}) and was not removed` : ""), + ); + } + if (session.projectID !== navigator.projectID) { + throw new Error(`Created session ${session.id} belongs to an unexpected project; it was not prompted`); + } + + try { + envelopeData( + await client.v2.session.prompt({ + sessionID: session.id, + prompt: { text: args.prompt }, + delivery: "steer", + }), + `OpenCode prompt admission for session ${session.id}`, + ); + } catch (error) { + throw new Error( + `Navigator prompt admission failed: ${error instanceof Error ? error.message : String(error)}. Session created: ${session.id}` + + (createdWorktree ? `. Worktree created: ${createdWorktree.name} (${createdWorktree.directory})` : ""), + ); + } + + return json({ + sessionID: session.id, + directory, + ...(createdWorktree + ? { worktree: { created: true, name: createdWorktree.name, ...(createdWorktree.branch ? { branch: createdWorktree.branch } : {}) } } + : {}), + }); + }, + }), + + session_list: tool({ + description: "List native OpenCode sessions owned by the current project.", + args: { + directory: tool.schema.string().optional(), + search: tool.schema.string().optional(), + limit: tool.schema.number().int().positive().max(100).optional(), + cursor: tool.schema.string().optional(), + }, + async execute(args) { + if (args.directory) { + const managed = await listManagedWorktrees(client, navigator.checkout); + const valid = [navigator.checkout, ...managed.map((item) => item.directory)] + .some((directory) => sameDirectory(directory, args.directory!)); + if (!valid) throw new Error("The requested directory is not a managed OpenCode worktree for this project"); + } + const [payload, active] = await Promise.all([ + client.v2.session.list({ + project: navigator.projectID, + directory: args.directory, + search: args.search, + limit: args.limit, + cursor: args.cursor, + }).then((response) => responseData(response, "OpenCode session list")), + activeSessionIDs(client), + ]); + const owned = payload.data.filter((session) => session.projectID === navigator.projectID); + return json({ + sessions: owned.map((session) => summarizeSession(session, active)), + ...(payload.cursor.next ? { cursor: payload.cursor.next } : {}), + }); + }, + }), + + session_read: tool({ + description: "Read a bounded page of recent messages from a current-project OpenCode session.", + args: { + sessionID: tool.schema.string().min(1), + cursor: tool.schema.string().optional(), + turnLimit: tool.schema.number().int().positive().max(100).optional(), + includeOutputs: tool.schema.boolean().optional(), + maxOutputCharsPerItem: tool.schema.number().int().positive().optional(), + }, + async execute(args) { + return json(await readSession(client, navigator, args)); + }, + }), + + session_send: tool({ + description: "Steer or queue a prompt for an existing current-project OpenCode session.", + args: { + sessionID: tool.schema.string().min(1), + prompt: tool.schema.string().min(1), + delivery: tool.schema.enum(["steer", "queue"]).optional(), + }, + async execute(args, context) { + assertNotCallingSession(args.sessionID, context, "send to"); + await getOwnedSession(client, navigator.projectID, args.sessionID); + const admitted = envelopeData( + await client.v2.session.prompt({ + sessionID: args.sessionID, + prompt: { text: args.prompt }, + delivery: args.delivery ?? "steer", + }), + `OpenCode prompt admission for session ${args.sessionID}`, + ); + return json({ sessionID: args.sessionID, admitted: Boolean(admitted) }); + }, + }), + + session_wait: tool({ + description: "Wait for the first of one to eight current-project OpenCode sessions to become idle.", + args: { + targets: tool.schema.array(tool.schema.object({ sessionID: tool.schema.string().min(1) })).min(1).max(8), + timeoutMs: tool.schema.number().int().nonnegative().optional(), + }, + async execute(args, context) { + const ids = args.targets.map((target) => target.sessionID); + if (new Set(ids).size !== ids.length) throw new Error("Navigator wait targets must be unique"); + for (const id of ids) assertNotCallingSession(id, context, "wait for"); + const sessions = await Promise.all(ids.map((id) => getOwnedSession(client, navigator.projectID, id))); + let active = await activeSessionIDs(client); + const alreadyIdle = sessions.find((session) => !active.has(session.id)); + if (alreadyIdle) { + const completed = await readSession(client, navigator, { sessionID: alreadyIdle.id }); + return json({ status: "completed", completed }); + } + + const timeoutMs = Math.min(args.timeoutMs ?? navigator.config.maxWaitMs, navigator.config.maxWaitMs); + if (timeoutMs === 0) { + return json({ + status: "timeout", + active: sessions.map((session) => summarizeSession(session, active)), + }); + } + + const controllers = ids.map(() => new AbortController()); + const abortAll = () => controllers.forEach((controller) => controller.abort()); + const invocationAbort = new Promise((_, reject) => { + context.abort.addEventListener("abort", () => reject(context.abort.reason ?? new Error("Navigator wait aborted")), { once: true }); + }); + let timer: ReturnType | undefined; + const timeout = new Promise<{ type: "timeout" }>((resolve) => { + timer = setTimeout(() => resolve({ type: "timeout" }), timeoutMs); + }); + const waits = ids.map((sessionID, index) => + client.v2.session.wait({ sessionID }, { signal: controllers[index].signal }) + .then((response) => { + if (response.error !== undefined) failResponse(response.error, `OpenCode wait for session ${sessionID}`); + return { type: "completed" as const, sessionID }; + }) + ); + + try { + const result = await Promise.race([...waits, timeout, invocationAbort]); + if (result.type === "completed") { + abortAll(); + const completed = await readSession(client, navigator, { sessionID: result.sessionID }); + return json({ status: "completed", completed }); + } + abortAll(); + active = await activeSessionIDs(client); + return json({ + status: "timeout", + active: sessions.filter((session) => active.has(session.id)).map((session) => summarizeSession(session, active)), + }); + } finally { + if (timer) clearTimeout(timer); + abortAll(); + } + }, + }), + + session_interrupt: tool({ + description: "Interrupt active execution in a current-project OpenCode session.", + args: { sessionID: tool.schema.string().min(1) }, + async execute(args, context) { + assertNotCallingSession(args.sessionID, context, "interrupt"); + await getOwnedSession(client, navigator.projectID, args.sessionID); + const response = await client.v2.session.interrupt({ sessionID: args.sessionID }); + if (response.error !== undefined) failResponse(response.error, `OpenCode interrupt for session ${args.sessionID}`); + return json({ sessionID: args.sessionID, interrupted: true }); + }, + }), + + worktree_remove: tool({ + description: "Remove an idle managed OpenCode worktree from the current project without force.", + args: { directory: tool.schema.string().min(1) }, + async execute(args) { + if (sameDirectory(args.directory, navigator.checkout)) { + throw new Error("Navigator refuses to remove the main checkout"); + } + const worktrees = await listManagedWorktrees(client, navigator.checkout); + const managed = worktrees.find((item) => sameDirectory(item.directory, args.directory)); + if (!managed) throw new Error("The requested directory is not a managed OpenCode worktree"); + + const active = await activeSessionIDs(client); + for (const sessionID of active) { + const session = await getSession(client, sessionID); + if (session.projectID !== navigator.projectID) continue; + if (sameDirectory(session.location.directory, managed.directory)) { + throw new Error(`Navigator refuses to remove a worktree containing active session ${sessionID}`); + } + } + const removed = responseData( + await client.worktree.remove({ + directory: navigator.checkout, + worktreeRemoveInput: { directory: managed.directory }, + }), + `OpenCode worktree removal for ${managed.directory}`, + ); + return json({ removed: Boolean(removed) }); + }, + }), + }; +} + +export async function checkNavigatorCompatibility(client: NavigatorClient, checkout: string) { + await listManagedWorktrees(client, checkout); +} + +export async function getNavigatorCompatibilityWarning(client: NavigatorClient) { + if (!client.worktree?.list || !client.v2?.session?.create || !client.v2.session.prompt || !client.v2.session.wait) { + return "Kompass Navigator requires OpenCode 1.17.12 or newer with V2 session and worktree APIs"; + } + if (!client.global?.health) return; + try { + const health = responseData(await client.global.health(), "OpenCode health") as { version?: string }; + if (health.version && compareVersions(health.version, "1.17.12") < 0) { + return `Kompass Navigator requires OpenCode 1.17.12 or newer (running ${health.version})`; + } + } catch (error) { + return `Kompass Navigator could not verify OpenCode compatibility: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function compareVersions(left: string, right: string) { + const a = left.split(".").map((value) => Number.parseInt(value, 10) || 0); + const b = right.split(".").map((value) => Number.parseInt(value, 10) || 0); + for (let index = 0; index < Math.max(a.length, b.length); index += 1) { + if ((a[index] ?? 0) !== (b[index] ?? 0)) return (a[index] ?? 0) - (b[index] ?? 0); + } + return 0; +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 472e429..623de7b 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -47,11 +47,12 @@ "url": "https://github.com/kompassdev/kompass/issues" }, "peerDependencies": { - "@opencode-ai/plugin": "^1.16.2" + "@opencode-ai/plugin": "^1.17.12", + "@opencode-ai/sdk": "^1.17.12" }, "devDependencies": { - "@opencode-ai/plugin": "^1.16.2", - "@opencode-ai/sdk": "^1.16.2", + "@opencode-ai/plugin": "^1.17.12", + "@opencode-ai/sdk": "^1.17.12", "yaml": "^2.8.2" } } diff --git a/packages/opencode/scripts/build.ts b/packages/opencode/scripts/build.ts index 9bf3661..300c46b 100644 --- a/packages/opencode/scripts/build.ts +++ b/packages/opencode/scripts/build.ts @@ -13,7 +13,11 @@ const workspaceReadme = path.join(workspaceRoot, "README.md"); const packageReadme = path.join(packageRoot, "README.md"); const runtimeDirs = ["agents", "commands", "components"] as const; -const bundleExternals = ["@opencode-ai/plugin", "@opencode-ai/plugin/tool"] as const; +const bundleExternals = [ + "@opencode-ai/plugin", + "@opencode-ai/plugin/tool", + "@opencode-ai/sdk/v2", +] as const; const bundleArgs = [ "build", "./plugin.ts", diff --git a/packages/opencode/scripts/compile.ts b/packages/opencode/scripts/compile.ts index bc5c75f..e7f6e73 100644 --- a/packages/opencode/scripts/compile.ts +++ b/packages/opencode/scripts/compile.ts @@ -50,7 +50,7 @@ async function main() { // Load configuration const userConfig = await loadKompassConfig(WORKSPACE_ROOT); const config = mergeWithDefaults(userConfig); - const enabledTools = getEnabledToolNames(config.tools); + const enabledTools = getEnabledToolNames(config.tools, config.adapters.opencode.navigator.enabled); const configuredToolNames = Object.fromEntries( Object.entries(config.tools).map(([toolName, toolConfig]) => [ toolName, diff --git a/packages/opencode/test/navigator.test.ts b/packages/opencode/test/navigator.test.ts new file mode 100644 index 0000000..926cb93 --- /dev/null +++ b/packages/opencode/test/navigator.test.ts @@ -0,0 +1,368 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { createNavigatorTools, getNavigatorCompatibilityWarning } from "../navigator.ts"; + +const navigatorConfig = { + enabled: true, + maxConcurrentSessions: 8, + maxReadChars: 20_000, + maxOutputCharsPerItem: 4_000, + maxWaitMs: 120_000, +}; + +function response(data: T) { + return { data, error: undefined }; +} + +function session(id: string, directory = "/repo", projectID = "project-1") { + return { + id, + projectID, + title: id, + location: { directory }, + time: { created: 1, updated: 2 }, + }; +} + +function createClient(overrides: Record = {}) { + const sessions = [session("session-1")]; + const client: Record = { + worktree: { + list: async () => response(["/repo-worktree"]), + create: async () => response({ directory: "/repo-new", name: "new", branch: "new" }), + remove: async () => response(true), + }, + v2: { + session: { + active: async () => response({ data: {} }), + get: async ({ sessionID }: { sessionID: string }) => response({ + data: sessions.find((item) => item.id === sessionID) ?? session(sessionID), + }), + list: async () => response({ data: sessions, cursor: {} }), + messages: async () => response({ data: [], cursor: {} }), + create: async ({ location }: any) => response({ data: session("created", location.directory) }), + prompt: async ({ sessionID }: any) => response({ data: { sessionID } }), + wait: async () => response(undefined), + interrupt: async () => response(undefined), + }, + }, + }; + for (const [key, value] of Object.entries(overrides)) { + if (key === "worktree") Object.assign(client.worktree, value); + else if (key === "session") Object.assign(client.v2.session, value); + else client[key] = value; + } + return client; +} + +function tools(client = createClient(), config = navigatorConfig) { + return createNavigatorTools({ + client: client as never, + config, + projectID: "project-1", + checkout: "/repo", + }); +} + +function context(sessionID = "caller", abort = new AbortController().signal) { + return { sessionID, abort } as never; +} + +describe("Kompass Navigator", () => { + test("lists the checkout and managed worktrees", async () => { + const output = JSON.parse(await (tools().worktree_list as any).execute({}, context())); + assert.deepEqual(output.checkout, { directory: "/repo" }); + assert.deepEqual(output.worktrees, [{ directory: "/repo-worktree", name: "repo-worktree" }]); + }); + + test("rejects foreign sessions", async () => { + const client = createClient({ + session: { get: async () => response({ data: session("foreign", "/repo", "project-2") }) }, + }); + await assert.rejects( + (tools(client).session_read as any).execute({ sessionID: "foreign" }, context()), + /does not belong to the current OpenCode project/, + ); + }); + + test("filters session lists to the current project", async () => { + const queries: any[] = []; + const client = createClient({ + session: { + list: async (args: any) => { + queries.push(args); + return response({ + data: [session("owned"), session("foreign", "/repo", "project-2")], + cursor: {}, + }); + }, + }, + }); + const output = JSON.parse(await (tools(client).session_list as any).execute({}, context())); + + assert.equal(queries[0].project, "project-1"); + assert.deepEqual(output.sessions.map((item: any) => item.sessionID), ["owned"]); + }); + + test("rejects arbitrary existing worktree paths", async () => { + await assert.rejects( + (tools().session_create as any).execute({ + prompt: "work", + environment: { type: "existing_worktree", directory: "/tmp/arbitrary" }, + }, context()), + /not a managed OpenCode worktree/, + ); + }); + + test("rejects arbitrary session-list directories", async () => { + await assert.rejects( + (tools().session_list as any).execute({ directory: "/tmp/arbitrary" }, context()), + /not a managed OpenCode worktree/, + ); + }); + + test("creates in the selected directory and admits the prompt", async () => { + const creates: any[] = []; + const prompts: any[] = []; + const client = createClient({ + session: { + create: async (args: any) => { + creates.push(args); + return response({ data: session("created", args.location.directory) }); + }, + prompt: async (args: any) => { + prompts.push(args); + return response({ data: { sessionID: args.sessionID } }); + }, + }, + }); + const output = JSON.parse(await (tools(client).session_create as any).execute({ + prompt: "implement it", + environment: { type: "existing_worktree", directory: "/repo-worktree" }, + }, context())); + + assert.equal(output.directory, "/repo-worktree"); + assert.equal(creates[0].location.directory, "/repo-worktree"); + assert.equal(prompts[0].prompt.text, "implement it"); + }); + + test("returns new worktree details", async () => { + const output = JSON.parse(await (tools().session_create as any).execute({ + prompt: "implement it", + environment: { type: "new_worktree", name: "feature" }, + }, context())); + assert.deepEqual(output.worktree, { created: true, name: "new", branch: "new" }); + }); + + test("reports resources created before a prompt failure", async () => { + const client = createClient({ session: { prompt: async () => { throw new Error("admission failed"); } } }); + await assert.rejects( + (tools(client).session_create as any).execute({ + prompt: "implement it", + environment: { type: "new_worktree" }, + }, context()), + /Session created: created.*Worktree created: new \(\/repo-new\)/, + ); + }); + + test("reports a worktree created before a session failure", async () => { + const client = createClient({ session: { create: async () => { throw new Error("create failed"); } } }); + await assert.rejects( + (tools(client).session_create as any).execute({ + prompt: "implement it", + environment: { type: "new_worktree" }, + }, context()), + /Worktree created: new \(\/repo-new\).*was not removed/, + ); + }); + + test("discovers a worktree left behind by a failed native create", async () => { + let created = false; + const client = createClient({ + worktree: { + list: async () => response(created ? ["/repo-partial"] : []), + create: async () => { + created = true; + throw new Error("start command failed"); + }, + }, + }); + await assert.rejects( + (tools(client).session_create as any).execute({ + prompt: "implement it", + environment: { type: "new_worktree", startCommand: "false" }, + }, context()), + /Worktrees created and not removed: \/repo-partial/, + ); + }); + + test("rejects self send, wait, and interrupt", async () => { + const navigator = tools(); + await assert.rejects( + (navigator.session_send as any).execute({ sessionID: "caller", prompt: "x" }, context()), + /cannot target the calling session/, + ); + await assert.rejects( + (navigator.session_wait as any).execute({ targets: [{ sessionID: "caller" }] }, context()), + /cannot target the calling session/, + ); + await assert.rejects( + (navigator.session_interrupt as any).execute({ sessionID: "caller" }, context()), + /cannot target the calling session/, + ); + }); + + test("sends steer by default, supports queue, and interrupts through the SDK", async () => { + const prompts: any[] = []; + const interrupts: any[] = []; + const client = createClient({ + session: { + active: async () => response({ data: { "session-1": { type: "running" } } }), + prompt: async (args: any) => { + prompts.push(args); + return response({ data: { sessionID: args.sessionID } }); + }, + interrupt: async (args: any) => { + interrupts.push(args); + return response(undefined); + }, + }, + }); + const navigator = tools(client); + await (navigator.session_send as any).execute( + { sessionID: "session-1", prompt: "steer" }, + context(), + ); + await (navigator.session_send as any).execute( + { sessionID: "session-1", prompt: "later", delivery: "queue" }, + context(), + ); + await (navigator.session_interrupt as any).execute( + { sessionID: "session-1" }, + context(), + ); + + assert.equal(prompts[0].delivery, "steer"); + assert.equal(prompts[1].delivery, "queue"); + assert.deepEqual(interrupts, [{ sessionID: "session-1" }]); + }); + + test("wait returns the first completion and aborts losing waits", async () => { + const aborted: string[] = []; + const client = createClient({ + session: { + active: async () => response({ data: { one: { type: "running" }, two: { type: "running" } } }), + wait: ({ sessionID }: any, options: any) => new Promise((resolve, reject) => { + options.signal.addEventListener("abort", () => { + aborted.push(sessionID); + reject(new DOMException("Aborted", "AbortError")); + }); + if (sessionID === "one") setTimeout(() => resolve(response(undefined)), 5); + }), + }, + }); + const output = JSON.parse(await (tools(client).session_wait as any).execute({ + targets: [{ sessionID: "one" }, { sessionID: "two" }], + timeoutMs: 100, + }, context())); + assert.equal(output.status, "completed"); + assert.equal(output.completed.session.sessionID, "one"); + assert.ok(aborted.includes("two")); + }); + + test("immediate wait returns an active snapshot", async () => { + const client = createClient({ + session: { active: async () => response({ data: { one: { type: "running" } } }) }, + }); + const output = JSON.parse(await (tools(client).session_wait as any).execute({ + targets: [{ sessionID: "one" }], + timeoutMs: 0, + }, context())); + assert.equal(output.status, "timeout"); + assert.equal(output.active[0].state, "active"); + }); + + test("bounds transcript items and total output", async () => { + const client = createClient({ + session: { + messages: async () => response({ + data: [ + { id: "m1", type: "user", time: { created: 1 }, text: "abcdefghij" }, + { id: "m2", type: "user", time: { created: 2 }, text: "klmnopqrst" }, + ], + cursor: {}, + }), + }, + }); + const output = JSON.parse(await (tools(client, { + ...navigatorConfig, + maxReadChars: 7, + maxOutputCharsPerItem: 5, + }).session_read as any).execute({ sessionID: "session-1" }, context())); + assert.equal(output.messages[0].items[0].text.length, 5); + assert.equal(output.messages[0].items[0].truncated, true); + assert.equal(output.messages[1].items[0].text.length, 2); + }); + + test("guards checkout, unmanaged, and active worktree removal", async () => { + const navigator = tools(); + await assert.rejects( + (navigator.worktree_remove as any).execute({ directory: "/repo" }, context()), + /main checkout/, + ); + await assert.rejects( + (navigator.worktree_remove as any).execute({ directory: "/other" }, context()), + /not a managed OpenCode worktree/, + ); + const activeClient = createClient({ + session: { + active: async () => response({ data: { active: { type: "running" } } }), + get: async () => response({ data: session("active", "/repo-worktree") }), + }, + }); + await assert.rejects( + (tools(activeClient).worktree_remove as any).execute({ directory: "/repo-worktree" }, context()), + /active session/, + ); + + const output = JSON.parse(await (navigator.worktree_remove as any).execute( + { directory: "/repo-worktree" }, + context(), + )); + assert.deepEqual(output, { removed: true }); + }); + + test("fails closed when active-session lookup fails during worktree removal", async () => { + const client = createClient({ + session: { + active: async () => response({ data: { active: { type: "running" } } }), + get: async () => { throw new Error("connection lost"); }, + }, + }); + await assert.rejects( + (tools(client).worktree_remove as any).execute({ directory: "/repo-worktree" }, context()), + /connection lost/, + ); + }); + + test("discovers existing sessions after reinitialization", async () => { + const client = createClient(); + const first = JSON.parse(await (tools(client).session_list as any).execute({}, context())); + const second = JSON.parse(await (tools(client).session_list as any).execute({}, context())); + assert.deepEqual(second.sessions, first.sessions); + }); + + test("reports incompatible OpenCode runtime versions", async () => { + const client = createClient({ + global: { health: async () => response({ version: "1.17.11" }) }, + }); + assert.match( + await getNavigatorCompatibilityWarning(client as never) ?? "", + /requires OpenCode 1\.17\.12 or newer/, + ); + + client.global.health = async () => response({ version: "1.17.12" }); + assert.equal(await getNavigatorCompatibilityWarning(client as never), undefined); + }); +}); diff --git a/packages/opencode/test/tool-registration.test.ts b/packages/opencode/test/tool-registration.test.ts index 03d8f8a..0b06c0d 100644 --- a/packages/opencode/test/tool-registration.test.ts +++ b/packages/opencode/test/tool-registration.test.ts @@ -127,6 +127,52 @@ async function createTempGitRepo() { } describe("createOpenCodeTools", () => { + test("keeps Navigator disabled by default", async () => { + await withTempHome(async () => { + const tools = await createOpenCodeTools(createMockClient() as never, process.cwd()); + assert.equal(tools.kompass_session_create, undefined); + assert.equal(tools.kompass_worktree_list, undefined); + }); + }); + + test("registers all Navigator tools when enabled and preserves aliases and disables", async () => { + await withTempHome(async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "kompass-navigator-tools-")); + try { + await mkdir(path.join(tempDir, ".opencode"), { recursive: true }); + await writeFile(path.join(tempDir, ".opencode", "kompass.jsonc"), `{ + "adapters": { "opencode": { "navigator": { "enabled": true } } }, + "tools": { + "session_create": { "name": "delegate_session" }, + "session_interrupt": { "enabled": false } + } + }`); + const navigatorClient = { + worktree: { list() {}, create() {}, remove() {} }, + v2: { session: { + create() {}, list() {}, get() {}, messages() {}, prompt() {}, active() {}, wait() {}, interrupt() {}, + } }, + }; + const tools = await createOpenCodeTools(createMockClient() as never, tempDir, { + client: navigatorClient as never, + projectID: "project-1", + }); + + assert.ok(tools.kompass_worktree_list); + assert.ok(tools.delegate_session); + assert.ok(tools.kompass_session_list); + assert.ok(tools.kompass_session_read); + assert.ok(tools.kompass_session_send); + assert.ok(tools.kompass_session_wait); + assert.ok(tools.kompass_worktree_remove); + assert.equal(tools.kompass_session_create, undefined); + assert.equal(tools.kompass_session_interrupt, undefined); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + }); + test("registers Kompass tools with prefixed names", async () => { await withTempHome(async () => { const tools = await createOpenCodeTools(createMockClient() as never, process.cwd()); diff --git a/packages/web/src/content/docs/docs/adapters/opencode.mdx b/packages/web/src/content/docs/docs/adapters/opencode.mdx index 0e333d5..e0d2235 100644 --- a/packages/web/src/content/docs/docs/adapters/opencode.mdx +++ b/packages/web/src/content/docs/docs/adapters/opencode.mdx @@ -14,6 +14,7 @@ Inside OpenCode, the adapter exposes: - Kompass commands - Kompass agents - Kompass tools +- opt-in Kompass Navigator session and worktree orchestration - project override loading from local config files ## Project overrides @@ -28,6 +29,35 @@ You can use overrides to: - remap tool names - change defaults such as the base branch - adjust adapter behavior such as `agentMode` +- enable and limit Navigator + +## Kompass Navigator + +Navigator uses OpenCode's native server and SDK to create, inspect, continue, wait for, and interrupt sessions in the current checkout or managed Git worktrees. It requires OpenCode `1.17.12` or newer and is disabled by default. + +```jsonc +{ + "adapters": { + "opencode": { + "navigator": { + "enabled": true, + "maxConcurrentSessions": 8, + "maxReadChars": 20000, + "maxOutputCharsPerItem": 4000, + "maxWaitMs": 120000 + } + } + }, + "tools": { + "session_interrupt": { "enabled": false }, + "session_create": { "name": "delegate_session" } + } +} +``` + +Enabling Navigator enables all eight logical tools unless an individual tool is disabled. Default names receive the `kompass_` prefix; configured aliases such as `delegate_session` are registered exactly. + +Navigator is scoped to the current project. Session IDs are ownership-checked, directories must be native managed worktrees, and lifecycle calls cannot target the calling session. Removal rejects the checkout, unmanaged worktrees, and worktrees containing active sessions. Navigator does not force removal, merge branches, create pull requests, or maintain a separate job database. ## Agent model diff --git a/packages/web/src/content/docs/docs/config/overview.mdx b/packages/web/src/content/docs/docs/config/overview.mdx index 352f2e9..1a5295f 100644 --- a/packages/web/src/content/docs/docs/config/overview.mdx +++ b/packages/web/src/content/docs/docs/config/overview.mdx @@ -64,6 +64,7 @@ Shared defaults such as `baseBranch`. Adapter-specific configuration. Today this includes: - `adapters.opencode.agentMode` +- `adapters.opencode.navigator`, including its feature gate and concurrency, read, output, and wait limits ## Tool alias example diff --git a/packages/web/src/content/docs/docs/config/schema.mdx b/packages/web/src/content/docs/docs/config/schema.mdx index 38b1333..08b2705 100644 --- a/packages/web/src/content/docs/docs/config/schema.mdx +++ b/packages/web/src/content/docs/docs/config/schema.mdx @@ -9,6 +9,7 @@ The published schema lives in `kompass.schema.json`. - default base branch: `main` - OpenCode adapter agent mode: `all` +- OpenCode Navigator: disabled, with limits of 8 concurrent sessions, 20,000 read characters, 4,000 output characters per item, and 120,000 milliseconds per wait - OpenCode subtask command mode: `kompass` - bundled shared validation guidance is included by default @@ -49,3 +50,11 @@ Deprecated array fields still exist in the schema for compatibility, but the new - `subagent` - `primary` - `all` + +`adapters.opencode.navigator` supports: + +- `enabled` +- `maxConcurrentSessions` from 1 through 8 +- positive `maxReadChars` +- positive `maxOutputCharsPerItem`, no greater than `maxReadChars` +- positive `maxWaitMs` diff --git a/packages/web/src/content/docs/docs/reference/tools/index.mdx b/packages/web/src/content/docs/docs/reference/tools/index.mdx index e0d0b36..fbf1549 100644 --- a/packages/web/src/content/docs/docs/reference/tools/index.mdx +++ b/packages/web/src/content/docs/docs/reference/tools/index.mdx @@ -13,6 +13,14 @@ Current built-in tools: - `pr_sync` - `ticket_load` - `ticket_sync` +- `worktree_list` (OpenCode Navigator) +- `session_create` (OpenCode Navigator) +- `session_list` (OpenCode Navigator) +- `session_read` (OpenCode Navigator) +- `session_send` (OpenCode Navigator) +- `session_wait` (OpenCode Navigator) +- `session_interrupt` (OpenCode Navigator) +- `worktree_remove` (OpenCode Navigator) ## Built-in tools @@ -39,3 +47,39 @@ Loads a ticket from GitHub issue references, local files, or raw text, with opti ### `ticket_sync` Creates or updates GitHub issues, renders checklist sections, and can append issue comments. + +## OpenCode Navigator tools + +Navigator tools are disabled by default and are available only through the OpenCode adapter. Their default runtime names use the `kompass_` prefix. See [Kompass Navigator](./navigator/) for arguments and safety behavior. + +### `worktree_list` + +Lists the current checkout explicitly and all native OpenCode-managed worktrees. + +### `session_create` + +Creates a native session in the checkout, an existing managed worktree, or a newly created worktree; admits its initial prompt and returns immediately. + +### `session_list` + +Lists paginated sessions filtered to the current OpenCode project, with location, agent, model, timestamps, and active or idle state. + +### `session_read` + +Reads a bounded page of recent projected messages. Tool outputs are excluded by default, and per-item and total truncation metadata is returned. + +### `session_send` + +Sends a follow-up with native `steer` delivery by default or queues it until the session would otherwise become idle. + +### `session_wait` + +Waits for the first of up to eight sessions to become idle, aborts losing waits, and returns bounded recent output. A zero timeout returns an immediate snapshot. + +### `session_interrupt` + +Interrupts active native execution for another session in the current project. + +### `worktree_remove` + +Removes an idle native managed worktree. It rejects the main checkout, unmanaged paths, and worktrees containing active sessions; force removal is not available. diff --git a/packages/web/src/content/docs/docs/reference/tools/navigator.mdx b/packages/web/src/content/docs/docs/reference/tools/navigator.mdx new file mode 100644 index 0000000..2c018e5 --- /dev/null +++ b/packages/web/src/content/docs/docs/reference/tools/navigator.mdx @@ -0,0 +1,28 @@ +--- +title: Kompass Navigator +description: Native OpenCode session and managed-worktree orchestration tools. +--- + +Navigator is an opt-in OpenCode adapter capability. Default runtime names use the `kompass_` prefix. + +| Logical tool | Purpose | +| --- | --- | +| `worktree_list` | List the checkout and native managed worktrees. | +| `session_create` | Create a V2 session in the checkout, an existing worktree, or a newly created worktree and admit its first prompt asynchronously. | +| `session_list` | List paginated sessions filtered to the current project. | +| `session_read` | Read a bounded page of recent messages with optional tool outputs. | +| `session_send` | Steer an active session or queue work for its next idle point. | +| `session_wait` | Wait for the first of one to eight sessions to become idle. A zero timeout returns an immediate snapshot. | +| `session_interrupt` | Interrupt another session's active execution. | +| `worktree_remove` | Remove an idle native managed worktree without force. | + +## Safety and limits + +- Every session ID is validated against the current OpenCode project. +- Existing-worktree paths must appear in OpenCode's native worktree list. +- Send, wait, and interrupt reject the calling session. +- Reads default to ten recent messages, exclude tool outputs, and enforce configured per-item and total character limits. +- Worktree removal rejects the main checkout, unmanaged worktrees, and worktrees containing active sessions. +- Partial creation errors identify resources already created and never automatically delete them. + +Navigator does not keep a separate job database. Existing native sessions and worktrees remain discoverable after plugin restarts. diff --git a/packages/web/src/pages/index.astro b/packages/web/src/pages/index.astro index 1d1acc9..3250888 100644 --- a/packages/web/src/pages/index.astro +++ b/packages/web/src/pages/index.astro @@ -12,7 +12,7 @@ const heroHighlights = [ }, { eyebrow: "Adapters", - title: "OpenCode today, more surfaces next" + title: "OpenCode workflows and native session navigation" }, { eyebrow: "Integrations", From 2d3ec46b356522e8d70c7d6c13e5065dfbdd62ab Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Sat, 25 Jul 2026 20:05:15 -0300 Subject: [PATCH 2/8] feat: enable Navigator by default - register all Navigator tools on compatible OpenCode runtimes - retain feature-level and per-tool opt-out controls - update bundled config, generated artifacts, tests, and docs --- README.md | 6 ++- kompass.jsonc | 2 +- kompass.schema.json | 2 +- packages/core/kompass.jsonc | 2 +- packages/core/lib/config.ts | 2 +- packages/core/test/config.test.ts | 2 +- packages/opencode/.opencode/kompass.jsonc | 26 +++++++++++- packages/opencode/README.md | 6 ++- packages/opencode/kompass.jsonc | 2 +- .../opencode/test/tool-registration.test.ts | 41 ++++++++++++++++--- .../content/docs/docs/adapters/opencode.mdx | 6 +-- .../src/content/docs/docs/config/schema.mdx | 2 +- .../docs/docs/reference/tools/index.mdx | 2 +- .../docs/docs/reference/tools/navigator.mdx | 2 +- 14 files changed, 80 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index adb8e41..b102c6c 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,9 @@ The recommended project override path is `.opencode/kompass.jsonc`. ## Kompass Navigator -Navigator is an opt-in OpenCode capability for orchestrating native sessions in the current checkout and OpenCode-managed Git worktrees. It uses the OpenCode server and SDK directly, returns immediately after admitting prompts, supports parallel sessions, and discovers native sessions and worktrees again after plugin restarts. It requires OpenCode `1.17.12` or newer. +Navigator is an OpenCode capability for orchestrating native sessions in the current checkout and OpenCode-managed Git worktrees. It is enabled by default, uses the OpenCode server and SDK directly, returns immediately after admitting prompts, supports parallel sessions, and discovers native sessions and worktrees again after plugin restarts. It requires OpenCode `1.17.12` or newer. -Enable all eight Navigator tools with: +Configure Navigator and its limits with: ```jsonc { @@ -76,6 +76,8 @@ Enable all eight Navigator tools with: The default runtime names are `kompass_worktree_list`, `kompass_session_create`, `kompass_session_list`, `kompass_session_read`, `kompass_session_send`, `kompass_session_wait`, `kompass_session_interrupt`, and `kompass_worktree_remove`. Disable or alias any logical name through `tools`; an alias is registered exactly as configured. +Set `adapters.opencode.navigator.enabled` to `false` to disable all Navigator tools. + Navigator accepts only sessions from the current OpenCode project and only worktrees returned by OpenCode. It rejects self-targeting lifecycle calls, arbitrary directories, main-checkout removal, unmanaged worktrees, and removal while a worktree has active sessions. It never force-removes or automatically cleans up resources after a partial failure. ## Workspace diff --git a/kompass.jsonc b/kompass.jsonc index 42d4a91..047f44a 100644 --- a/kompass.jsonc +++ b/kompass.jsonc @@ -87,7 +87,7 @@ "opencode": { "agentMode": "all", "navigator": { - "enabled": false, + "enabled": true, "maxConcurrentSessions": 8, "maxReadChars": 20000, "maxOutputCharsPerItem": 4000, diff --git a/kompass.schema.json b/kompass.schema.json index a3b1478..0efd73f 100644 --- a/kompass.schema.json +++ b/kompass.schema.json @@ -356,7 +356,7 @@ "type": "object", "additionalProperties": false, "properties": { - "enabled": { "type": "boolean", "default": false }, + "enabled": { "type": "boolean", "default": true }, "maxConcurrentSessions": { "type": "integer", "minimum": 1, "maximum": 8, "default": 8 }, "maxReadChars": { "type": "integer", "minimum": 1, "default": 20000 }, "maxOutputCharsPerItem": { "type": "integer", "minimum": 1, "default": 4000 }, diff --git a/packages/core/kompass.jsonc b/packages/core/kompass.jsonc index 42d4a91..047f44a 100644 --- a/packages/core/kompass.jsonc +++ b/packages/core/kompass.jsonc @@ -87,7 +87,7 @@ "opencode": { "agentMode": "all", "navigator": { - "enabled": false, + "enabled": true, "maxConcurrentSessions": 8, "maxReadChars": 20000, "maxOutputCharsPerItem": 4000, diff --git a/packages/core/lib/config.ts b/packages/core/lib/config.ts index 157fdd9..e2ee245 100644 --- a/packages/core/lib/config.ts +++ b/packages/core/lib/config.ts @@ -642,7 +642,7 @@ export function mergeWithDefaults( const { enabled: _reviewerEnabled, ...reviewerOverrides } = config?.agents?.reviewer ?? {}; const { enabled: _plannerEnabled, ...plannerOverrides } = config?.agents?.planner ?? {}; const navigator = { - enabled: config?.adapters?.opencode?.navigator?.enabled ?? false, + enabled: config?.adapters?.opencode?.navigator?.enabled ?? true, maxConcurrentSessions: config?.adapters?.opencode?.navigator?.maxConcurrentSessions ?? 8, maxReadChars: config?.adapters?.opencode?.navigator?.maxReadChars ?? 20_000, diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index 0417c47..53e257c 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -232,7 +232,7 @@ describe("object-based config", () => { test("merges Navigator defaults and validates limits", () => { const defaults = mergeWithDefaults(null); assert.deepEqual(defaults.adapters.opencode.navigator, { - enabled: false, + enabled: true, maxConcurrentSessions: 8, maxReadChars: 20_000, maxOutputCharsPerItem: 4_000, diff --git a/packages/opencode/.opencode/kompass.jsonc b/packages/opencode/.opencode/kompass.jsonc index 1c1c65f..7eface7 100644 --- a/packages/opencode/.opencode/kompass.jsonc +++ b/packages/opencode/.opencode/kompass.jsonc @@ -118,6 +118,30 @@ }, "ticket_load": { "enabled": true + }, + "worktree_list": { + "enabled": true + }, + "session_create": { + "enabled": true + }, + "session_list": { + "enabled": true + }, + "session_read": { + "enabled": true + }, + "session_send": { + "enabled": true + }, + "session_wait": { + "enabled": true + }, + "session_interrupt": { + "enabled": true + }, + "worktree_remove": { + "enabled": true } }, "defaults": { @@ -127,7 +151,7 @@ "opencode": { "agentMode": "all", "navigator": { - "enabled": false, + "enabled": true, "maxConcurrentSessions": 8, "maxReadChars": 20000, "maxOutputCharsPerItem": 4000, diff --git a/packages/opencode/README.md b/packages/opencode/README.md index adb8e41..b102c6c 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -54,9 +54,9 @@ The recommended project override path is `.opencode/kompass.jsonc`. ## Kompass Navigator -Navigator is an opt-in OpenCode capability for orchestrating native sessions in the current checkout and OpenCode-managed Git worktrees. It uses the OpenCode server and SDK directly, returns immediately after admitting prompts, supports parallel sessions, and discovers native sessions and worktrees again after plugin restarts. It requires OpenCode `1.17.12` or newer. +Navigator is an OpenCode capability for orchestrating native sessions in the current checkout and OpenCode-managed Git worktrees. It is enabled by default, uses the OpenCode server and SDK directly, returns immediately after admitting prompts, supports parallel sessions, and discovers native sessions and worktrees again after plugin restarts. It requires OpenCode `1.17.12` or newer. -Enable all eight Navigator tools with: +Configure Navigator and its limits with: ```jsonc { @@ -76,6 +76,8 @@ Enable all eight Navigator tools with: The default runtime names are `kompass_worktree_list`, `kompass_session_create`, `kompass_session_list`, `kompass_session_read`, `kompass_session_send`, `kompass_session_wait`, `kompass_session_interrupt`, and `kompass_worktree_remove`. Disable or alias any logical name through `tools`; an alias is registered exactly as configured. +Set `adapters.opencode.navigator.enabled` to `false` to disable all Navigator tools. + Navigator accepts only sessions from the current OpenCode project and only worktrees returned by OpenCode. It rejects self-targeting lifecycle calls, arbitrary directories, main-checkout removal, unmanaged worktrees, and removal while a worktree has active sessions. It never force-removes or automatically cleans up resources after a partial failure. ## Workspace diff --git a/packages/opencode/kompass.jsonc b/packages/opencode/kompass.jsonc index 42d4a91..047f44a 100644 --- a/packages/opencode/kompass.jsonc +++ b/packages/opencode/kompass.jsonc @@ -87,7 +87,7 @@ "opencode": { "agentMode": "all", "navigator": { - "enabled": false, + "enabled": true, "maxConcurrentSessions": 8, "maxReadChars": 20000, "maxOutputCharsPerItem": 4000, diff --git a/packages/opencode/test/tool-registration.test.ts b/packages/opencode/test/tool-registration.test.ts index 0b06c0d..c0397aa 100644 --- a/packages/opencode/test/tool-registration.test.ts +++ b/packages/opencode/test/tool-registration.test.ts @@ -127,21 +127,50 @@ async function createTempGitRepo() { } describe("createOpenCodeTools", () => { - test("keeps Navigator disabled by default", async () => { + test("registers Navigator by default", async () => { await withTempHome(async () => { - const tools = await createOpenCodeTools(createMockClient() as never, process.cwd()); - assert.equal(tools.kompass_session_create, undefined); - assert.equal(tools.kompass_worktree_list, undefined); + const navigatorClient = { + worktree: { list() {}, create() {}, remove() {} }, + v2: { session: { + create() {}, list() {}, get() {}, messages() {}, prompt() {}, active() {}, wait() {}, interrupt() {}, + } }, + }; + const tools = await createOpenCodeTools(createMockClient() as never, process.cwd(), { + client: navigatorClient as never, + projectID: "project-1", + }); + assert.ok(tools.kompass_session_create); + assert.ok(tools.kompass_worktree_list); + }); + }); + + test("allows Navigator to be disabled as a feature", async () => { + await withTempHome(async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "kompass-navigator-disabled-")); + try { + await mkdir(path.join(tempDir, ".opencode"), { recursive: true }); + await writeFile(path.join(tempDir, ".opencode", "kompass.jsonc"), `{ + "adapters": { "opencode": { "navigator": { "enabled": false } } } + }`); + const tools = await createOpenCodeTools(createMockClient() as never, tempDir, { + client: {} as never, + projectID: "project-1", + }); + + assert.equal(tools.kompass_session_create, undefined); + assert.equal(tools.kompass_worktree_list, undefined); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } }); }); - test("registers all Navigator tools when enabled and preserves aliases and disables", async () => { + test("preserves Navigator aliases and individual disables", async () => { await withTempHome(async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "kompass-navigator-tools-")); try { await mkdir(path.join(tempDir, ".opencode"), { recursive: true }); await writeFile(path.join(tempDir, ".opencode", "kompass.jsonc"), `{ - "adapters": { "opencode": { "navigator": { "enabled": true } } }, "tools": { "session_create": { "name": "delegate_session" }, "session_interrupt": { "enabled": false } diff --git a/packages/web/src/content/docs/docs/adapters/opencode.mdx b/packages/web/src/content/docs/docs/adapters/opencode.mdx index e0d2235..95ae6a0 100644 --- a/packages/web/src/content/docs/docs/adapters/opencode.mdx +++ b/packages/web/src/content/docs/docs/adapters/opencode.mdx @@ -14,7 +14,7 @@ Inside OpenCode, the adapter exposes: - Kompass commands - Kompass agents - Kompass tools -- opt-in Kompass Navigator session and worktree orchestration +- Kompass Navigator session and worktree orchestration - project override loading from local config files ## Project overrides @@ -33,7 +33,7 @@ You can use overrides to: ## Kompass Navigator -Navigator uses OpenCode's native server and SDK to create, inspect, continue, wait for, and interrupt sessions in the current checkout or managed Git worktrees. It requires OpenCode `1.17.12` or newer and is disabled by default. +Navigator uses OpenCode's native server and SDK to create, inspect, continue, wait for, and interrupt sessions in the current checkout or managed Git worktrees. It requires OpenCode `1.17.12` or newer and is enabled by default. ```jsonc { @@ -55,7 +55,7 @@ Navigator uses OpenCode's native server and SDK to create, inspect, continue, wa } ``` -Enabling Navigator enables all eight logical tools unless an individual tool is disabled. Default names receive the `kompass_` prefix; configured aliases such as `delegate_session` are registered exactly. +Navigator registers all eight logical tools unless the feature or an individual tool is disabled. Default names receive the `kompass_` prefix; configured aliases such as `delegate_session` are registered exactly. Navigator is scoped to the current project. Session IDs are ownership-checked, directories must be native managed worktrees, and lifecycle calls cannot target the calling session. Removal rejects the checkout, unmanaged worktrees, and worktrees containing active sessions. Navigator does not force removal, merge branches, create pull requests, or maintain a separate job database. diff --git a/packages/web/src/content/docs/docs/config/schema.mdx b/packages/web/src/content/docs/docs/config/schema.mdx index 08b2705..69e2bc6 100644 --- a/packages/web/src/content/docs/docs/config/schema.mdx +++ b/packages/web/src/content/docs/docs/config/schema.mdx @@ -9,7 +9,7 @@ The published schema lives in `kompass.schema.json`. - default base branch: `main` - OpenCode adapter agent mode: `all` -- OpenCode Navigator: disabled, with limits of 8 concurrent sessions, 20,000 read characters, 4,000 output characters per item, and 120,000 milliseconds per wait +- OpenCode Navigator: enabled, with limits of 8 concurrent sessions, 20,000 read characters, 4,000 output characters per item, and 120,000 milliseconds per wait - OpenCode subtask command mode: `kompass` - bundled shared validation guidance is included by default diff --git a/packages/web/src/content/docs/docs/reference/tools/index.mdx b/packages/web/src/content/docs/docs/reference/tools/index.mdx index fbf1549..7bec614 100644 --- a/packages/web/src/content/docs/docs/reference/tools/index.mdx +++ b/packages/web/src/content/docs/docs/reference/tools/index.mdx @@ -50,7 +50,7 @@ Creates or updates GitHub issues, renders checklist sections, and can append iss ## OpenCode Navigator tools -Navigator tools are disabled by default and are available only through the OpenCode adapter. Their default runtime names use the `kompass_` prefix. See [Kompass Navigator](./navigator/) for arguments and safety behavior. +Navigator tools are enabled by default and are available only through the OpenCode adapter. Their default runtime names use the `kompass_` prefix. See [Kompass Navigator](./navigator/) for arguments and safety behavior. ### `worktree_list` diff --git a/packages/web/src/content/docs/docs/reference/tools/navigator.mdx b/packages/web/src/content/docs/docs/reference/tools/navigator.mdx index 2c018e5..aef4f52 100644 --- a/packages/web/src/content/docs/docs/reference/tools/navigator.mdx +++ b/packages/web/src/content/docs/docs/reference/tools/navigator.mdx @@ -3,7 +3,7 @@ title: Kompass Navigator description: Native OpenCode session and managed-worktree orchestration tools. --- -Navigator is an opt-in OpenCode adapter capability. Default runtime names use the `kompass_` prefix. +Navigator is enabled by default in the OpenCode adapter. Default runtime names use the `kompass_` prefix; set `adapters.opencode.navigator.enabled` to `false` to disable all eight tools. | Logical tool | Purpose | | --- | --- | From 8ecc561e5fa7f45d94c23a0ad904339f6e9071b9 Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Sun, 26 Jul 2026 18:24:57 -0300 Subject: [PATCH 3/8] fix: align Navigator with OpenCode protocols - detect and route sessions through the compatible OpenCode protocol - add legacy session handling for prompts, reads, status, and interrupts - replace V2 wait calls with local active-session polling - remove queued delivery from the Navigator send tool surface - update Navigator tests and docs for protocol-aware behavior --- README.md | 2 +- packages/opencode/README.md | 2 +- packages/opencode/index.ts | 43 ++- packages/opencode/navigator.ts | 302 +++++++++++++----- packages/opencode/test/navigator.test.ts | 107 +++++-- .../opencode/test/tool-registration.test.ts | 6 + .../content/docs/docs/adapters/opencode.mdx | 2 +- .../docs/docs/reference/tools/index.mdx | 4 +- .../docs/docs/reference/tools/navigator.mdx | 7 +- 9 files changed, 357 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index b102c6c..d6c15a6 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ The recommended project override path is `.opencode/kompass.jsonc`. ## Kompass Navigator -Navigator is an OpenCode capability for orchestrating native sessions in the current checkout and OpenCode-managed Git worktrees. It is enabled by default, uses the OpenCode server and SDK directly, returns immediately after admitting prompts, supports parallel sessions, and discovers native sessions and worktrees again after plugin restarts. It requires OpenCode `1.17.12` or newer. +Navigator is an OpenCode capability for orchestrating native sessions in the current checkout and OpenCode-managed Git worktrees. It is enabled by default, follows OpenCode Desktop's protocol detection so session creation, prompts, reads, status, and interrupts stay on one compatible API, returns immediately after admitting prompts, and supports parallel sessions. Until OpenCode implements V2 wait, Navigator waits by polling the active-session API locally. It requires OpenCode `1.17.12` or newer. Configure Navigator and its limits with: diff --git a/packages/opencode/README.md b/packages/opencode/README.md index b102c6c..d6c15a6 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -54,7 +54,7 @@ The recommended project override path is `.opencode/kompass.jsonc`. ## Kompass Navigator -Navigator is an OpenCode capability for orchestrating native sessions in the current checkout and OpenCode-managed Git worktrees. It is enabled by default, uses the OpenCode server and SDK directly, returns immediately after admitting prompts, supports parallel sessions, and discovers native sessions and worktrees again after plugin restarts. It requires OpenCode `1.17.12` or newer. +Navigator is an OpenCode capability for orchestrating native sessions in the current checkout and OpenCode-managed Git worktrees. It is enabled by default, follows OpenCode Desktop's protocol detection so session creation, prompts, reads, status, and interrupts stay on one compatible API, returns immediately after admitting prompts, and supports parallel sessions. Until OpenCode implements V2 wait, Navigator waits by polling the active-session API locally. It requires OpenCode `1.17.12` or newer. Configure Navigator and its limits with: diff --git a/packages/opencode/index.ts b/packages/opencode/index.ts index 1fb3d7c..be6c929 100644 --- a/packages/opencode/index.ts +++ b/packages/opencode/index.ts @@ -1,5 +1,6 @@ import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin"; import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"; +import { createOpencodeClient as createLegacyClient, type OpencodeClient as LegacyClient } from "@opencode-ai/sdk/client"; import { createOpencodeClient as createV2Client, type OpencodeClient as V2Client } from "@opencode-ai/sdk/v2"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; @@ -19,7 +20,7 @@ import { import { loadMergedKompassConfig } from "./cache.ts"; import { applyAgentsConfig, applyCommandsConfig } from "./config.ts"; import { createPluginLogger, getErrorDetails, type PluginLogger } from "./logging.ts"; -import { createNavigatorTools, getNavigatorCompatibilityWarning } from "./navigator.ts"; +import { createNavigatorTools, detectNavigatorProtocol, getNavigatorCompatibilityWarning } from "./navigator.ts"; import { getConfiguredOpenCodeToolName, } from "./tool-names.ts"; @@ -312,7 +313,12 @@ const opencodeToolCreators: Record = { export async function createOpenCodeTools( client: PluginInput["client"], projectRoot: string, - navigator?: { client: V2Client; projectID: string }, + navigator?: { + client: V2Client; + legacyClient: (directory: string) => LegacyClient; + projectID: string; + protocol: "v1" | "v2"; + }, ): Promise> { const config = await loadMergedKompassConfig(projectRoot); const tools: Record = {}; @@ -323,9 +329,11 @@ export async function createOpenCodeTools( const navigatorTools: Record = navigatorEnabled && navigator ? createNavigatorTools({ client: navigator.client, + legacyClient: navigator.legacyClient, projectID: navigator.projectID, checkout: projectRoot, config: config.adapters.opencode.navigator, + protocol: navigator.protocol, }) : {}; @@ -358,20 +366,39 @@ export const OpenCodeCompassPlugin: Plugin = async (input: PluginInput) => { async function createToolsSafely() { try { const config = await loadMergedKompassConfig(worktree); - let navigator: { client: V2Client; projectID: string } | undefined; + let navigator: { + client: V2Client; + legacyClient: (directory: string) => LegacyClient; + projectID: string; + protocol: "v1" | "v2"; + } | undefined; if (config.adapters.opencode.navigator.enabled) { const projectID = getString(input.project?.id); if (!input.serverUrl || !projectID) { logger.warn("Navigator requires OpenCode 1.17.12 or newer; Navigator tools were not registered"); } else { + const legacyClients = new Map(); + const navigatorClient = createV2Client({ baseUrl: input.serverUrl.toString() }); + const protocol = await detectNavigatorProtocol(navigatorClient); navigator = { - client: createV2Client({ - baseUrl: input.serverUrl.toString(), - directory: worktree, - }), + // A client-wide directory would silently hide sessions in managed worktrees. + client: navigatorClient, + legacyClient(directory) { + let located = legacyClients.get(directory); + if (!located) { + located = createLegacyClient({ baseUrl: input.serverUrl!.toString(), directory }); + legacyClients.set(directory, located); + } + return located; + }, projectID, + protocol, }; - const warning = await getNavigatorCompatibilityWarning(navigator.client); + const warning = await getNavigatorCompatibilityWarning( + navigator.client, + navigator.protocol, + navigator.legacyClient(worktree), + ); if (warning) { logger.warn(`${warning}; Navigator tools were not registered`); navigator = undefined; diff --git a/packages/opencode/navigator.ts b/packages/opencode/navigator.ts index cbbf19d..8a1ef67 100644 --- a/packages/opencode/navigator.ts +++ b/packages/opencode/navigator.ts @@ -1,5 +1,10 @@ import type { ToolContext, ToolDefinition } from "@opencode-ai/plugin/tool"; import { tool } from "@opencode-ai/plugin/tool"; +import type { + Message as LegacyMessage, + OpencodeClient as LegacyOpencodeClient, + Part as LegacyPart, +} from "@opencode-ai/sdk/client"; import type { OpencodeClient, SessionMessage, @@ -11,6 +16,10 @@ import path from "node:path"; import type { NavigatorConfig } from "../core/index.ts"; export type NavigatorClient = Pick; +export type NavigatorLegacyClient = Pick; + +type NavigatorProtocol = "v1" | "v2"; +type LegacySessionMessage = { info: LegacyMessage; parts: LegacyPart[] }; export interface SessionSummary { sessionID: string; @@ -57,6 +66,8 @@ type NavigatorContext = { checkout: string; projectID: string; config: NavigatorConfig; + protocol: NavigatorProtocol; + legacyClient: (directory: string) => NavigatorLegacyClient; }; type NativeWorktree = { directory: string; name: string; branch?: string }; @@ -103,7 +114,18 @@ async function listManagedWorktrees(client: NavigatorClient, checkout: string) { return values.map(normalizeWorktree).filter((item) => !sameDirectory(item.directory, checkout)); } -async function activeSessionIDs(client: NavigatorClient) { +async function activeSessionIDs(client: NavigatorClient, navigator: NavigatorContext) { + if (navigator.protocol === "v1") { + const worktrees = await listManagedWorktrees(client, navigator.checkout); + const directories = [navigator.checkout, ...worktrees.map((item) => item.directory)]; + const statuses = await Promise.all(directories.map(async (directory) => responseData( + await navigator.legacyClient(directory).session.status(), + `OpenCode session status for ${directory}`, + ) as Record)); + return new Set(statuses.flatMap((items) => Object.entries(items) + .filter(([, status]) => status.type !== "idle") + .map(([sessionID]) => sessionID))); + } const active = envelopeData( await client.v2.session.active(), "OpenCode active session list", @@ -111,6 +133,60 @@ async function activeSessionIDs(client: NavigatorClient) { return new Set(Object.keys(active)); } +function projectLegacyMessages(messages: LegacySessionMessage[]): SessionMessage[] { + return messages.map(({ info, parts }) => { + if (info.role === "user") { + return { + id: info.id, + type: "user", + time: { created: info.time.created }, + text: parts.filter((part) => part.type === "text").map((part) => part.text).join("\n"), + }; + } + + const content: Extract["content"] = []; + for (const part of parts) { + if (part.type === "text" || part.type === "reasoning") { + content.push({ id: part.id, type: part.type, text: part.text }); + continue; + } + if (part.type !== "tool") continue; + const state = part.state.status === "pending" + ? { status: "pending", input: JSON.stringify(part.state.input) } + : part.state.status === "running" + ? { status: "running", input: part.state.input, structured: {}, content: [] } + : part.state.status === "completed" + ? { status: "completed", input: part.state.input, structured: {}, content: [], result: part.state.output } + : { + status: "error", + input: part.state.input, + structured: {}, + content: [], + error: { name: "UnknownError", data: { message: part.state.error } }, + }; + content.push({ + id: part.id, + type: "tool", + name: part.tool, + state, + time: { + created: "time" in part.state ? part.state.time.start : info.time.created, + ...("time" in part.state && "end" in part.state.time ? { completed: part.state.time.end } : {}), + }, + } as Extract["content"][number]); + } + + return { + id: info.id, + type: "assistant", + time: info.time, + agent: info.mode, + model: { providerID: info.providerID, id: info.modelID }, + content, + } as SessionMessage; + }); +} + async function getOwnedSession( client: NavigatorClient, projectID: string, @@ -288,18 +364,30 @@ async function readSession( ) { const [session, active] = await Promise.all([ getOwnedSession(client, navigator.projectID, args.sessionID), - activeSessionIDs(client), + activeSessionIDs(client, navigator), ]); const limit = Math.min(args.turnLimit ?? 10, 100); - const payload = responseData( - await client.v2.session.messages({ - sessionID: args.sessionID, - limit, - order: args.cursor ? undefined : "desc", - cursor: args.cursor, - }), - `OpenCode messages for session ${args.sessionID}`, - ); + const payload = navigator.protocol === "v1" + ? await (async () => { + const response = await navigator.legacyClient(session.location.directory).session.messages({ + path: { id: args.sessionID }, + query: { limit, ...(args.cursor ? { before: args.cursor } : {}) }, + }); + const messages = responseData(response, `OpenCode messages for session ${args.sessionID}`) as LegacySessionMessage[]; + return { + data: projectLegacyMessages(messages).reverse(), + cursor: { next: response.response.headers.get("x-next-cursor") ?? undefined }, + }; + })() + : responseData( + await client.v2.session.messages({ + sessionID: args.sessionID, + limit, + order: args.cursor ? undefined : "desc", + cursor: args.cursor, + }), + `OpenCode messages for session ${args.sessionID}`, + ); const formatted = summarizeMessages(payload.data, { includeOutputs: args.includeOutputs ?? false, maxItemChars: Math.min( @@ -359,7 +447,7 @@ export function createNavigatorTools( }).optional(), }, async execute(args) { - const active = await activeSessionIDs(client); + const active = await activeSessionIDs(client, navigator); const activeSessions = await Promise.all([...active].map((sessionID) => getSession(client, sessionID))); const activeOwnedCount = activeSessions.filter((session) => session.projectID === navigator.projectID).length; if (activeOwnedCount >= navigator.config.maxConcurrentSessions) { @@ -400,16 +488,21 @@ export function createNavigatorTools( } } - let session: SessionV2Info; + let session: SessionV2Info | { id: string; projectID: string }; try { - session = envelopeData( - await client.v2.session.create({ - ...(args.agent ? { agent: args.agent } : {}), - ...(args.model ? { model: { providerID: args.model.providerID, id: args.model.modelID } } : {}), - location: { directory }, - }), - "OpenCode session create", - ); + session = navigator.protocol === "v1" + ? responseData( + await navigator.legacyClient(directory).session.create(), + "OpenCode session create", + ) as { id: string; projectID: string } + : envelopeData( + await client.v2.session.create({ + ...(args.agent ? { agent: args.agent } : {}), + ...(args.model ? { model: { providerID: args.model.providerID, id: args.model.modelID } } : {}), + location: { directory }, + }), + "OpenCode session create", + ); } catch (error) { throw new Error( `Navigator session creation failed: ${error instanceof Error ? error.message : String(error)}` + @@ -421,14 +514,26 @@ export function createNavigatorTools( } try { - envelopeData( - await client.v2.session.prompt({ - sessionID: session.id, - prompt: { text: args.prompt }, - delivery: "steer", - }), - `OpenCode prompt admission for session ${session.id}`, - ); + if (navigator.protocol === "v1") { + const response = await navigator.legacyClient(directory).session.promptAsync({ + path: { id: session.id }, + body: { + parts: [{ type: "text", text: args.prompt }], + ...(args.agent ? { agent: args.agent } : {}), + ...(args.model ? { model: args.model } : {}), + }, + }); + if (response.error !== undefined) failResponse(response.error, `OpenCode prompt admission for session ${session.id}`); + } else { + envelopeData( + await client.v2.session.prompt({ + sessionID: session.id, + prompt: { text: args.prompt }, + delivery: "steer", + }), + `OpenCode prompt admission for session ${session.id}`, + ); + } } catch (error) { throw new Error( `Navigator prompt admission failed: ${error instanceof Error ? error.message : String(error)}. Session created: ${session.id}` + @@ -469,7 +574,7 @@ export function createNavigatorTools( limit: args.limit, cursor: args.cursor, }).then((response) => responseData(response, "OpenCode session list")), - activeSessionIDs(client), + activeSessionIDs(client, navigator), ]); const owned = payload.data.filter((session) => session.projectID === navigator.projectID); return json({ @@ -494,23 +599,27 @@ export function createNavigatorTools( }), session_send: tool({ - description: "Steer or queue a prompt for an existing current-project OpenCode session.", + description: "Steer a prompt for an existing current-project OpenCode session.", args: { sessionID: tool.schema.string().min(1), prompt: tool.schema.string().min(1), - delivery: tool.schema.enum(["steer", "queue"]).optional(), }, async execute(args, context) { assertNotCallingSession(args.sessionID, context, "send to"); - await getOwnedSession(client, navigator.projectID, args.sessionID); - const admitted = envelopeData( - await client.v2.session.prompt({ - sessionID: args.sessionID, - prompt: { text: args.prompt }, - delivery: args.delivery ?? "steer", - }), - `OpenCode prompt admission for session ${args.sessionID}`, - ); + const session = await getOwnedSession(client, navigator.projectID, args.sessionID); + if (navigator.protocol === "v1") { + const response = await navigator.legacyClient(session.location.directory).session.promptAsync({ + path: { id: args.sessionID }, + body: { parts: [{ type: "text", text: args.prompt }] }, + }); + if (response.error !== undefined) failResponse(response.error, `OpenCode prompt admission for session ${args.sessionID}`); + return json({ sessionID: args.sessionID, admitted: true }); + } + const admitted = envelopeData(await client.v2.session.prompt({ + sessionID: args.sessionID, + prompt: { text: args.prompt }, + delivery: "steer", + }), `OpenCode prompt admission for session ${args.sessionID}`); return json({ sessionID: args.sessionID, admitted: Boolean(admitted) }); }, }), @@ -526,7 +635,7 @@ export function createNavigatorTools( if (new Set(ids).size !== ids.length) throw new Error("Navigator wait targets must be unique"); for (const id of ids) assertNotCallingSession(id, context, "wait for"); const sessions = await Promise.all(ids.map((id) => getOwnedSession(client, navigator.projectID, id))); - let active = await activeSessionIDs(client); + let active = await activeSessionIDs(client, navigator); const alreadyIdle = sessions.find((session) => !active.has(session.id)); if (alreadyIdle) { const completed = await readSession(client, navigator, { sessionID: alreadyIdle.id }); @@ -541,40 +650,21 @@ export function createNavigatorTools( }); } - const controllers = ids.map(() => new AbortController()); - const abortAll = () => controllers.forEach((controller) => controller.abort()); - const invocationAbort = new Promise((_, reject) => { - context.abort.addEventListener("abort", () => reject(context.abort.reason ?? new Error("Navigator wait aborted")), { once: true }); - }); - let timer: ReturnType | undefined; - const timeout = new Promise<{ type: "timeout" }>((resolve) => { - timer = setTimeout(() => resolve({ type: "timeout" }), timeoutMs); - }); - const waits = ids.map((sessionID, index) => - client.v2.session.wait({ sessionID }, { signal: controllers[index].signal }) - .then((response) => { - if (response.error !== undefined) failResponse(response.error, `OpenCode wait for session ${sessionID}`); - return { type: "completed" as const, sessionID }; - }) - ); - - try { - const result = await Promise.race([...waits, timeout, invocationAbort]); - if (result.type === "completed") { - abortAll(); - const completed = await readSession(client, navigator, { sessionID: result.sessionID }); + // OpenCode's V2 wait endpoint is unavailable, so poll process-global active ownership locally. + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await waitFor(Math.min(250, deadline - Date.now()), context.abort); + active = await activeSessionIDs(client, navigator); + const completedSession = sessions.find((session) => !active.has(session.id)); + if (completedSession) { + const completed = await readSession(client, navigator, { sessionID: completedSession.id }); return json({ status: "completed", completed }); } - abortAll(); - active = await activeSessionIDs(client); - return json({ - status: "timeout", - active: sessions.filter((session) => active.has(session.id)).map((session) => summarizeSession(session, active)), - }); - } finally { - if (timer) clearTimeout(timer); - abortAll(); } + return json({ + status: "timeout", + active: sessions.filter((session) => active.has(session.id)).map((session) => summarizeSession(session, active)), + }); }, }), @@ -583,8 +673,10 @@ export function createNavigatorTools( args: { sessionID: tool.schema.string().min(1) }, async execute(args, context) { assertNotCallingSession(args.sessionID, context, "interrupt"); - await getOwnedSession(client, navigator.projectID, args.sessionID); - const response = await client.v2.session.interrupt({ sessionID: args.sessionID }); + const session = await getOwnedSession(client, navigator.projectID, args.sessionID); + const response = navigator.protocol === "v1" + ? await navigator.legacyClient(session.location.directory).session.abort({ path: { id: args.sessionID } }) + : await client.v2.session.interrupt({ sessionID: args.sessionID }); if (response.error !== undefined) failResponse(response.error, `OpenCode interrupt for session ${args.sessionID}`); return json({ sessionID: args.sessionID, interrupted: true }); }, @@ -601,7 +693,7 @@ export function createNavigatorTools( const managed = worktrees.find((item) => sameDirectory(item.directory, args.directory)); if (!managed) throw new Error("The requested directory is not a managed OpenCode worktree"); - const active = await activeSessionIDs(client); + const active = await activeSessionIDs(client, navigator); for (const sessionID of active) { const session = await getSession(client, sessionID); if (session.projectID !== navigator.projectID) continue; @@ -626,9 +718,37 @@ export async function checkNavigatorCompatibility(client: NavigatorClient, check await listManagedWorktrees(client, checkout); } -export async function getNavigatorCompatibilityWarning(client: NavigatorClient) { - if (!client.worktree?.list || !client.v2?.session?.create || !client.v2.session.prompt || !client.v2.session.wait) { - return "Kompass Navigator requires OpenCode 1.17.12 or newer with V2 session and worktree APIs"; +export async function detectNavigatorProtocol(client: NavigatorClient): Promise { + try { + const health = responseData(await client.global.health(), "OpenCode health") as { healthy?: boolean }; + if (health.healthy === true) return "v1"; + } catch { + // Continue with the current health probe, matching OpenCode Desktop. + } + try { + const health = responseData(await client.v2.health.get(), "OpenCode V2 health") as { + healthy?: boolean; + pid?: number; + }; + if (typeof health.pid === "number") return "v2"; + if (health.healthy === true) return "v1"; + } catch { + // Desktop defaults to V2 when neither health shape can be identified. + } + return "v2"; +} + +export async function getNavigatorCompatibilityWarning( + client: NavigatorClient, + protocol: NavigatorProtocol, + legacyClient?: NavigatorLegacyClient, +) { + const v2Metadata = hasMethods(client.worktree, ["list"]) && hasMethods(client.v2?.session, ["list", "get"]); + const compatible = protocol === "v1" + ? v2Metadata && hasMethods(legacyClient?.session, ["create", "promptAsync", "status", "messages", "abort"]) + : v2Metadata && hasMethods(client.v2.session, ["create", "messages", "prompt", "active", "interrupt"]); + if (!compatible) { + return `Kompass Navigator requires OpenCode 1.17.12 or newer with ${protocol.toUpperCase()} session and worktree APIs`; } if (!client.global?.health) return; try { @@ -641,6 +761,30 @@ export async function getNavigatorCompatibilityWarning(client: NavigatorClient) } } +function hasMethods(value: unknown, names: string[]) { + if (!value || typeof value !== "object") return false; + return names.every((name) => typeof Reflect.get(value, name) === "function"); +} + +function waitFor(timeoutMs: number, signal: AbortSignal) { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason ?? new Error("Navigator wait aborted")); + return; + } + const timer = setTimeout(done, timeoutMs); + signal.addEventListener("abort", aborted, { once: true }); + function done() { + signal.removeEventListener("abort", aborted); + resolve(); + } + function aborted() { + clearTimeout(timer); + reject(signal.reason ?? new Error("Navigator wait aborted")); + } + }); +} + function compareVersions(left: string, right: string) { const a = left.split(".").map((value) => Number.parseInt(value, 10) || 0); const b = right.split(".").map((value) => Number.parseInt(value, 10) || 0); diff --git a/packages/opencode/test/navigator.test.ts b/packages/opencode/test/navigator.test.ts index 926cb93..e1e0edf 100644 --- a/packages/opencode/test/navigator.test.ts +++ b/packages/opencode/test/navigator.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { describe, test } from "node:test"; -import { createNavigatorTools, getNavigatorCompatibilityWarning } from "../navigator.ts"; +import { createNavigatorTools, detectNavigatorProtocol, getNavigatorCompatibilityWarning } from "../navigator.ts"; const navigatorConfig = { enabled: true, @@ -47,21 +47,31 @@ function createClient(overrides: Record = {}) { interrupt: async () => response(undefined), }, }, + session: { + status: async () => response({}), + create: async () => response(session("created")), + promptAsync: async () => response(undefined), + abort: async () => response(true), + messages: async () => ({ ...response([]), response: new Response() }), + }, }; for (const [key, value] of Object.entries(overrides)) { if (key === "worktree") Object.assign(client.worktree, value); else if (key === "session") Object.assign(client.v2.session, value); + else if (key === "legacySession") Object.assign(client.session, value); else client[key] = value; } return client; } -function tools(client = createClient(), config = navigatorConfig) { +function tools(client = createClient(), config = navigatorConfig, protocol: "v1" | "v2" = "v2") { return createNavigatorTools({ client: client as never, + legacyClient: () => client as never, config, projectID: "project-1", checkout: "/repo", + protocol, }); } @@ -70,6 +80,19 @@ function context(sessionID = "caller", abort = new AbortController().signal) { } describe("Kompass Navigator", () => { + test("matches Desktop protocol detection", async () => { + const legacy = createClient({ + global: { health: async () => response({ healthy: true }) }, + }); + assert.equal(await detectNavigatorProtocol(legacy as never), "v1"); + + const current = createClient({ + global: { health: async () => { throw new Error("not found"); } }, + }); + current.v2.health = { get: async () => response({ pid: 123 }) }; + assert.equal(await detectNavigatorProtocol(current as never), "v2"); + }); + test("lists the checkout and managed worktrees", async () => { const output = JSON.parse(await (tools().worktree_list as any).execute({}, context())); assert.deepEqual(output.checkout, { directory: "/repo" }); @@ -147,6 +170,39 @@ describe("Kompass Navigator", () => { assert.equal(prompts[0].prompt.text, "implement it"); }); + test("uses one legacy transcript path when Desktop selects V1", async () => { + const prompts: any[] = []; + const client = createClient({ + legacySession: { + promptAsync: async (args: any) => { + prompts.push(args); + return response(undefined); + }, + messages: async () => ({ + ...response([{ + info: { + id: "user-1", + sessionID: "session-1", + role: "user", + time: { created: 1 }, + agent: "build", + model: { providerID: "provider", modelID: "model" }, + }, + parts: [{ id: "part-1", sessionID: "session-1", messageID: "user-1", type: "text", text: "visible" }], + }]), + response: new Response(), + }), + }, + }); + const navigator = tools(client, navigatorConfig, "v1"); + + await (navigator.session_send as any).execute({ sessionID: "session-1", prompt: "follow up" }, context()); + const output = JSON.parse(await (navigator.session_read as any).execute({ sessionID: "session-1" }, context())); + + assert.equal(prompts[0].body.parts[0].text, "follow up"); + assert.equal(output.messages[0].items[0].text, "visible"); + }); + test("returns new worktree details", async () => { const output = JSON.parse(await (tools().session_create as any).execute({ prompt: "implement it", @@ -213,12 +269,11 @@ describe("Kompass Navigator", () => { ); }); - test("sends steer by default, supports queue, and interrupts through the SDK", async () => { + test("sends steer and interrupts through V2", async () => { const prompts: any[] = []; const interrupts: any[] = []; const client = createClient({ session: { - active: async () => response({ data: { "session-1": { type: "running" } } }), prompt: async (args: any) => { prompts.push(args); return response({ data: { sessionID: args.sessionID } }); @@ -234,41 +289,47 @@ describe("Kompass Navigator", () => { { sessionID: "session-1", prompt: "steer" }, context(), ); - await (navigator.session_send as any).execute( - { sessionID: "session-1", prompt: "later", delivery: "queue" }, - context(), - ); await (navigator.session_interrupt as any).execute( { sessionID: "session-1" }, context(), ); assert.equal(prompts[0].delivery, "steer"); - assert.equal(prompts[1].delivery, "queue"); assert.deepEqual(interrupts, [{ sessionID: "session-1" }]); }); - test("wait returns the first completion and aborts losing waits", async () => { - const aborted: string[] = []; + test("wait polls V2 active sessions until the first completion", async () => { + let activeCalls = 0; const client = createClient({ session: { - active: async () => response({ data: { one: { type: "running" }, two: { type: "running" } } }), - wait: ({ sessionID }: any, options: any) => new Promise((resolve, reject) => { - options.signal.addEventListener("abort", () => { - aborted.push(sessionID); - reject(new DOMException("Aborted", "AbortError")); - }); - if (sessionID === "one") setTimeout(() => resolve(response(undefined)), 5); - }), + active: async () => response({ data: activeCalls++ === 0 + ? { one: { type: "running" }, two: { type: "running" } } + : { two: { type: "running" } } }), + wait: async () => { throw new Error("Unavailable V2 wait must not be used"); }, }, }); const output = JSON.parse(await (tools(client).session_wait as any).execute({ targets: [{ sessionID: "one" }, { sessionID: "two" }], - timeoutMs: 100, + timeoutMs: 500, }, context())); assert.equal(output.status, "completed"); assert.equal(output.completed.session.sessionID, "one"); - assert.ok(aborted.includes("two")); + }); + + test("wait stops active-session polling when its invocation is cancelled", async () => { + const controller = new AbortController(); + const client = createClient({ + session: { + wait: async () => { throw new Error("V2 wait must not be used"); }, + active: async () => response({ data: { one: { type: "running" } } }), + }, + }); + const waiting = (tools(client).session_wait as any).execute({ + targets: [{ sessionID: "one" }], + timeoutMs: 500, + }, context("caller", controller.signal)); + controller.abort(new Error("cancelled by caller")); + await assert.rejects(waiting, /cancelled by caller/); }); test("immediate wait returns an active snapshot", async () => { @@ -358,11 +419,11 @@ describe("Kompass Navigator", () => { global: { health: async () => response({ version: "1.17.11" }) }, }); assert.match( - await getNavigatorCompatibilityWarning(client as never) ?? "", + await getNavigatorCompatibilityWarning(client as never, "v2") ?? "", /requires OpenCode 1\.17\.12 or newer/, ); client.global.health = async () => response({ version: "1.17.12" }); - assert.equal(await getNavigatorCompatibilityWarning(client as never), undefined); + assert.equal(await getNavigatorCompatibilityWarning(client as never, "v2"), undefined); }); }); diff --git a/packages/opencode/test/tool-registration.test.ts b/packages/opencode/test/tool-registration.test.ts index c0397aa..7094b16 100644 --- a/packages/opencode/test/tool-registration.test.ts +++ b/packages/opencode/test/tool-registration.test.ts @@ -137,7 +137,9 @@ describe("createOpenCodeTools", () => { }; const tools = await createOpenCodeTools(createMockClient() as never, process.cwd(), { client: navigatorClient as never, + legacyClient: () => createMockClient() as never, projectID: "project-1", + protocol: "v2", }); assert.ok(tools.kompass_session_create); assert.ok(tools.kompass_worktree_list); @@ -154,7 +156,9 @@ describe("createOpenCodeTools", () => { }`); const tools = await createOpenCodeTools(createMockClient() as never, tempDir, { client: {} as never, + legacyClient: () => createMockClient() as never, projectID: "project-1", + protocol: "v2", }); assert.equal(tools.kompass_session_create, undefined); @@ -184,7 +188,9 @@ describe("createOpenCodeTools", () => { }; const tools = await createOpenCodeTools(createMockClient() as never, tempDir, { client: navigatorClient as never, + legacyClient: () => createMockClient() as never, projectID: "project-1", + protocol: "v2", }); assert.ok(tools.kompass_worktree_list); diff --git a/packages/web/src/content/docs/docs/adapters/opencode.mdx b/packages/web/src/content/docs/docs/adapters/opencode.mdx index 95ae6a0..4a5806a 100644 --- a/packages/web/src/content/docs/docs/adapters/opencode.mdx +++ b/packages/web/src/content/docs/docs/adapters/opencode.mdx @@ -33,7 +33,7 @@ You can use overrides to: ## Kompass Navigator -Navigator uses OpenCode's native server and SDK to create, inspect, continue, wait for, and interrupt sessions in the current checkout or managed Git worktrees. It requires OpenCode `1.17.12` or newer and is enabled by default. +Navigator follows OpenCode Desktop's protocol detection so session creation, prompts, reads, status, and interrupts stay on the same compatible API in the current checkout or managed Git worktrees. Until OpenCode implements V2 wait, Navigator waits by polling the active-session API locally. It requires OpenCode `1.17.12` or newer and is enabled by default. ```jsonc { diff --git a/packages/web/src/content/docs/docs/reference/tools/index.mdx b/packages/web/src/content/docs/docs/reference/tools/index.mdx index 7bec614..b4271e0 100644 --- a/packages/web/src/content/docs/docs/reference/tools/index.mdx +++ b/packages/web/src/content/docs/docs/reference/tools/index.mdx @@ -70,11 +70,11 @@ Reads a bounded page of recent projected messages. Tool outputs are excluded by ### `session_send` -Sends a follow-up with native `steer` delivery by default or queues it until the session would otherwise become idle. +Sends a steered follow-up through the same session protocol selected by OpenCode Desktop. ### `session_wait` -Waits for the first of up to eight sessions to become idle, aborts losing waits, and returns bounded recent output. A zero timeout returns an immediate snapshot. +Polls the active-session API for the detected protocol until the first of up to eight sessions becomes idle, then returns bounded recent output. A zero timeout returns an immediate snapshot. ### `session_interrupt` diff --git a/packages/web/src/content/docs/docs/reference/tools/navigator.mdx b/packages/web/src/content/docs/docs/reference/tools/navigator.mdx index aef4f52..6a0c48b 100644 --- a/packages/web/src/content/docs/docs/reference/tools/navigator.mdx +++ b/packages/web/src/content/docs/docs/reference/tools/navigator.mdx @@ -8,11 +8,11 @@ Navigator is enabled by default in the OpenCode adapter. Default runtime names u | Logical tool | Purpose | | --- | --- | | `worktree_list` | List the checkout and native managed worktrees. | -| `session_create` | Create a V2 session in the checkout, an existing worktree, or a newly created worktree and admit its first prompt asynchronously. | +| `session_create` | Create a native session in the checkout, an existing worktree, or a newly created worktree and admit its first prompt asynchronously. | | `session_list` | List paginated sessions filtered to the current project. | | `session_read` | Read a bounded page of recent messages with optional tool outputs. | -| `session_send` | Steer an active session or queue work for its next idle point. | -| `session_wait` | Wait for the first of one to eight sessions to become idle. A zero timeout returns an immediate snapshot. | +| `session_send` | Send a steered follow-up through the detected session API. | +| `session_wait` | Poll the detected active-session API until the first of one to eight sessions becomes idle. A zero timeout returns an immediate snapshot. | | `session_interrupt` | Interrupt another session's active execution. | | `worktree_remove` | Remove an idle native managed worktree without force. | @@ -22,6 +22,7 @@ Navigator is enabled by default in the OpenCode adapter. Default runtime names u - Existing-worktree paths must appear in OpenCode's native worktree list. - Send, wait, and interrupt reject the calling session. - Reads default to ten recent messages, exclude tool outputs, and enforce configured per-item and total character limits. +- Navigator uses the same V1-or-V2 protocol choice as OpenCode Desktop so created sessions remain visible there. - Worktree removal rejects the main checkout, unmanaged worktrees, and worktrees containing active sessions. - Partial creation errors identify resources already created and never automatically delete them. From 0659340b5363ff860b4a2b43365055b85bd4c19f Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Sun, 26 Jul 2026 22:33:10 -0300 Subject: [PATCH 4/8] fix: preserve legacy Navigator tool errors --- packages/opencode/navigator.ts | 2 +- packages/opencode/test/navigator.test.ts | 42 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/opencode/navigator.ts b/packages/opencode/navigator.ts index 8a1ef67..7850834 100644 --- a/packages/opencode/navigator.ts +++ b/packages/opencode/navigator.ts @@ -162,7 +162,7 @@ function projectLegacyMessages(messages: LegacySessionMessage[]): SessionMessage input: part.state.input, structured: {}, content: [], - error: { name: "UnknownError", data: { message: part.state.error } }, + error: { type: "unknown", message: part.state.error }, }; content.push({ id: part.id, diff --git a/packages/opencode/test/navigator.test.ts b/packages/opencode/test/navigator.test.ts index e1e0edf..801c81e 100644 --- a/packages/opencode/test/navigator.test.ts +++ b/packages/opencode/test/navigator.test.ts @@ -203,6 +203,48 @@ describe("Kompass Navigator", () => { assert.equal(output.messages[0].items[0].text, "visible"); }); + test("preserves legacy errored tool output text", async () => { + const client = createClient({ + legacySession: { + messages: async () => ({ + ...response([{ + info: { + id: "assistant-1", + sessionID: "session-1", + role: "assistant", + time: { created: 1, completed: 2 }, + mode: "build", + providerID: "provider", + modelID: "model", + }, + parts: [{ + id: "tool-1", + sessionID: "session-1", + messageID: "assistant-1", + type: "tool", + tool: "bash", + state: { + status: "error", + input: { command: "false" }, + error: "command failed", + time: { start: 1, end: 2 }, + }, + }], + }]), + response: new Response(), + }), + }, + }); + + const output = JSON.parse(await (tools(client, navigatorConfig, "v1").session_read as any).execute({ + sessionID: "session-1", + includeOutputs: true, + }, context())); + + assert.equal(output.messages[0].items[0].status, "error"); + assert.equal(output.messages[0].items[0].output, "command failed"); + }); + test("returns new worktree details", async () => { const output = JSON.parse(await (tools().session_create as any).execute({ prompt: "implement it", From afdc00f6a6cf25d01fbd748ad788d96d64edef87 Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Mon, 27 Jul 2026 10:08:16 -0300 Subject: [PATCH 5/8] feat: allow Navigator send context switches - add optional agent and model overrides to session_send - map V1 sends to promptAsync agent/model fields - switch V2 session agent or model before steering prompts - document wait timeout defaults and caps - cover V2 context switching in Navigator tests --- README.md | 2 +- packages/opencode/README.md | 2 +- packages/opencode/navigator.ts | 29 +++++++++++++++++-- packages/opencode/test/navigator.test.ts | 26 +++++++++++++++-- .../content/docs/docs/adapters/opencode.mdx | 2 +- .../docs/docs/reference/tools/index.mdx | 4 +-- .../docs/docs/reference/tools/navigator.mdx | 4 +-- 7 files changed, 57 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index d6c15a6..fabea6c 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ The default runtime names are `kompass_worktree_list`, `kompass_session_create`, Set `adapters.opencode.navigator.enabled` to `false` to disable all Navigator tools. -Navigator accepts only sessions from the current OpenCode project and only worktrees returned by OpenCode. It rejects self-targeting lifecycle calls, arbitrary directories, main-checkout removal, unmanaged worktrees, and removal while a worktree has active sessions. It never force-removes or automatically cleans up resources after a partial failure. +Navigator accepts only sessions from the current OpenCode project and only worktrees returned by OpenCode. It rejects self-targeting lifecycle calls, arbitrary directories, main-checkout removal, unmanaged worktrees, and removal while a worktree has active sessions. `session_send` can switch the target session's agent or model before admitting a steered prompt when the detected OpenCode protocol supports it. `session_wait` defaults to `maxWaitMs`, caps requested timeouts at `maxWaitMs`, and treats `timeoutMs: 0` as an immediate snapshot. Navigator never force-removes or automatically cleans up resources after a partial failure. ## Workspace diff --git a/packages/opencode/README.md b/packages/opencode/README.md index d6c15a6..fabea6c 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -78,7 +78,7 @@ The default runtime names are `kompass_worktree_list`, `kompass_session_create`, Set `adapters.opencode.navigator.enabled` to `false` to disable all Navigator tools. -Navigator accepts only sessions from the current OpenCode project and only worktrees returned by OpenCode. It rejects self-targeting lifecycle calls, arbitrary directories, main-checkout removal, unmanaged worktrees, and removal while a worktree has active sessions. It never force-removes or automatically cleans up resources after a partial failure. +Navigator accepts only sessions from the current OpenCode project and only worktrees returned by OpenCode. It rejects self-targeting lifecycle calls, arbitrary directories, main-checkout removal, unmanaged worktrees, and removal while a worktree has active sessions. `session_send` can switch the target session's agent or model before admitting a steered prompt when the detected OpenCode protocol supports it. `session_wait` defaults to `maxWaitMs`, caps requested timeouts at `maxWaitMs`, and treats `timeoutMs: 0` as an immediate snapshot. Navigator never force-removes or automatically cleans up resources after a partial failure. ## Workspace diff --git a/packages/opencode/navigator.ts b/packages/opencode/navigator.ts index 7850834..d17f130 100644 --- a/packages/opencode/navigator.ts +++ b/packages/opencode/navigator.ts @@ -599,10 +599,15 @@ export function createNavigatorTools( }), session_send: tool({ - description: "Steer a prompt for an existing current-project OpenCode session.", + description: "Steer a prompt for an existing current-project OpenCode session, optionally switching agent or model first.", args: { sessionID: tool.schema.string().min(1), prompt: tool.schema.string().min(1), + agent: tool.schema.string().min(1).optional(), + model: tool.schema.object({ + providerID: tool.schema.string().min(1), + modelID: tool.schema.string().min(1), + }).optional(), }, async execute(args, context) { assertNotCallingSession(args.sessionID, context, "send to"); @@ -610,11 +615,29 @@ export function createNavigatorTools( if (navigator.protocol === "v1") { const response = await navigator.legacyClient(session.location.directory).session.promptAsync({ path: { id: args.sessionID }, - body: { parts: [{ type: "text", text: args.prompt }] }, + body: { + parts: [{ type: "text", text: args.prompt }], + ...(args.agent ? { agent: args.agent } : {}), + ...(args.model ? { model: args.model } : {}), + }, }); if (response.error !== undefined) failResponse(response.error, `OpenCode prompt admission for session ${args.sessionID}`); return json({ sessionID: args.sessionID, admitted: true }); } + if (args.agent) { + const response = await client.v2.session.switchAgent({ + sessionID: args.sessionID, + agent: args.agent, + }); + if (response.error !== undefined) failResponse(response.error, `OpenCode agent switch for session ${args.sessionID}`); + } + if (args.model) { + const response = await client.v2.session.switchModel({ + sessionID: args.sessionID, + model: { providerID: args.model.providerID, id: args.model.modelID }, + }); + if (response.error !== undefined) failResponse(response.error, `OpenCode model switch for session ${args.sessionID}`); + } const admitted = envelopeData(await client.v2.session.prompt({ sessionID: args.sessionID, prompt: { text: args.prompt }, @@ -746,7 +769,7 @@ export async function getNavigatorCompatibilityWarning( const v2Metadata = hasMethods(client.worktree, ["list"]) && hasMethods(client.v2?.session, ["list", "get"]); const compatible = protocol === "v1" ? v2Metadata && hasMethods(legacyClient?.session, ["create", "promptAsync", "status", "messages", "abort"]) - : v2Metadata && hasMethods(client.v2.session, ["create", "messages", "prompt", "active", "interrupt"]); + : v2Metadata && hasMethods(client.v2.session, ["create", "messages", "prompt", "switchAgent", "switchModel", "active", "interrupt"]); if (!compatible) { return `Kompass Navigator requires OpenCode 1.17.12 or newer with ${protocol.toUpperCase()} session and worktree APIs`; } diff --git a/packages/opencode/test/navigator.test.ts b/packages/opencode/test/navigator.test.ts index 801c81e..7a431da 100644 --- a/packages/opencode/test/navigator.test.ts +++ b/packages/opencode/test/navigator.test.ts @@ -43,6 +43,8 @@ function createClient(overrides: Record = {}) { messages: async () => response({ data: [], cursor: {} }), create: async ({ location }: any) => response({ data: session("created", location.directory) }), prompt: async ({ sessionID }: any) => response({ data: { sessionID } }), + switchAgent: async () => response(undefined), + switchModel: async () => response(undefined), wait: async () => response(undefined), interrupt: async () => response(undefined), }, @@ -311,15 +313,25 @@ describe("Kompass Navigator", () => { ); }); - test("sends steer and interrupts through V2", async () => { + test("switches context, sends steer, and interrupts through V2", async () => { const prompts: any[] = []; const interrupts: any[] = []; + const agentSwitches: any[] = []; + const modelSwitches: any[] = []; const client = createClient({ session: { prompt: async (args: any) => { prompts.push(args); return response({ data: { sessionID: args.sessionID } }); }, + switchAgent: async (args: any) => { + agentSwitches.push(args); + return response(undefined); + }, + switchModel: async (args: any) => { + modelSwitches.push(args); + return response(undefined); + }, interrupt: async (args: any) => { interrupts.push(args); return response(undefined); @@ -328,7 +340,12 @@ describe("Kompass Navigator", () => { }); const navigator = tools(client); await (navigator.session_send as any).execute( - { sessionID: "session-1", prompt: "steer" }, + { + sessionID: "session-1", + prompt: "steer", + agent: "worker", + model: { providerID: "openai", modelID: "gpt-5.5" }, + }, context(), ); await (navigator.session_interrupt as any).execute( @@ -336,6 +353,11 @@ describe("Kompass Navigator", () => { context(), ); + assert.deepEqual(agentSwitches, [{ sessionID: "session-1", agent: "worker" }]); + assert.deepEqual(modelSwitches, [{ + sessionID: "session-1", + model: { providerID: "openai", id: "gpt-5.5" }, + }]); assert.equal(prompts[0].delivery, "steer"); assert.deepEqual(interrupts, [{ sessionID: "session-1" }]); }); diff --git a/packages/web/src/content/docs/docs/adapters/opencode.mdx b/packages/web/src/content/docs/docs/adapters/opencode.mdx index 4a5806a..2a48cdc 100644 --- a/packages/web/src/content/docs/docs/adapters/opencode.mdx +++ b/packages/web/src/content/docs/docs/adapters/opencode.mdx @@ -57,7 +57,7 @@ Navigator follows OpenCode Desktop's protocol detection so session creation, pro Navigator registers all eight logical tools unless the feature or an individual tool is disabled. Default names receive the `kompass_` prefix; configured aliases such as `delegate_session` are registered exactly. -Navigator is scoped to the current project. Session IDs are ownership-checked, directories must be native managed worktrees, and lifecycle calls cannot target the calling session. Removal rejects the checkout, unmanaged worktrees, and worktrees containing active sessions. Navigator does not force removal, merge branches, create pull requests, or maintain a separate job database. +Navigator is scoped to the current project. Session IDs are ownership-checked, directories must be native managed worktrees, and lifecycle calls cannot target the calling session. `session_send` can switch the target session's agent or model before admitting a steered prompt. Waits default to `maxWaitMs`, cap requested timeouts at `maxWaitMs`, and use `timeoutMs: 0` for an immediate snapshot. Removal rejects the checkout, unmanaged worktrees, and worktrees containing active sessions. Navigator does not force removal, merge branches, create pull requests, or maintain a separate job database. ## Agent model diff --git a/packages/web/src/content/docs/docs/reference/tools/index.mdx b/packages/web/src/content/docs/docs/reference/tools/index.mdx index b4271e0..de223c5 100644 --- a/packages/web/src/content/docs/docs/reference/tools/index.mdx +++ b/packages/web/src/content/docs/docs/reference/tools/index.mdx @@ -70,11 +70,11 @@ Reads a bounded page of recent projected messages. Tool outputs are excluded by ### `session_send` -Sends a steered follow-up through the same session protocol selected by OpenCode Desktop. +Sends a steered follow-up through the same session protocol selected by OpenCode Desktop, optionally switching the target agent or model first. ### `session_wait` -Polls the active-session API for the detected protocol until the first of up to eight sessions becomes idle, then returns bounded recent output. A zero timeout returns an immediate snapshot. +Polls the active-session API for the detected protocol until the first of up to eight sessions becomes idle, then returns bounded recent output. The timeout defaults to configured `maxWaitMs`, is capped by `maxWaitMs`, and `0` returns an immediate snapshot. ### `session_interrupt` diff --git a/packages/web/src/content/docs/docs/reference/tools/navigator.mdx b/packages/web/src/content/docs/docs/reference/tools/navigator.mdx index 6a0c48b..2d68793 100644 --- a/packages/web/src/content/docs/docs/reference/tools/navigator.mdx +++ b/packages/web/src/content/docs/docs/reference/tools/navigator.mdx @@ -11,8 +11,8 @@ Navigator is enabled by default in the OpenCode adapter. Default runtime names u | `session_create` | Create a native session in the checkout, an existing worktree, or a newly created worktree and admit its first prompt asynchronously. | | `session_list` | List paginated sessions filtered to the current project. | | `session_read` | Read a bounded page of recent messages with optional tool outputs. | -| `session_send` | Send a steered follow-up through the detected session API. | -| `session_wait` | Poll the detected active-session API until the first of one to eight sessions becomes idle. A zero timeout returns an immediate snapshot. | +| `session_send` | Send a steered follow-up through the detected session API, optionally switching agent or model first. | +| `session_wait` | Poll the detected active-session API until the first of one to eight sessions becomes idle. The timeout defaults to and is capped by `maxWaitMs`; zero returns an immediate snapshot. | | `session_interrupt` | Interrupt another session's active execution. | | `worktree_remove` | Remove an idle native managed worktree without force. | From 410d8189ea5f98c232883389970bcf24dc3e1b3b Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Mon, 27 Jul 2026 13:48:29 -0300 Subject: [PATCH 6/8] feat: add Rift Navigator workspace support - register a bundled Rift workspace adapter for OpenCode experimental workspaces - prefer Rift for new Navigator workspaces when available and use Git worktrees as fallback - use Rift copy-all snapshots and default storage to avoid filtered APFS copy failures - document Rift behavior and cover adapter/list/remove paths in tests --- README.md | 2 + bun.lock | 17 +- packages/opencode/README.md | 2 + packages/opencode/index.ts | 2 + packages/opencode/navigator.ts | 132 ++++++++++++--- packages/opencode/package.json | 11 +- packages/opencode/rift-workspace.ts | 154 ++++++++++++++++++ packages/opencode/test/navigator.test.ts | 83 +++++++++- .../opencode/test/tool-registration.test.ts | 49 ++++++ .../content/docs/docs/adapters/opencode.mdx | 2 + .../docs/docs/reference/tools/index.mdx | 2 +- .../docs/docs/reference/tools/navigator.mdx | 2 +- 12 files changed, 413 insertions(+), 45 deletions(-) create mode 100644 packages/opencode/rift-workspace.ts diff --git a/README.md b/README.md index fabea6c..d848731 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ Set `adapters.opencode.navigator.enabled` to `false` to disable all Navigator to Navigator accepts only sessions from the current OpenCode project and only worktrees returned by OpenCode. It rejects self-targeting lifecycle calls, arbitrary directories, main-checkout removal, unmanaged worktrees, and removal while a worktree has active sessions. `session_send` can switch the target session's agent or model before admitting a steered prompt when the detected OpenCode protocol supports it. `session_wait` defaults to `maxWaitMs`, caps requested timeouts at `maxWaitMs`, and treats `timeoutMs: 0` as an immediate snapshot. Navigator never force-removes or automatically cleans up resources after a partial failure. +When OpenCode exposes experimental workspace adapters, Kompass registers a `rift` workspace adapter backed by its bundled `rift-snapshot` dependency. Navigator automatically uses that adapter for `new_worktree` sessions when no `startCommand` is requested, falling back to Git worktrees otherwise. + ## Workspace This repository is the Kompass development workspace. diff --git a/bun.lock b/bun.lock index 7365dbe..bfa0525 100644 --- a/bun.lock +++ b/bun.lock @@ -19,14 +19,17 @@ "packages/opencode": { "name": "@kompassdev/opencode", "version": "0.16.1", + "dependencies": { + "rift-snapshot": "^0.0.10", + }, "devDependencies": { - "@opencode-ai/plugin": "^1.17.12", - "@opencode-ai/sdk": "^1.17.12", + "@opencode-ai/plugin": "^1.18.7", + "@opencode-ai/sdk": "^1.18.7", "yaml": "^2.8.2", }, "peerDependencies": { - "@opencode-ai/plugin": "^1.17.12", - "@opencode-ai/sdk": "^1.17.12", + "@opencode-ai/plugin": "^1.18.7", + "@opencode-ai/sdk": "^1.18.7", }, }, "packages/web": { @@ -229,9 +232,9 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.5", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.5", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-o1loQw5lh3zK7dgTN25Zh4tK+bW7BdszyDwdSumj0ahaR1lXWjYjVbAZuOQxeEQfkr266cqNi2x8UrrlkI0n7A=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.7", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.7", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-kXm5hqur5JiF/xJPaQYkuNn7vSl4ya09YgL/pIvIrggWCLFsfo1gXqNbONRcGW7m5Ih2k6kgMXFyjB9or1+dEg=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.5", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-7KgMvP5/1oxbhHj6kYBtPSTEdFKYpUeEYOzBTKdzSaRpapUpFFdn6Hkus3rr0rljO0kukWZIgRd3DrVBwTULGA=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.7", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-5txVELvCxzC3O/MYnRKdTvBN3TqQjE3PaEks3kDRA/moWzMSUhdm15xCth/Mz/NPYyGTn2u4IdScdV9vLTfLRg=="], "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], @@ -913,6 +916,8 @@ "retext-stringify": ["retext-stringify@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unified": "^11.0.0" } }, "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA=="], + "rift-snapshot": ["rift-snapshot@0.0.10", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "rift": "bin/rift.js" } }, "sha512-A10cjyHqylhvD+Qu7gu/oCZ7z7euhsDV1K3Uz0byATct2Vt6IYvGDbuRcE/Hq7RrqBOGM8STLeFm8EPpTBYKcw=="], + "rollup": ["rollup@4.60.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.0", "@rollup/rollup-android-arm64": "4.60.0", "@rollup/rollup-darwin-arm64": "4.60.0", "@rollup/rollup-darwin-x64": "4.60.0", "@rollup/rollup-freebsd-arm64": "4.60.0", "@rollup/rollup-freebsd-x64": "4.60.0", "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", "@rollup/rollup-linux-arm-musleabihf": "4.60.0", "@rollup/rollup-linux-arm64-gnu": "4.60.0", "@rollup/rollup-linux-arm64-musl": "4.60.0", "@rollup/rollup-linux-loong64-gnu": "4.60.0", "@rollup/rollup-linux-loong64-musl": "4.60.0", "@rollup/rollup-linux-ppc64-gnu": "4.60.0", "@rollup/rollup-linux-ppc64-musl": "4.60.0", "@rollup/rollup-linux-riscv64-gnu": "4.60.0", "@rollup/rollup-linux-riscv64-musl": "4.60.0", "@rollup/rollup-linux-s390x-gnu": "4.60.0", "@rollup/rollup-linux-x64-gnu": "4.60.0", "@rollup/rollup-linux-x64-musl": "4.60.0", "@rollup/rollup-openbsd-x64": "4.60.0", "@rollup/rollup-openharmony-arm64": "4.60.0", "@rollup/rollup-win32-arm64-msvc": "4.60.0", "@rollup/rollup-win32-ia32-msvc": "4.60.0", "@rollup/rollup-win32-x64-gnu": "4.60.0", "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ=="], "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], diff --git a/packages/opencode/README.md b/packages/opencode/README.md index fabea6c..d848731 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -80,6 +80,8 @@ Set `adapters.opencode.navigator.enabled` to `false` to disable all Navigator to Navigator accepts only sessions from the current OpenCode project and only worktrees returned by OpenCode. It rejects self-targeting lifecycle calls, arbitrary directories, main-checkout removal, unmanaged worktrees, and removal while a worktree has active sessions. `session_send` can switch the target session's agent or model before admitting a steered prompt when the detected OpenCode protocol supports it. `session_wait` defaults to `maxWaitMs`, caps requested timeouts at `maxWaitMs`, and treats `timeoutMs: 0` as an immediate snapshot. Navigator never force-removes or automatically cleans up resources after a partial failure. +When OpenCode exposes experimental workspace adapters, Kompass registers a `rift` workspace adapter backed by its bundled `rift-snapshot` dependency. Navigator automatically uses that adapter for `new_worktree` sessions when no `startCommand` is requested, falling back to Git worktrees otherwise. + ## Workspace This repository is the Kompass development workspace. diff --git a/packages/opencode/index.ts b/packages/opencode/index.ts index be6c929..32932ca 100644 --- a/packages/opencode/index.ts +++ b/packages/opencode/index.ts @@ -21,6 +21,7 @@ import { loadMergedKompassConfig } from "./cache.ts"; import { applyAgentsConfig, applyCommandsConfig } from "./config.ts"; import { createPluginLogger, getErrorDetails, type PluginLogger } from "./logging.ts"; import { createNavigatorTools, detectNavigatorProtocol, getNavigatorCompatibilityWarning } from "./navigator.ts"; +import { registerRiftWorkspaceAdapter } from "./rift-workspace.ts"; import { getConfiguredOpenCodeToolName, } from "./tool-names.ts"; @@ -427,6 +428,7 @@ export const OpenCodeCompassPlugin: Plugin = async (input: PluginInput) => { } const tools = await createToolsSafely(); + await registerRiftWorkspaceAdapter(input as never, logger); return { tool: tools, diff --git a/packages/opencode/navigator.ts b/packages/opencode/navigator.ts index d17f130..1e1db09 100644 --- a/packages/opencode/navigator.ts +++ b/packages/opencode/navigator.ts @@ -20,6 +20,23 @@ export type NavigatorLegacyClient = Pick; type NavigatorProtocol = "v1" | "v2"; type LegacySessionMessage = { info: LegacyMessage; parts: LegacyPart[] }; +type WorkspaceApi = { + workspace?: { + adapter?: { list(args?: { directory?: string }): Promise<{ data?: Array<{ type: string }>; error?: unknown }> }; + create(args?: { directory?: string; type?: string; extra?: unknown }): Promise<{ data?: NativeWorkspace; error?: unknown }>; + list(args?: { directory?: string }): Promise<{ data?: NativeWorkspace[]; error?: unknown }>; + remove(args: { id: string; directory?: string }): Promise<{ data?: unknown; error?: unknown }>; + syncList?(args?: { directory?: string }): Promise<{ data?: unknown; error?: unknown }>; + }; +}; +type NativeWorkspace = { + id: string; + type: string; + name?: string | null; + branch?: string | null; + directory?: string | null; + projectID?: string; +}; export interface SessionSummary { sessionID: string; @@ -70,7 +87,7 @@ type NavigatorContext = { legacyClient: (directory: string) => NavigatorLegacyClient; }; -type NativeWorktree = { directory: string; name: string; branch?: string }; +type NativeWorktree = { directory: string; name: string; branch?: string; id?: string; type: "worktree" | "rift" }; function failResponse(error: unknown, operation: string): never { const message = error instanceof Error @@ -101,17 +118,57 @@ function sameDirectory(left: string, right: string) { function normalizeWorktree(value: string | Worktree): NativeWorktree { if (typeof value === "string") { - return { directory: value, name: path.basename(value) }; + return { directory: value, name: path.basename(value), type: "worktree" }; } - return { directory: value.directory, name: value.name, ...(value.branch ? { branch: value.branch } : {}) }; + return { directory: value.directory, name: value.name, ...(value.branch ? { branch: value.branch } : {}), type: "worktree" }; } -async function listManagedWorktrees(client: NavigatorClient, checkout: string) { +function workspaceApi(client: NavigatorClient): WorkspaceApi | undefined { + const candidate = client as unknown as { experimental?: WorkspaceApi; v2?: { experimental?: WorkspaceApi } }; + return candidate.experimental ?? candidate.v2?.experimental; +} + +async function hasRiftAdapter(client: NavigatorClient, checkout: string) { + const api = workspaceApi(client); + if (!api?.workspace?.adapter?.list || !api.workspace.create) return false; + const adapters = responseData(await api.workspace.adapter.list({ directory: checkout }), "OpenCode workspace adapter list"); + return adapters.some((adapter) => adapter.type === "rift"); +} + +function normalizeRiftWorkspace(workspace: NativeWorkspace): NativeWorktree | undefined { + if (workspace.type !== "rift" || !workspace.directory) return; + return { + id: workspace.id, + type: "rift", + directory: workspace.directory, + name: workspace.name || path.basename(workspace.directory), + ...(workspace.branch ? { branch: workspace.branch } : {}), + }; +} + +async function listRiftWorkspaces(client: NavigatorClient, checkout: string, projectID: string) { + const api = workspaceApi(client); + if (!api?.workspace?.list || !api.workspace.adapter?.list) return []; + if (!(await hasRiftAdapter(client, checkout))) return []; + await api.workspace.syncList?.({ directory: checkout }).catch(() => undefined); + const workspaces = responseData(await api.workspace.list({ directory: checkout }), "OpenCode workspace list"); + return workspaces + .filter((workspace) => !workspace.projectID || workspace.projectID === projectID) + .flatMap((workspace) => { + const normalized = normalizeRiftWorkspace(workspace); + return normalized ? [normalized] : []; + }); +} + +async function listManagedWorktrees(client: NavigatorClient, checkout: string, projectID?: string) { const values = responseData( await client.worktree.list({ directory: checkout }), "OpenCode worktree list", ) as Array; - return values.map(normalizeWorktree).filter((item) => !sameDirectory(item.directory, checkout)); + const worktrees = values.map(normalizeWorktree).filter((item) => !sameDirectory(item.directory, checkout)); + if (!projectID) return worktrees; + const rifts = await listRiftWorkspaces(client, checkout, projectID).catch(() => []); + return [...worktrees, ...rifts]; } async function activeSessionIDs(client: NavigatorClient, navigator: NavigatorContext) { @@ -419,7 +476,7 @@ export function createNavigatorTools( async execute() { return json({ checkout: { directory: navigator.checkout }, - worktrees: await listManagedWorktrees(client, navigator.checkout), + worktrees: await listManagedWorktrees(client, navigator.checkout, navigator.projectID), }); }, }), @@ -458,32 +515,45 @@ export function createNavigatorTools( let createdWorktree: NativeWorktree | undefined; if (args.environment.type === "existing_worktree") { const requestedDirectory = args.environment.directory; - const worktrees = await listManagedWorktrees(client, navigator.checkout); + const worktrees = await listManagedWorktrees(client, navigator.checkout, navigator.projectID); const requested = worktrees.find((item) => sameDirectory(item.directory, requestedDirectory)); - if (!requested) throw new Error("The requested directory is not a managed OpenCode worktree for this project"); + if (!requested) throw new Error("The requested directory is not a managed OpenCode workspace for this project"); directory = requested.directory; } else if (args.environment.type === "new_worktree") { - const worktreesBefore = await listManagedWorktrees(client, navigator.checkout); + const worktreesBefore = await listManagedWorktrees(client, navigator.checkout, navigator.projectID); try { - const worktree = responseData( - await client.worktree.create({ + const api = workspaceApi(client); + const useRift = !args.environment.startCommand && await hasRiftAdapter(client, navigator.checkout).catch(() => false); + if (useRift && api?.workspace?.create) { + const workspace = responseData(await api.workspace.create({ directory: navigator.checkout, - worktreeCreateInput: { - ...(args.environment.name ? { name: args.environment.name } : {}), - ...(args.environment.startCommand ? { startCommand: args.environment.startCommand } : {}), - }, - }), - "OpenCode worktree create", - ); - createdWorktree = normalizeWorktree(worktree); + type: "rift", + extra: args.environment.name ? { name: args.environment.name } : undefined, + }), "OpenCode Rift workspace create"); + const normalized = normalizeRiftWorkspace(workspace); + if (!normalized) throw new Error("OpenCode Rift workspace create returned no directory"); + createdWorktree = normalized; + } else { + const worktree = responseData( + await client.worktree.create({ + directory: navigator.checkout, + worktreeCreateInput: { + ...(args.environment.name ? { name: args.environment.name } : {}), + ...(args.environment.startCommand ? { startCommand: args.environment.startCommand } : {}), + }, + }), + "OpenCode worktree create", + ); + createdWorktree = normalizeWorktree(worktree); + } directory = createdWorktree.directory; } catch (error) { - const worktreesAfter = await listManagedWorktrees(client, navigator.checkout).catch(() => []); + const worktreesAfter = await listManagedWorktrees(client, navigator.checkout, navigator.projectID).catch(() => []); const before = new Set(worktreesBefore.map((item) => normalizeDirectory(item.directory))); const partial = worktreesAfter.filter((item) => !before.has(normalizeDirectory(item.directory))); throw new Error( - `Navigator could not create the requested worktree: ${error instanceof Error ? error.message : String(error)}` + - (partial.length ? `. Worktrees created and not removed: ${partial.map((item) => item.directory).join(", ")}` : ""), + `Navigator could not create the requested workspace: ${error instanceof Error ? error.message : String(error)}` + + (partial.length ? `. Workspaces created and not removed: ${partial.map((item) => item.directory).join(", ")}` : ""), ); } } @@ -545,7 +615,7 @@ export function createNavigatorTools( sessionID: session.id, directory, ...(createdWorktree - ? { worktree: { created: true, name: createdWorktree.name, ...(createdWorktree.branch ? { branch: createdWorktree.branch } : {}) } } + ? { worktree: { created: true, type: createdWorktree.type, name: createdWorktree.name, ...(createdWorktree.branch ? { branch: createdWorktree.branch } : {}) } } : {}), }); }, @@ -561,10 +631,10 @@ export function createNavigatorTools( }, async execute(args) { if (args.directory) { - const managed = await listManagedWorktrees(client, navigator.checkout); + const managed = await listManagedWorktrees(client, navigator.checkout, navigator.projectID); const valid = [navigator.checkout, ...managed.map((item) => item.directory)] .some((directory) => sameDirectory(directory, args.directory!)); - if (!valid) throw new Error("The requested directory is not a managed OpenCode worktree for this project"); + if (!valid) throw new Error("The requested directory is not a managed OpenCode workspace for this project"); } const [payload, active] = await Promise.all([ client.v2.session.list({ @@ -712,9 +782,9 @@ export function createNavigatorTools( if (sameDirectory(args.directory, navigator.checkout)) { throw new Error("Navigator refuses to remove the main checkout"); } - const worktrees = await listManagedWorktrees(client, navigator.checkout); + const worktrees = await listManagedWorktrees(client, navigator.checkout, navigator.projectID); const managed = worktrees.find((item) => sameDirectory(item.directory, args.directory)); - if (!managed) throw new Error("The requested directory is not a managed OpenCode worktree"); + if (!managed) throw new Error("The requested directory is not a managed OpenCode workspace"); const active = await activeSessionIDs(client, navigator); for (const sessionID of active) { @@ -724,6 +794,14 @@ export function createNavigatorTools( throw new Error(`Navigator refuses to remove a worktree containing active session ${sessionID}`); } } + if (managed.type === "rift") { + if (!managed.id) throw new Error("The requested Rift workspace is missing an OpenCode workspace id"); + const api = workspaceApi(client); + if (!api?.workspace?.remove) throw new Error("OpenCode workspace removal is unavailable for Rift workspaces"); + const response = await api.workspace.remove({ id: managed.id, directory: navigator.checkout }); + if (response.error !== undefined) failResponse(response.error, `OpenCode Rift workspace removal for ${managed.directory}`); + return json({ removed: true }); + } const removed = responseData( await client.worktree.remove({ directory: navigator.checkout, diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 623de7b..b9dbc21 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -47,12 +47,15 @@ "url": "https://github.com/kompassdev/kompass/issues" }, "peerDependencies": { - "@opencode-ai/plugin": "^1.17.12", - "@opencode-ai/sdk": "^1.17.12" + "@opencode-ai/plugin": "^1.18.7", + "@opencode-ai/sdk": "^1.18.7" + }, + "dependencies": { + "rift-snapshot": "^0.0.10" }, "devDependencies": { - "@opencode-ai/plugin": "^1.17.12", - "@opencode-ai/sdk": "^1.17.12", + "@opencode-ai/plugin": "^1.18.7", + "@opencode-ai/sdk": "^1.18.7", "yaml": "^2.8.2" } } diff --git a/packages/opencode/rift-workspace.ts b/packages/opencode/rift-workspace.ts new file mode 100644 index 0000000..8a33060 --- /dev/null +++ b/packages/opencode/rift-workspace.ts @@ -0,0 +1,154 @@ +import path from "node:path"; + +type WorkspaceInfo = { + id: string; + type: string; + name: string; + branch: string | null; + directory: string | null; + extra: unknown | null; + projectID: string; +}; + +type WorkspaceTarget = { type: "local"; directory: string }; + +type WorkspaceAdapter = { + name: string; + description: string; + configure(config: WorkspaceInfo): WorkspaceInfo | Promise; + create(config: WorkspaceInfo, env: Record, from?: WorkspaceInfo): Promise; + list?(): WorkspaceInfo[] | Promise; + remove(config: WorkspaceInfo): Promise; + target(config: WorkspaceInfo): WorkspaceTarget | Promise; +}; + +type RiftModule = { + init(options?: { at?: string }): null; + create(options?: { from?: string; name?: string; copyAll?: boolean; hooks?: boolean }): string; + remove(options?: { at?: string; all?: false }): void; + list(options?: { of?: string }): string[]; +}; + +type RiftAdapterOptions = { + sourceDirectory: string; + projectID: string; +}; + +type ExperimentalWorkspaceInput = { + experimental_workspace?: { register(type: string, adapter: WorkspaceAdapter): void }; + project?: { id?: string }; + worktree?: string; + directory?: string; +}; + +type Logger = { + info(message: string, extra?: Record): void; + warn(message: string, extra?: Record): void; +}; + +const importModule = new Function("specifier", "return import(specifier)") as (specifier: string) => Promise; + +function slugify(input: string) { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") || "rift"; +} + +function workspaceName(config: WorkspaceInfo) { + const extra = typeof config.extra === "object" && config.extra ? config.extra as { name?: unknown } : {}; + return slugify(typeof extra.name === "string" ? extra.name : config.name || config.id); +} + +function managedRoot(sourceDirectory: string) { + return path.join(path.dirname(sourceDirectory), ".rifts", path.basename(sourceDirectory)); +} + +function workspaceDirectory(sourceDirectory: string, name: string) { + return path.join(managedRoot(sourceDirectory), name); +} + +function requireDirectory(config: WorkspaceInfo) { + if (!config.directory) throw new Error("Rift workspace is missing a directory"); + return config.directory; +} + +export function createRiftWorkspaceAdapter(rift: RiftModule, options: RiftAdapterOptions): WorkspaceAdapter { + const sourceDirectory = path.resolve(options.sourceDirectory); + + return { + name: "Rift", + description: "Create a copy-on-write Rift workspace", + configure(config) { + const name = workspaceName(config); + return { + ...config, + type: "rift", + name, + branch: null, + directory: workspaceDirectory(sourceDirectory, name), + extra: { ...(typeof config.extra === "object" && config.extra ? config.extra : {}), sourceDirectory }, + }; + }, + async create(config, _env, from) { + const source = path.resolve(from?.directory ?? sourceDirectory); + const expected = path.resolve(requireDirectory(config)); + rift.init({ at: source }); + const created = path.resolve(rift.create({ + from: source, + name: config.name, + // Use Rift's APFS full-clone path; the filtered walker can fail on protected macOS xattrs. + copyAll: true, + })); + if (created !== expected) { + throw new Error(`Rift created ${created}, but OpenCode registered ${expected}`); + } + }, + list() { + return rift.list({ of: sourceDirectory }).map((directory) => ({ + id: `rift-${path.basename(directory)}`, + type: "rift", + name: path.basename(directory), + branch: null, + directory, + extra: { sourceDirectory }, + projectID: options.projectID, + })); + }, + async remove(config) { + rift.remove({ at: requireDirectory(config) }); + }, + target(config) { + return { type: "local", directory: requireDirectory(config) }; + }, + }; +} + +export async function registerRiftWorkspaceAdapter(input: ExperimentalWorkspaceInput, logger: Logger) { + const registrar = input.experimental_workspace; + const projectID = input.project?.id; + const sourceDirectory = input.worktree ?? input.directory; + if (!registrar || !projectID || !sourceDirectory) return; + + let rift: RiftModule; + try { + rift = await importModule("rift-snapshot") as RiftModule; + } catch (error) { + logger.info("Rift workspace adapter not registered", { + reason: "Install rift-snapshot to enable the experimental Rift workspace adapter", + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + try { + registrar.register("rift", createRiftWorkspaceAdapter(rift, { sourceDirectory, projectID })); + logger.info("Registered Rift workspace adapter", { type: "rift" }); + } catch (error) { + logger.warn("Rift workspace adapter registration failed", { + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/packages/opencode/test/navigator.test.ts b/packages/opencode/test/navigator.test.ts index 7a431da..65295dc 100644 --- a/packages/opencode/test/navigator.test.ts +++ b/packages/opencode/test/navigator.test.ts @@ -98,7 +98,7 @@ describe("Kompass Navigator", () => { test("lists the checkout and managed worktrees", async () => { const output = JSON.parse(await (tools().worktree_list as any).execute({}, context())); assert.deepEqual(output.checkout, { directory: "/repo" }); - assert.deepEqual(output.worktrees, [{ directory: "/repo-worktree", name: "repo-worktree" }]); + assert.deepEqual(output.worktrees, [{ directory: "/repo-worktree", name: "repo-worktree", type: "worktree" }]); }); test("rejects foreign sessions", async () => { @@ -136,14 +136,14 @@ describe("Kompass Navigator", () => { prompt: "work", environment: { type: "existing_worktree", directory: "/tmp/arbitrary" }, }, context()), - /not a managed OpenCode worktree/, + /not a managed OpenCode workspace/, ); }); test("rejects arbitrary session-list directories", async () => { await assert.rejects( (tools().session_list as any).execute({ directory: "/tmp/arbitrary" }, context()), - /not a managed OpenCode worktree/, + /not a managed OpenCode workspace/, ); }); @@ -252,7 +252,47 @@ describe("Kompass Navigator", () => { prompt: "implement it", environment: { type: "new_worktree", name: "feature" }, }, context())); - assert.deepEqual(output.worktree, { created: true, name: "new", branch: "new" }); + assert.deepEqual(output.worktree, { created: true, type: "worktree", name: "new", branch: "new" }); + }); + + test("prefers Rift workspace adapter for new workspaces when available", async () => { + const workspaceCreates: any[] = []; + const sessionCreates: any[] = []; + const client = createClient({ + session: { + create: async (args: any) => { + sessionCreates.push(args); + return response({ data: session("created", args.location.directory) }); + }, + }, + }); + client.experimental = { + workspace: { + adapter: { list: async () => response([{ type: "worktree" }, { type: "rift" }]) }, + syncList: async () => response(undefined), + list: async () => response([]), + create: async (args: any) => { + workspaceCreates.push(args); + return response({ + id: "wrk_rift", + type: "rift", + name: "parser-fix", + directory: "/repo-rift", + projectID: "project-1", + }); + }, + remove: async () => response(undefined), + }, + }; + + const output = JSON.parse(await (tools(client).session_create as any).execute({ + prompt: "implement it", + environment: { type: "new_worktree", name: "Parser Fix" }, + }, context())); + + assert.deepEqual(workspaceCreates, [{ directory: "/repo", type: "rift", extra: { name: "Parser Fix" } }]); + assert.equal(sessionCreates[0].location.directory, "/repo-rift"); + assert.deepEqual(output.worktree, { created: true, type: "rift", name: "parser-fix" }); }); test("reports resources created before a prompt failure", async () => { @@ -293,7 +333,7 @@ describe("Kompass Navigator", () => { prompt: "implement it", environment: { type: "new_worktree", startCommand: "false" }, }, context()), - /Worktrees created and not removed: \/repo-partial/, + /Workspaces created and not removed: \/repo-partial/, ); }); @@ -438,7 +478,7 @@ describe("Kompass Navigator", () => { ); await assert.rejects( (navigator.worktree_remove as any).execute({ directory: "/other" }, context()), - /not a managed OpenCode worktree/, + /not a managed OpenCode workspace/, ); const activeClient = createClient({ session: { @@ -458,6 +498,37 @@ describe("Kompass Navigator", () => { assert.deepEqual(output, { removed: true }); }); + test("removes managed Rift workspaces through the workspace API", async () => { + const removes: any[] = []; + const client = createClient(); + client.experimental = { + workspace: { + adapter: { list: async () => response([{ type: "rift" }]) }, + syncList: async () => response(undefined), + list: async () => response([{ + id: "wrk_rift", + type: "rift", + name: "parser-fix", + directory: "/repo-rift", + projectID: "project-1", + }]), + create: async () => response(undefined), + remove: async (args: any) => { + removes.push(args); + return response(undefined); + }, + }, + }; + + const output = JSON.parse(await (tools(client).worktree_remove as any).execute( + { directory: "/repo-rift" }, + context(), + )); + + assert.deepEqual(output, { removed: true }); + assert.deepEqual(removes, [{ id: "wrk_rift", directory: "/repo" }]); + }); + test("fails closed when active-session lookup fails during worktree removal", async () => { const client = createClient({ session: { diff --git a/packages/opencode/test/tool-registration.test.ts b/packages/opencode/test/tool-registration.test.ts index 7094b16..62d733c 100644 --- a/packages/opencode/test/tool-registration.test.ts +++ b/packages/opencode/test/tool-registration.test.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { promisify } from "node:util"; import { createOpenCodeTools, OpenCodeCompassPlugin } from "../index.ts"; +import { createRiftWorkspaceAdapter } from "../rift-workspace.ts"; const execFileAsync = promisify(execFile); @@ -127,6 +128,54 @@ async function createTempGitRepo() { } describe("createOpenCodeTools", () => { + test("Rift workspace adapter maps OpenCode workspaces to Rift snapshots", async () => { + const calls: Array<{ name: string; options?: Record }> = []; + const sourceDirectory = path.join(os.tmpdir(), "kompass-rift-source"); + const adapter = createRiftWorkspaceAdapter({ + init: (options) => { + calls.push({ name: "init", options }); + return null; + }, + create: (options) => { + calls.push({ name: "create", options }); + return path.join(path.dirname(sourceDirectory), ".rifts", path.basename(sourceDirectory), options?.name ?? "missing"); + }, + remove: (options) => { + calls.push({ name: "remove", options }); + }, + list: (options) => { + calls.push({ name: "list", options }); + return [path.join(path.dirname(sourceDirectory), ".rifts", path.basename(sourceDirectory), "parser-fix")]; + }, + }, { sourceDirectory, projectID: "project-1" }); + + const configured = await adapter.configure({ + id: "wrk_1", + type: "rift", + name: "Parser Fix", + branch: null, + directory: null, + extra: null, + projectID: "project-1", + }); + await adapter.create(configured, {}); + const listed = await adapter.list?.(); + await adapter.remove(configured); + const target = await adapter.target(configured); + + assert.equal(configured.name, "parser-fix"); + assert.equal(configured.directory, path.join(path.dirname(sourceDirectory), ".rifts", path.basename(sourceDirectory), "parser-fix")); + assert.deepEqual(target, { type: "local", directory: configured.directory }); + assert.equal(listed?.[0]?.type, "rift"); + assert.equal(listed?.[0]?.projectID, "project-1"); + assert.deepEqual(calls.map((call) => call.name), ["init", "create", "list", "remove"]); + assert.deepEqual(calls.find((call) => call.name === "create")?.options, { + from: sourceDirectory, + name: "parser-fix", + copyAll: true, + }); + }); + test("registers Navigator by default", async () => { await withTempHome(async () => { const navigatorClient = { diff --git a/packages/web/src/content/docs/docs/adapters/opencode.mdx b/packages/web/src/content/docs/docs/adapters/opencode.mdx index 2a48cdc..04bb31c 100644 --- a/packages/web/src/content/docs/docs/adapters/opencode.mdx +++ b/packages/web/src/content/docs/docs/adapters/opencode.mdx @@ -59,6 +59,8 @@ Navigator registers all eight logical tools unless the feature or an individual Navigator is scoped to the current project. Session IDs are ownership-checked, directories must be native managed worktrees, and lifecycle calls cannot target the calling session. `session_send` can switch the target session's agent or model before admitting a steered prompt. Waits default to `maxWaitMs`, cap requested timeouts at `maxWaitMs`, and use `timeoutMs: 0` for an immediate snapshot. Removal rejects the checkout, unmanaged worktrees, and worktrees containing active sessions. Navigator does not force removal, merge branches, create pull requests, or maintain a separate job database. +When OpenCode exposes experimental workspace adapters, Kompass registers a `rift` workspace adapter for OpenCode's workspace API using its bundled `rift-snapshot` dependency. Navigator automatically uses Rift for `new_worktree` sessions when no `startCommand` is requested, falling back to Git worktrees otherwise. + ## Agent model Most command execution runs on the bundled `worker` agent. diff --git a/packages/web/src/content/docs/docs/reference/tools/index.mdx b/packages/web/src/content/docs/docs/reference/tools/index.mdx index de223c5..a9c9acb 100644 --- a/packages/web/src/content/docs/docs/reference/tools/index.mdx +++ b/packages/web/src/content/docs/docs/reference/tools/index.mdx @@ -58,7 +58,7 @@ Lists the current checkout explicitly and all native OpenCode-managed worktrees. ### `session_create` -Creates a native session in the checkout, an existing managed worktree, or a newly created worktree; admits its initial prompt and returns immediately. +Creates a native session in the checkout, an existing managed workspace, or a newly created workspace; admits its initial prompt and returns immediately. New workspaces prefer the registered Rift adapter when available and fall back to Git worktrees. ### `session_list` diff --git a/packages/web/src/content/docs/docs/reference/tools/navigator.mdx b/packages/web/src/content/docs/docs/reference/tools/navigator.mdx index 2d68793..ef54167 100644 --- a/packages/web/src/content/docs/docs/reference/tools/navigator.mdx +++ b/packages/web/src/content/docs/docs/reference/tools/navigator.mdx @@ -8,7 +8,7 @@ Navigator is enabled by default in the OpenCode adapter. Default runtime names u | Logical tool | Purpose | | --- | --- | | `worktree_list` | List the checkout and native managed worktrees. | -| `session_create` | Create a native session in the checkout, an existing worktree, or a newly created worktree and admit its first prompt asynchronously. | +| `session_create` | Create a native session in the checkout, an existing managed workspace, or a newly created workspace and admit its first prompt asynchronously. New workspaces prefer Rift when available. | | `session_list` | List paginated sessions filtered to the current project. | | `session_read` | Read a bounded page of recent messages with optional tool outputs. | | `session_send` | Send a steered follow-up through the detected session API, optionally switching agent or model first. | From ba00d8f886d8cb9fbee32d11e0d48be893f0abc4 Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Mon, 27 Jul 2026 17:30:14 -0300 Subject: [PATCH 7/8] fix: verify Rift workspace removal - retry Rift workspace removal once when OpenCode re-discovers the path - fail if Navigator cannot confirm the workspace disappeared - cover stale-list retry behavior in Navigator tests --- packages/opencode/navigator.ts | 16 ++++++++++++---- packages/opencode/test/navigator.test.ts | 21 +++++++++++++-------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/packages/opencode/navigator.ts b/packages/opencode/navigator.ts index 1e1db09..e01fc45 100644 --- a/packages/opencode/navigator.ts +++ b/packages/opencode/navigator.ts @@ -795,12 +795,20 @@ export function createNavigatorTools( } } if (managed.type === "rift") { - if (!managed.id) throw new Error("The requested Rift workspace is missing an OpenCode workspace id"); const api = workspaceApi(client); if (!api?.workspace?.remove) throw new Error("OpenCode workspace removal is unavailable for Rift workspaces"); - const response = await api.workspace.remove({ id: managed.id, directory: navigator.checkout }); - if (response.error !== undefined) failResponse(response.error, `OpenCode Rift workspace removal for ${managed.directory}`); - return json({ removed: true }); + let target = managed; + for (let attempt = 0; attempt < 2; attempt++) { + if (!target.id) throw new Error("The requested Rift workspace is missing an OpenCode workspace id"); + const response = await api.workspace.remove({ id: target.id, directory: navigator.checkout }); + if (response.error !== undefined) failResponse(response.error, `OpenCode Rift workspace removal for ${target.directory}`); + + const remaining = await listRiftWorkspaces(client, navigator.checkout, navigator.projectID).catch(() => []); + const stillPresent = remaining.find((item) => sameDirectory(item.directory, target.directory)); + if (!stillPresent) return json({ removed: true }); + target = stillPresent; + } + throw new Error(`Navigator could not confirm Rift workspace removal for ${managed.directory}`); } const removed = responseData( await client.worktree.remove({ diff --git a/packages/opencode/test/navigator.test.ts b/packages/opencode/test/navigator.test.ts index 65295dc..67eb18a 100644 --- a/packages/opencode/test/navigator.test.ts +++ b/packages/opencode/test/navigator.test.ts @@ -500,18 +500,20 @@ describe("Kompass Navigator", () => { test("removes managed Rift workspaces through the workspace API", async () => { const removes: any[] = []; + let lists = 0; const client = createClient(); + const riftWorkspace = { + id: "wrk_rift", + type: "rift", + name: "parser-fix", + directory: "/repo-rift", + projectID: "project-1", + }; client.experimental = { workspace: { adapter: { list: async () => response([{ type: "rift" }]) }, syncList: async () => response(undefined), - list: async () => response([{ - id: "wrk_rift", - type: "rift", - name: "parser-fix", - directory: "/repo-rift", - projectID: "project-1", - }]), + list: async () => response(++lists < 3 ? [riftWorkspace] : []), create: async () => response(undefined), remove: async (args: any) => { removes.push(args); @@ -526,7 +528,10 @@ describe("Kompass Navigator", () => { )); assert.deepEqual(output, { removed: true }); - assert.deepEqual(removes, [{ id: "wrk_rift", directory: "/repo" }]); + assert.deepEqual(removes, [ + { id: "wrk_rift", directory: "/repo" }, + { id: "wrk_rift", directory: "/repo" }, + ]); }); test("fails closed when active-session lookup fails during worktree removal", async () => { From 27483cc50039be5f2652748f7a7337714c293e96 Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Mon, 27 Jul 2026 17:35:05 -0300 Subject: [PATCH 8/8] fix: include Rift workspaces in V1 Navigator activity --- packages/opencode/navigator.ts | 2 +- packages/opencode/test/navigator.test.ts | 47 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/opencode/navigator.ts b/packages/opencode/navigator.ts index e01fc45..28d37ef 100644 --- a/packages/opencode/navigator.ts +++ b/packages/opencode/navigator.ts @@ -173,7 +173,7 @@ async function listManagedWorktrees(client: NavigatorClient, checkout: string, p async function activeSessionIDs(client: NavigatorClient, navigator: NavigatorContext) { if (navigator.protocol === "v1") { - const worktrees = await listManagedWorktrees(client, navigator.checkout); + const worktrees = await listManagedWorktrees(client, navigator.checkout, navigator.projectID); const directories = [navigator.checkout, ...worktrees.map((item) => item.directory)]; const statuses = await Promise.all(directories.map(async (directory) => responseData( await navigator.legacyClient(directory).session.status(), diff --git a/packages/opencode/test/navigator.test.ts b/packages/opencode/test/navigator.test.ts index 67eb18a..f3817de 100644 --- a/packages/opencode/test/navigator.test.ts +++ b/packages/opencode/test/navigator.test.ts @@ -534,6 +534,53 @@ describe("Kompass Navigator", () => { ]); }); + test("guards active Rift workspaces when polling active sessions through V1", async () => { + const statusDirectories: string[] = []; + const client = createClient({ + session: { + get: async () => response({ data: session("rift-active", "/repo-rift") }), + }, + }); + client.experimental = { + workspace: { + adapter: { list: async () => response([{ type: "rift" }]) }, + syncList: async () => response(undefined), + list: async () => response([{ + id: "wrk_rift", + type: "rift", + name: "parser-fix", + directory: "/repo-rift", + projectID: "project-1", + }]), + create: async () => response(undefined), + remove: async () => response(undefined), + }, + }; + const navigator = createNavigatorTools({ + client: client as never, + legacyClient: (directory: string) => ({ + ...client, + session: { + ...client.session, + status: async () => { + statusDirectories.push(directory); + return response(directory === "/repo-rift" ? { "rift-active": { type: "running" } } : {}); + }, + }, + }) as never, + config: navigatorConfig, + projectID: "project-1", + checkout: "/repo", + protocol: "v1", + }); + + await assert.rejects( + (navigator.worktree_remove as any).execute({ directory: "/repo-rift" }, context()), + /active session rift-active/, + ); + assert.ok(statusDirectories.includes("/repo-rift")); + }); + test("fails closed when active-session lookup fails during worktree removal", async () => { const client = createClient({ session: {