diff --git a/README.md b/README.md index 57782ef..d848731 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,36 @@ 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 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: + +```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. + +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. `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 ca6898b..bfa0525 100644 --- a/bun.lock +++ b/bun.lock @@ -18,14 +18,18 @@ }, "packages/opencode": { "name": "@kompassdev/opencode", - "version": "0.13.2", + "version": "0.16.1", + "dependencies": { + "rift-snapshot": "^0.0.10", + }, "devDependencies": { - "@opencode-ai/plugin": "^1.16.2", - "@opencode-ai/sdk": "^1.16.2", + "@opencode-ai/plugin": "^1.18.7", + "@opencode-ai/sdk": "^1.18.7", "yaml": "^2.8.2", }, "peerDependencies": { - "@opencode-ai/plugin": "^1.16.2", + "@opencode-ai/plugin": "^1.18.7", + "@opencode-ai/sdk": "^1.18.7", }, }, "packages/web": { @@ -42,6 +46,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 +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.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.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.16.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg=="], + "@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=="], @@ -490,7 +496,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 +646,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=="], @@ -908,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=="], @@ -1068,7 +1078,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 +1120,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..047f44a 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": true, + "maxConcurrentSessions": 8, + "maxReadChars": 20000, + "maxOutputCharsPerItem": 4000, + "maxWaitMs": 120000, + }, }, }, } diff --git a/kompass.schema.json b/kompass.schema.json index 71d60cc..0efd73f 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": true }, + "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..047f44a 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": true, + "maxConcurrentSessions": 8, + "maxReadChars": 20000, + "maxOutputCharsPerItem": 4000, + "maxWaitMs": 120000, + }, }, }, } diff --git a/packages/core/lib/config.ts b/packages/core/lib/config.ts index 97ec9f3..e2ee245 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 ?? true, + 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..53e257c 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: true, + 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..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": { @@ -125,7 +149,14 @@ }, "adapters": { "opencode": { - "agentMode": "all" + "agentMode": "all", + "navigator": { + "enabled": true, + "maxConcurrentSessions": 8, + "maxReadChars": 20000, + "maxOutputCharsPerItem": 4000, + "maxWaitMs": 120000 + } } } } diff --git a/packages/opencode/README.md b/packages/opencode/README.md index 57782ef..d848731 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,36 @@ 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 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: + +```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. + +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. `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 b26527f..32932ca 100644 --- a/packages/opencode/index.ts +++ b/packages/opencode/index.ts @@ -1,5 +1,7 @@ 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"; @@ -18,6 +20,8 @@ import { 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"; @@ -310,17 +314,36 @@ const opencodeToolCreators: Record = { export async function createOpenCodeTools( client: PluginInput["client"], projectRoot: string, + navigator?: { + client: V2Client; + legacyClient: (directory: string) => LegacyClient; + projectID: string; + protocol: "v1" | "v2"; + }, ): 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, + legacyClient: navigator.legacyClient, + projectID: navigator.projectID, + checkout: projectRoot, + config: config.adapters.opencode.navigator, + protocol: navigator.protocol, + }) + : {}; + + 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 +366,47 @@ 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; + 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 = { + // 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, + navigator.protocol, + navigator.legacyClient(worktree), + ); + 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), @@ -365,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/kompass.jsonc b/packages/opencode/kompass.jsonc index 6260572..047f44a 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": true, + "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..28d37ef --- /dev/null +++ b/packages/opencode/navigator.ts @@ -0,0 +1,904 @@ +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, + 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 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; + 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; + protocol: NavigatorProtocol; + legacyClient: (directory: string) => NavigatorLegacyClient; +}; + +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 + ? 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), type: "worktree" }; + } + return { directory: value.directory, name: value.name, ...(value.branch ? { branch: value.branch } : {}), type: "worktree" }; +} + +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; + 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) { + if (navigator.protocol === "v1") { + 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(), + `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", + ); + 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: { type: "unknown", 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, + 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, navigator), + ]); + const limit = Math.min(args.turnLimit ?? 10, 100); + 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( + 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, navigator.projectID), + }); + }, + }), + + 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, 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) { + 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, navigator.projectID); + const requested = worktrees.find((item) => sameDirectory(item.directory, requestedDirectory)); + 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, navigator.projectID); + try { + 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, + 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, 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 workspace: ${error instanceof Error ? error.message : String(error)}` + + (partial.length ? `. Workspaces created and not removed: ${partial.map((item) => item.directory).join(", ")}` : ""), + ); + } + } + + let session: SessionV2Info | { id: string; projectID: string }; + try { + 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)}` + + (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 { + 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}` + + (createdWorktree ? `. Worktree created: ${createdWorktree.name} (${createdWorktree.directory})` : ""), + ); + } + + return json({ + sessionID: session.id, + directory, + ...(createdWorktree + ? { worktree: { created: true, type: createdWorktree.type, 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, 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 workspace 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, navigator), + ]); + 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 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"); + 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 }], + ...(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 }, + 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, navigator); + 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)), + }); + } + + // 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 }); + } + } + return json({ + status: "timeout", + active: sessions.filter((session) => active.has(session.id)).map((session) => summarizeSession(session, active)), + }); + }, + }), + + 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"); + 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 }); + }, + }), + + 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, 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 workspace"); + + const active = await activeSessionIDs(client, navigator); + 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}`); + } + } + if (managed.type === "rift") { + const api = workspaceApi(client); + if (!api?.workspace?.remove) throw new Error("OpenCode workspace removal is unavailable for Rift workspaces"); + 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({ + 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 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", "switchAgent", "switchModel", "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 { + 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 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); + 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..b9dbc21 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -47,11 +47,15 @@ "url": "https://github.com/kompassdev/kompass/issues" }, "peerDependencies": { - "@opencode-ai/plugin": "^1.16.2" + "@opencode-ai/plugin": "^1.18.7", + "@opencode-ai/sdk": "^1.18.7" + }, + "dependencies": { + "rift-snapshot": "^0.0.10" }, "devDependencies": { - "@opencode-ai/plugin": "^1.16.2", - "@opencode-ai/sdk": "^1.16.2", + "@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/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..f3817de --- /dev/null +++ b/packages/opencode/test/navigator.test.ts @@ -0,0 +1,616 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { createNavigatorTools, detectNavigatorProtocol, 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 } }), + switchAgent: async () => response(undefined), + switchModel: async () => response(undefined), + wait: async () => response(undefined), + 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, protocol: "v1" | "v2" = "v2") { + return createNavigatorTools({ + client: client as never, + legacyClient: () => client as never, + config, + projectID: "project-1", + checkout: "/repo", + protocol, + }); +} + +function context(sessionID = "caller", abort = new AbortController().signal) { + return { sessionID, abort } as never; +} + +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" }); + assert.deepEqual(output.worktrees, [{ directory: "/repo-worktree", name: "repo-worktree", type: "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 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 workspace/, + ); + }); + + 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("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("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", + environment: { type: "new_worktree", name: "feature" }, + }, context())); + 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 () => { + 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()), + /Workspaces 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("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); + }, + }, + }); + const navigator = tools(client); + await (navigator.session_send as any).execute( + { + sessionID: "session-1", + prompt: "steer", + agent: "worker", + model: { providerID: "openai", modelID: "gpt-5.5" }, + }, + context(), + ); + await (navigator.session_interrupt as any).execute( + { sessionID: "session-1" }, + 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" }]); + }); + + test("wait polls V2 active sessions until the first completion", async () => { + let activeCalls = 0; + const client = createClient({ + session: { + 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: 500, + }, context())); + assert.equal(output.status, "completed"); + assert.equal(output.completed.session.sessionID, "one"); + }); + + 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 () => { + 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 workspace/, + ); + 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("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(++lists < 3 ? [riftWorkspace] : []), + 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" }, + { id: "wrk_rift", directory: "/repo" }, + ]); + }); + + 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: { + 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, "v2") ?? "", + /requires OpenCode 1\.17\.12 or newer/, + ); + + client.global.health = async () => response({ version: "1.17.12" }); + 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 03d8f8a..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,135 @@ 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 = { + 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, + legacyClient: () => createMockClient() as never, + projectID: "project-1", + protocol: "v2", + }); + 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, + legacyClient: () => createMockClient() as never, + projectID: "project-1", + protocol: "v2", + }); + + assert.equal(tools.kompass_session_create, undefined); + assert.equal(tools.kompass_worktree_list, undefined); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + }); + + 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"), `{ + "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, + legacyClient: () => createMockClient() as never, + projectID: "project-1", + protocol: "v2", + }); + + 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..04bb31c 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 +- Kompass Navigator session and worktree orchestration - project override loading from local config files ## Project overrides @@ -28,6 +29,37 @@ 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 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 +{ + "adapters": { + "opencode": { + "navigator": { + "enabled": true, + "maxConcurrentSessions": 8, + "maxReadChars": 20000, + "maxOutputCharsPerItem": 4000, + "maxWaitMs": 120000 + } + } + }, + "tools": { + "session_interrupt": { "enabled": false }, + "session_create": { "name": "delegate_session" } + } +} +``` + +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. `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 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..69e2bc6 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: 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 @@ -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..a9c9acb 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 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` + +Lists the current checkout explicitly and all native OpenCode-managed worktrees. + +### `session_create` + +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` + +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 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. The timeout defaults to configured `maxWaitMs`, is capped by `maxWaitMs`, and `0` 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..ef54167 --- /dev/null +++ b/packages/web/src/content/docs/docs/reference/tools/navigator.mdx @@ -0,0 +1,29 @@ +--- +title: Kompass Navigator +description: Native OpenCode session and managed-worktree orchestration tools. +--- + +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 | +| --- | --- | +| `worktree_list` | List the checkout and native managed worktrees. | +| `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. | +| `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. | + +## 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. +- 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. + +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",