diff --git a/README.md b/README.md index 57782ef..b102c6c 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,34 @@ 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, uses the OpenCode server and SDK directly, returns immediately after admitting prompts, supports parallel sessions, and discovers native sessions and worktrees again after plugin restarts. It requires OpenCode `1.17.12` or newer. + +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. It never force-removes or automatically cleans up resources after a partial failure. + ## Workspace This repository is the Kompass development workspace. diff --git a/bun.lock b/bun.lock index ca6898b..7365dbe 100644 --- a/bun.lock +++ b/bun.lock @@ -18,14 +18,15 @@ }, "packages/opencode": { "name": "@kompassdev/opencode", - "version": "0.13.2", + "version": "0.16.1", "devDependencies": { - "@opencode-ai/plugin": "^1.16.2", - "@opencode-ai/sdk": "^1.16.2", + "@opencode-ai/plugin": "^1.17.12", + "@opencode-ai/sdk": "^1.17.12", "yaml": "^2.8.2", }, "peerDependencies": { - "@opencode-ai/plugin": "^1.16.2", + "@opencode-ai/plugin": "^1.17.12", + "@opencode-ai/sdk": "^1.17.12", }, }, "packages/web": { @@ -42,6 +43,8 @@ }, }, "packages": { + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + "@astrojs/check": ["@astrojs/check@0.9.8", "", { "dependencies": { "@astrojs/language-server": "^2.16.5", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-LDng8446QLS5ToKjRHd3bgUdirvemVVExV7nRyJfW2wV36xuv7vDxwy5NWN9zqeSEDgg0Tv84sP+T3yEq+Zlkw=="], "@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], @@ -226,9 +229,9 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.16.2", "", { "dependencies": { "@opencode-ai/sdk": "1.16.2", "effect": "4.0.0-beta.74", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.3.2", "@opentui/keymap": ">=0.3.2", "@opentui/solid": ">=0.3.2" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-FaZhVXrbz93xsdGLCtarRDTeqFt8AkLfh8B34tFBj6G4HXVmKSgBwVXmtELKKC+08xMtawBC9hshiMbXryv6cg=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.5", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.5", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-o1loQw5lh3zK7dgTN25Zh4tK+bW7BdszyDwdSumj0ahaR1lXWjYjVbAZuOQxeEQfkr266cqNi2x8UrrlkI0n7A=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.16.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.5", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-7KgMvP5/1oxbhHj6kYBtPSTEdFKYpUeEYOzBTKdzSaRpapUpFFdn6Hkus3rr0rljO0kukWZIgRd3DrVBwTULGA=="], "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], @@ -490,7 +493,7 @@ "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], - "effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], + "effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], "emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="], @@ -640,6 +643,8 @@ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], @@ -1068,7 +1073,7 @@ "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "yaml-language-server": ["yaml-language-server@1.20.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.7.1" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA=="], @@ -1110,8 +1115,6 @@ "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "effect/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], diff --git a/kompass.jsonc b/kompass.jsonc index 6260572..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..b102c6c 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,34 @@ 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, uses the OpenCode server and SDK directly, returns immediately after admitting prompts, supports parallel sessions, and discovers native sessions and worktrees again after plugin restarts. It requires OpenCode `1.17.12` or newer. + +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. It never force-removes or automatically cleans up resources after a partial failure. + ## Workspace This repository is the Kompass development workspace. diff --git a/packages/opencode/index.ts b/packages/opencode/index.ts index b26527f..1fb3d7c 100644 --- a/packages/opencode/index.ts +++ b/packages/opencode/index.ts @@ -1,5 +1,6 @@ import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin"; import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"; +import { createOpencodeClient as createV2Client, type OpencodeClient as V2Client } from "@opencode-ai/sdk/v2"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; @@ -18,6 +19,7 @@ import { import { loadMergedKompassConfig } from "./cache.ts"; import { applyAgentsConfig, applyCommandsConfig } from "./config.ts"; import { createPluginLogger, getErrorDetails, type PluginLogger } from "./logging.ts"; +import { createNavigatorTools, getNavigatorCompatibilityWarning } from "./navigator.ts"; import { getConfiguredOpenCodeToolName, } from "./tool-names.ts"; @@ -310,17 +312,29 @@ const opencodeToolCreators: Record = { export async function createOpenCodeTools( client: PluginInput["client"], projectRoot: string, + navigator?: { client: V2Client; projectID: string }, ): Promise> { const config = await loadMergedKompassConfig(projectRoot); const tools: Record = {}; const logger = createPluginLogger(client, projectRoot); const shell = createNodeShell(projectRoot); - for (const toolName of getEnabledToolNames(config.tools)) { + const navigatorEnabled = config.adapters.opencode.navigator.enabled; + const navigatorTools: Record = navigatorEnabled && navigator + ? createNavigatorTools({ + client: navigator.client, + projectID: navigator.projectID, + checkout: projectRoot, + config: config.adapters.opencode.navigator, + }) + : {}; + + for (const toolName of getEnabledToolNames(config.tools, navigatorEnabled)) { const creator = opencodeToolCreators[toolName as keyof typeof opencodeToolCreators]; - if (creator) { + const navigatorTool = navigatorTools[toolName]; + if (creator || navigatorTool) { const registeredName = getConfiguredOpenCodeToolName(toolName, config.tools[toolName].name); - tools[registeredName] = creator(client, config, projectRoot, shell); + tools[registeredName] = navigatorTool ?? creator(client, config, projectRoot, shell); logger.info("Loaded Kompass tool", { tool: toolName, registeredName, @@ -343,7 +357,28 @@ export const OpenCodeCompassPlugin: Plugin = async (input: PluginInput) => { async function createToolsSafely() { try { - return await createOpenCodeTools(client, worktree); + const config = await loadMergedKompassConfig(worktree); + let navigator: { client: V2Client; projectID: string } | undefined; + if (config.adapters.opencode.navigator.enabled) { + const projectID = getString(input.project?.id); + if (!input.serverUrl || !projectID) { + logger.warn("Navigator requires OpenCode 1.17.12 or newer; Navigator tools were not registered"); + } else { + navigator = { + client: createV2Client({ + baseUrl: input.serverUrl.toString(), + directory: worktree, + }), + projectID, + }; + const warning = await getNavigatorCompatibilityWarning(navigator.client); + if (warning) { + logger.warn(`${warning}; Navigator tools were not registered`); + navigator = undefined; + } + } + } + return await createOpenCodeTools(client, worktree, navigator); } catch (error) { logger.warn("Skipping Kompass tool registration", { ...getErrorDetails(error), diff --git a/packages/opencode/kompass.jsonc b/packages/opencode/kompass.jsonc index 6260572..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..cbbf19d --- /dev/null +++ b/packages/opencode/navigator.ts @@ -0,0 +1,651 @@ +import type { ToolContext, ToolDefinition } from "@opencode-ai/plugin/tool"; +import { tool } from "@opencode-ai/plugin/tool"; +import type { + OpencodeClient, + SessionMessage, + SessionV2Info, + Worktree, +} from "@opencode-ai/sdk/v2/client"; +import path from "node:path"; + +import type { NavigatorConfig } from "../core/index.ts"; + +export type NavigatorClient = Pick; + +export interface SessionSummary { + sessionID: string; + projectID: string; + directory: string; + title: string; + agent?: string; + model?: { providerID: string; modelID: string }; + createdAt: number; + updatedAt: number; + state: "active" | "idle"; +} + +export interface MessageSummary { + id: string; + type: string; + createdAt?: number; + completedAt?: number; + agent?: string; + model?: { providerID: string; modelID: string }; + items: Array<{ + type: string; + text?: string; + name?: string; + status?: string; + output?: string; + truncated?: boolean; + originalChars?: number; + returnedChars?: number; + }>; +} + +type NavigatorToolName = + | "worktree_list" + | "session_create" + | "session_list" + | "session_read" + | "session_send" + | "session_wait" + | "session_interrupt" + | "worktree_remove"; + +type NavigatorContext = { + checkout: string; + projectID: string; + config: NavigatorConfig; +}; + +type NativeWorktree = { directory: string; name: string; branch?: string }; + +function failResponse(error: unknown, operation: string): never { + const message = error instanceof Error + ? error.message + : typeof error === "object" && error && "message" in error + ? String(error.message) + : JSON.stringify(error); + throw new Error(`${operation} failed${message ? `: ${message}` : ""}`); +} + +function responseData(response: { data?: T; error?: unknown }, operation: string): T { + if (response.error !== undefined) failResponse(response.error, operation); + if (response.data === undefined) throw new Error(`${operation} returned no data`); + return response.data; +} + +function envelopeData(response: { data?: { data: T }; error?: unknown }, operation: string): T { + return responseData(response, operation).data; +} + +function normalizeDirectory(directory: string) { + return path.resolve(directory); +} + +function sameDirectory(left: string, right: string) { + return normalizeDirectory(left) === normalizeDirectory(right); +} + +function normalizeWorktree(value: string | Worktree): NativeWorktree { + if (typeof value === "string") { + return { directory: value, name: path.basename(value) }; + } + return { directory: value.directory, name: value.name, ...(value.branch ? { branch: value.branch } : {}) }; +} + +async function listManagedWorktrees(client: NavigatorClient, checkout: string) { + const values = responseData( + await client.worktree.list({ directory: checkout }), + "OpenCode worktree list", + ) as Array; + return values.map(normalizeWorktree).filter((item) => !sameDirectory(item.directory, checkout)); +} + +async function activeSessionIDs(client: NavigatorClient) { + const active = envelopeData( + await client.v2.session.active(), + "OpenCode active session list", + ); + return new Set(Object.keys(active)); +} + +async function getOwnedSession( + client: NavigatorClient, + projectID: string, + sessionID: string, +): Promise { + const session = await getSession(client, sessionID); + if (session.projectID !== projectID) { + throw new Error(`Session ${sessionID} does not belong to the current OpenCode project`); + } + return session; +} + +async function getSession(client: NavigatorClient, sessionID: string): Promise { + return envelopeData( + await client.v2.session.get({ sessionID }), + `OpenCode session ${sessionID}`, + ); +} + +function summarizeSession(session: SessionV2Info, active: Set): SessionSummary { + return { + sessionID: session.id, + projectID: session.projectID, + directory: session.location.directory, + title: session.title, + ...(session.agent ? { agent: session.agent } : {}), + ...(session.model + ? { model: { providerID: session.model.providerID, modelID: session.model.id } } + : {}), + createdAt: session.time.created, + updatedAt: session.time.updated, + state: active.has(session.id) ? "active" : "idle", + }; +} + +function assertNotCallingSession(sessionID: string, context: ToolContext, operation: string) { + if (sessionID === context.sessionID) { + throw new Error(`Navigator cannot target the calling session for ${operation}`); + } +} + +function toolStatus(message: Extract["content"][number]) { + return message.type === "tool" ? message.state.status : undefined; +} + +function summarizeMessages( + messages: SessionMessage[], + options: { includeOutputs: boolean; maxItemChars: number; maxTotalChars: number }, +) { + let remaining = options.maxTotalChars; + let originalChars = 0; + let returnedChars = 0; + let omittedItems = 0; + + function bounded(value: string | undefined) { + if (value === undefined) return { omitted: true } as const; + originalChars += value.length; + const limit = Math.min(options.maxItemChars, remaining); + if (limit <= 0) { + omittedItems += 1; + return { omitted: true } as const; + } + const text = value.slice(0, limit); + remaining -= text.length; + returnedChars += text.length; + return { + omitted: false, + text, + ...(text.length < value.length + ? { truncated: { originalChars: value.length, returnedChars: text.length } } + : {}), + } as const; + } + + const summaries: MessageSummary[] = messages.map((message) => { + const items: MessageSummary["items"] = []; + const addText = (type: string, value: string | undefined) => { + const result = bounded(value); + if (!result.omitted) items.push({ + type, + text: result.text, + ...(result.truncated + ? { truncated: true, originalChars: result.truncated.originalChars, returnedChars: result.truncated.returnedChars } + : {}), + }); + }; + + if (message.type === "assistant") { + for (const item of message.content) { + if (item.type === "text" || item.type === "reasoning") { + addText(item.type, item.text); + continue; + } + const summary: MessageSummary["items"][number] = { + type: "tool", + name: item.name, + status: toolStatus(item), + }; + if (options.includeOutputs && (item.state.status === "completed" || item.state.status === "error")) { + const raw = item.state.status === "error" + ? item.state.error.message + : JSON.stringify(item.state.result ?? item.state.structured); + const result = bounded(raw); + if (!result.omitted) { + summary.output = result.text; + if (result.truncated) { + summary.truncated = true; + summary.originalChars = result.truncated.originalChars; + summary.returnedChars = result.truncated.returnedChars; + } + } + } + items.push(summary); + } + } else if (message.type === "shell") { + addText("command", message.command); + if (options.includeOutputs) { + const result = bounded(message.output); + if (!result.omitted) { + items.push({ + type: "output", + output: result.text, + ...(result.truncated + ? { truncated: true, originalChars: result.truncated.originalChars, returnedChars: result.truncated.returnedChars } + : {}), + }); + } + } + } else if (message.type === "compaction") { + addText("summary", message.summary); + addText("recent", message.recent); + } else if ("text" in message) { + addText("text", message.text); + } else if (message.type === "agent-switched") { + addText("agent", message.agent); + } else if (message.type === "model-switched") { + addText("model", `${message.model.providerID}/${message.model.id}`); + } + + return { + id: message.id, + type: message.type, + createdAt: "time" in message ? message.time.created : undefined, + ...(message.type === "assistant" && message.time.completed ? { completedAt: message.time.completed } : {}), + ...(message.type === "assistant" ? { agent: message.agent } : {}), + ...(message.type === "assistant" + ? { model: { providerID: message.model.providerID, modelID: message.model.id } } + : {}), + items, + }; + }); + + return { + messages: summaries, + truncation: { + truncated: originalChars > returnedChars, + originalChars, + returnedChars, + maxChars: options.maxTotalChars, + omittedItems, + }, + }; +} + +async function readSession( + client: NavigatorClient, + navigator: NavigatorContext, + args: { + sessionID: string; + cursor?: string; + turnLimit?: number; + includeOutputs?: boolean; + maxOutputCharsPerItem?: number; + }, +) { + const [session, active] = await Promise.all([ + getOwnedSession(client, navigator.projectID, args.sessionID), + activeSessionIDs(client), + ]); + const limit = Math.min(args.turnLimit ?? 10, 100); + const payload = responseData( + await client.v2.session.messages({ + sessionID: args.sessionID, + limit, + order: args.cursor ? undefined : "desc", + cursor: args.cursor, + }), + `OpenCode messages for session ${args.sessionID}`, + ); + const formatted = summarizeMessages(payload.data, { + includeOutputs: args.includeOutputs ?? false, + maxItemChars: Math.min( + args.maxOutputCharsPerItem ?? navigator.config.maxOutputCharsPerItem, + navigator.config.maxOutputCharsPerItem, + ), + maxTotalChars: navigator.config.maxReadChars, + }); + return { + session: summarizeSession(session, active), + messages: formatted.messages, + ...(payload.cursor.next ? { cursor: payload.cursor.next } : {}), + truncation: formatted.truncation, + }; +} + +function json(value: unknown) { + return JSON.stringify(value); +} + +export function createNavigatorTools( + input: NavigatorContext & { client: NavigatorClient }, +): Record { + const { client, ...navigator } = input; + return { + worktree_list: tool({ + description: "List the current OpenCode project checkout and its managed native worktrees.", + args: {}, + async execute() { + return json({ + checkout: { directory: navigator.checkout }, + worktrees: await listManagedWorktrees(client, navigator.checkout), + }); + }, + }), + + session_create: tool({ + description: "Create and asynchronously prompt a native OpenCode session in the checkout or a managed worktree.", + args: { + prompt: tool.schema.string().min(1), + environment: tool.schema.discriminatedUnion("type", [ + tool.schema.object({ type: tool.schema.literal("checkout") }), + tool.schema.object({ + type: tool.schema.literal("existing_worktree"), + directory: tool.schema.string().min(1), + }), + tool.schema.object({ + type: tool.schema.literal("new_worktree"), + name: tool.schema.string().min(1).optional(), + startCommand: tool.schema.string().min(1).optional(), + }), + ]), + agent: tool.schema.string().min(1).optional(), + model: tool.schema.object({ + providerID: tool.schema.string().min(1), + modelID: tool.schema.string().min(1), + }).optional(), + }, + async execute(args) { + const active = await activeSessionIDs(client); + const activeSessions = await Promise.all([...active].map((sessionID) => getSession(client, sessionID))); + const activeOwnedCount = activeSessions.filter((session) => session.projectID === navigator.projectID).length; + if (activeOwnedCount >= navigator.config.maxConcurrentSessions) { + throw new Error(`Navigator allows at most ${navigator.config.maxConcurrentSessions} concurrent sessions`); + } + + let directory = navigator.checkout; + let createdWorktree: NativeWorktree | undefined; + if (args.environment.type === "existing_worktree") { + const requestedDirectory = args.environment.directory; + const worktrees = await listManagedWorktrees(client, navigator.checkout); + const requested = worktrees.find((item) => sameDirectory(item.directory, requestedDirectory)); + if (!requested) throw new Error("The requested directory is not a managed OpenCode worktree for this project"); + directory = requested.directory; + } else if (args.environment.type === "new_worktree") { + const worktreesBefore = await listManagedWorktrees(client, navigator.checkout); + try { + const worktree = responseData( + await client.worktree.create({ + directory: navigator.checkout, + worktreeCreateInput: { + ...(args.environment.name ? { name: args.environment.name } : {}), + ...(args.environment.startCommand ? { startCommand: args.environment.startCommand } : {}), + }, + }), + "OpenCode worktree create", + ); + createdWorktree = normalizeWorktree(worktree); + directory = createdWorktree.directory; + } catch (error) { + const worktreesAfter = await listManagedWorktrees(client, navigator.checkout).catch(() => []); + const before = new Set(worktreesBefore.map((item) => normalizeDirectory(item.directory))); + const partial = worktreesAfter.filter((item) => !before.has(normalizeDirectory(item.directory))); + throw new Error( + `Navigator could not create the requested worktree: ${error instanceof Error ? error.message : String(error)}` + + (partial.length ? `. Worktrees created and not removed: ${partial.map((item) => item.directory).join(", ")}` : ""), + ); + } + } + + let session: SessionV2Info; + try { + session = envelopeData( + await client.v2.session.create({ + ...(args.agent ? { agent: args.agent } : {}), + ...(args.model ? { model: { providerID: args.model.providerID, id: args.model.modelID } } : {}), + location: { directory }, + }), + "OpenCode session create", + ); + } catch (error) { + throw new Error( + `Navigator session creation failed: ${error instanceof Error ? error.message : String(error)}` + + (createdWorktree ? `. Worktree created: ${createdWorktree.name} (${createdWorktree.directory}) and was not removed` : ""), + ); + } + if (session.projectID !== navigator.projectID) { + throw new Error(`Created session ${session.id} belongs to an unexpected project; it was not prompted`); + } + + try { + envelopeData( + await client.v2.session.prompt({ + sessionID: session.id, + prompt: { text: args.prompt }, + delivery: "steer", + }), + `OpenCode prompt admission for session ${session.id}`, + ); + } catch (error) { + throw new Error( + `Navigator prompt admission failed: ${error instanceof Error ? error.message : String(error)}. Session created: ${session.id}` + + (createdWorktree ? `. Worktree created: ${createdWorktree.name} (${createdWorktree.directory})` : ""), + ); + } + + return json({ + sessionID: session.id, + directory, + ...(createdWorktree + ? { worktree: { created: true, name: createdWorktree.name, ...(createdWorktree.branch ? { branch: createdWorktree.branch } : {}) } } + : {}), + }); + }, + }), + + session_list: tool({ + description: "List native OpenCode sessions owned by the current project.", + args: { + directory: tool.schema.string().optional(), + search: tool.schema.string().optional(), + limit: tool.schema.number().int().positive().max(100).optional(), + cursor: tool.schema.string().optional(), + }, + async execute(args) { + if (args.directory) { + const managed = await listManagedWorktrees(client, navigator.checkout); + const valid = [navigator.checkout, ...managed.map((item) => item.directory)] + .some((directory) => sameDirectory(directory, args.directory!)); + if (!valid) throw new Error("The requested directory is not a managed OpenCode worktree for this project"); + } + const [payload, active] = await Promise.all([ + client.v2.session.list({ + project: navigator.projectID, + directory: args.directory, + search: args.search, + limit: args.limit, + cursor: args.cursor, + }).then((response) => responseData(response, "OpenCode session list")), + activeSessionIDs(client), + ]); + const owned = payload.data.filter((session) => session.projectID === navigator.projectID); + return json({ + sessions: owned.map((session) => summarizeSession(session, active)), + ...(payload.cursor.next ? { cursor: payload.cursor.next } : {}), + }); + }, + }), + + session_read: tool({ + description: "Read a bounded page of recent messages from a current-project OpenCode session.", + args: { + sessionID: tool.schema.string().min(1), + cursor: tool.schema.string().optional(), + turnLimit: tool.schema.number().int().positive().max(100).optional(), + includeOutputs: tool.schema.boolean().optional(), + maxOutputCharsPerItem: tool.schema.number().int().positive().optional(), + }, + async execute(args) { + return json(await readSession(client, navigator, args)); + }, + }), + + session_send: tool({ + description: "Steer or queue a prompt for an existing current-project OpenCode session.", + args: { + sessionID: tool.schema.string().min(1), + prompt: tool.schema.string().min(1), + delivery: tool.schema.enum(["steer", "queue"]).optional(), + }, + async execute(args, context) { + assertNotCallingSession(args.sessionID, context, "send to"); + await getOwnedSession(client, navigator.projectID, args.sessionID); + const admitted = envelopeData( + await client.v2.session.prompt({ + sessionID: args.sessionID, + prompt: { text: args.prompt }, + delivery: args.delivery ?? "steer", + }), + `OpenCode prompt admission for session ${args.sessionID}`, + ); + return json({ sessionID: args.sessionID, admitted: Boolean(admitted) }); + }, + }), + + session_wait: tool({ + description: "Wait for the first of one to eight current-project OpenCode sessions to become idle.", + args: { + targets: tool.schema.array(tool.schema.object({ sessionID: tool.schema.string().min(1) })).min(1).max(8), + timeoutMs: tool.schema.number().int().nonnegative().optional(), + }, + async execute(args, context) { + const ids = args.targets.map((target) => target.sessionID); + if (new Set(ids).size !== ids.length) throw new Error("Navigator wait targets must be unique"); + for (const id of ids) assertNotCallingSession(id, context, "wait for"); + const sessions = await Promise.all(ids.map((id) => getOwnedSession(client, navigator.projectID, id))); + let active = await activeSessionIDs(client); + const alreadyIdle = sessions.find((session) => !active.has(session.id)); + if (alreadyIdle) { + const completed = await readSession(client, navigator, { sessionID: alreadyIdle.id }); + return json({ status: "completed", completed }); + } + + const timeoutMs = Math.min(args.timeoutMs ?? navigator.config.maxWaitMs, navigator.config.maxWaitMs); + if (timeoutMs === 0) { + return json({ + status: "timeout", + active: sessions.map((session) => summarizeSession(session, active)), + }); + } + + const controllers = ids.map(() => new AbortController()); + const abortAll = () => controllers.forEach((controller) => controller.abort()); + const invocationAbort = new Promise((_, reject) => { + context.abort.addEventListener("abort", () => reject(context.abort.reason ?? new Error("Navigator wait aborted")), { once: true }); + }); + let timer: ReturnType | undefined; + const timeout = new Promise<{ type: "timeout" }>((resolve) => { + timer = setTimeout(() => resolve({ type: "timeout" }), timeoutMs); + }); + const waits = ids.map((sessionID, index) => + client.v2.session.wait({ sessionID }, { signal: controllers[index].signal }) + .then((response) => { + if (response.error !== undefined) failResponse(response.error, `OpenCode wait for session ${sessionID}`); + return { type: "completed" as const, sessionID }; + }) + ); + + try { + const result = await Promise.race([...waits, timeout, invocationAbort]); + if (result.type === "completed") { + abortAll(); + const completed = await readSession(client, navigator, { sessionID: result.sessionID }); + return json({ status: "completed", completed }); + } + abortAll(); + active = await activeSessionIDs(client); + return json({ + status: "timeout", + active: sessions.filter((session) => active.has(session.id)).map((session) => summarizeSession(session, active)), + }); + } finally { + if (timer) clearTimeout(timer); + abortAll(); + } + }, + }), + + session_interrupt: tool({ + description: "Interrupt active execution in a current-project OpenCode session.", + args: { sessionID: tool.schema.string().min(1) }, + async execute(args, context) { + assertNotCallingSession(args.sessionID, context, "interrupt"); + await getOwnedSession(client, navigator.projectID, args.sessionID); + const response = await client.v2.session.interrupt({ sessionID: args.sessionID }); + if (response.error !== undefined) failResponse(response.error, `OpenCode interrupt for session ${args.sessionID}`); + return json({ sessionID: args.sessionID, interrupted: true }); + }, + }), + + worktree_remove: tool({ + description: "Remove an idle managed OpenCode worktree from the current project without force.", + args: { directory: tool.schema.string().min(1) }, + async execute(args) { + if (sameDirectory(args.directory, navigator.checkout)) { + throw new Error("Navigator refuses to remove the main checkout"); + } + const worktrees = await listManagedWorktrees(client, navigator.checkout); + const managed = worktrees.find((item) => sameDirectory(item.directory, args.directory)); + if (!managed) throw new Error("The requested directory is not a managed OpenCode worktree"); + + const active = await activeSessionIDs(client); + for (const sessionID of active) { + const session = await getSession(client, sessionID); + if (session.projectID !== navigator.projectID) continue; + if (sameDirectory(session.location.directory, managed.directory)) { + throw new Error(`Navigator refuses to remove a worktree containing active session ${sessionID}`); + } + } + const removed = responseData( + await client.worktree.remove({ + directory: navigator.checkout, + worktreeRemoveInput: { directory: managed.directory }, + }), + `OpenCode worktree removal for ${managed.directory}`, + ); + return json({ removed: Boolean(removed) }); + }, + }), + }; +} + +export async function checkNavigatorCompatibility(client: NavigatorClient, checkout: string) { + await listManagedWorktrees(client, checkout); +} + +export async function getNavigatorCompatibilityWarning(client: NavigatorClient) { + if (!client.worktree?.list || !client.v2?.session?.create || !client.v2.session.prompt || !client.v2.session.wait) { + return "Kompass Navigator requires OpenCode 1.17.12 or newer with V2 session and worktree APIs"; + } + if (!client.global?.health) return; + try { + const health = responseData(await client.global.health(), "OpenCode health") as { version?: string }; + if (health.version && compareVersions(health.version, "1.17.12") < 0) { + return `Kompass Navigator requires OpenCode 1.17.12 or newer (running ${health.version})`; + } + } catch (error) { + return `Kompass Navigator could not verify OpenCode compatibility: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function compareVersions(left: string, right: string) { + const a = left.split(".").map((value) => Number.parseInt(value, 10) || 0); + const b = right.split(".").map((value) => Number.parseInt(value, 10) || 0); + for (let index = 0; index < Math.max(a.length, b.length); index += 1) { + if ((a[index] ?? 0) !== (b[index] ?? 0)) return (a[index] ?? 0) - (b[index] ?? 0); + } + return 0; +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 472e429..623de7b 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -47,11 +47,12 @@ "url": "https://github.com/kompassdev/kompass/issues" }, "peerDependencies": { - "@opencode-ai/plugin": "^1.16.2" + "@opencode-ai/plugin": "^1.17.12", + "@opencode-ai/sdk": "^1.17.12" }, "devDependencies": { - "@opencode-ai/plugin": "^1.16.2", - "@opencode-ai/sdk": "^1.16.2", + "@opencode-ai/plugin": "^1.17.12", + "@opencode-ai/sdk": "^1.17.12", "yaml": "^2.8.2" } } diff --git a/packages/opencode/scripts/build.ts b/packages/opencode/scripts/build.ts index 9bf3661..300c46b 100644 --- a/packages/opencode/scripts/build.ts +++ b/packages/opencode/scripts/build.ts @@ -13,7 +13,11 @@ const workspaceReadme = path.join(workspaceRoot, "README.md"); const packageReadme = path.join(packageRoot, "README.md"); const runtimeDirs = ["agents", "commands", "components"] as const; -const bundleExternals = ["@opencode-ai/plugin", "@opencode-ai/plugin/tool"] as const; +const bundleExternals = [ + "@opencode-ai/plugin", + "@opencode-ai/plugin/tool", + "@opencode-ai/sdk/v2", +] as const; const bundleArgs = [ "build", "./plugin.ts", diff --git a/packages/opencode/scripts/compile.ts b/packages/opencode/scripts/compile.ts index bc5c75f..e7f6e73 100644 --- a/packages/opencode/scripts/compile.ts +++ b/packages/opencode/scripts/compile.ts @@ -50,7 +50,7 @@ async function main() { // Load configuration const userConfig = await loadKompassConfig(WORKSPACE_ROOT); const config = mergeWithDefaults(userConfig); - const enabledTools = getEnabledToolNames(config.tools); + const enabledTools = getEnabledToolNames(config.tools, config.adapters.opencode.navigator.enabled); const configuredToolNames = Object.fromEntries( Object.entries(config.tools).map(([toolName, toolConfig]) => [ toolName, diff --git a/packages/opencode/test/navigator.test.ts b/packages/opencode/test/navigator.test.ts new file mode 100644 index 0000000..926cb93 --- /dev/null +++ b/packages/opencode/test/navigator.test.ts @@ -0,0 +1,368 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { createNavigatorTools, getNavigatorCompatibilityWarning } from "../navigator.ts"; + +const navigatorConfig = { + enabled: true, + maxConcurrentSessions: 8, + maxReadChars: 20_000, + maxOutputCharsPerItem: 4_000, + maxWaitMs: 120_000, +}; + +function response(data: T) { + return { data, error: undefined }; +} + +function session(id: string, directory = "/repo", projectID = "project-1") { + return { + id, + projectID, + title: id, + location: { directory }, + time: { created: 1, updated: 2 }, + }; +} + +function createClient(overrides: Record = {}) { + const sessions = [session("session-1")]; + const client: Record = { + worktree: { + list: async () => response(["/repo-worktree"]), + create: async () => response({ directory: "/repo-new", name: "new", branch: "new" }), + remove: async () => response(true), + }, + v2: { + session: { + active: async () => response({ data: {} }), + get: async ({ sessionID }: { sessionID: string }) => response({ + data: sessions.find((item) => item.id === sessionID) ?? session(sessionID), + }), + list: async () => response({ data: sessions, cursor: {} }), + messages: async () => response({ data: [], cursor: {} }), + create: async ({ location }: any) => response({ data: session("created", location.directory) }), + prompt: async ({ sessionID }: any) => response({ data: { sessionID } }), + wait: async () => response(undefined), + interrupt: async () => response(undefined), + }, + }, + }; + for (const [key, value] of Object.entries(overrides)) { + if (key === "worktree") Object.assign(client.worktree, value); + else if (key === "session") Object.assign(client.v2.session, value); + else client[key] = value; + } + return client; +} + +function tools(client = createClient(), config = navigatorConfig) { + return createNavigatorTools({ + client: client as never, + config, + projectID: "project-1", + checkout: "/repo", + }); +} + +function context(sessionID = "caller", abort = new AbortController().signal) { + return { sessionID, abort } as never; +} + +describe("Kompass Navigator", () => { + test("lists the checkout and managed worktrees", async () => { + const output = JSON.parse(await (tools().worktree_list as any).execute({}, context())); + assert.deepEqual(output.checkout, { directory: "/repo" }); + assert.deepEqual(output.worktrees, [{ directory: "/repo-worktree", name: "repo-worktree" }]); + }); + + test("rejects foreign sessions", async () => { + const client = createClient({ + session: { get: async () => response({ data: session("foreign", "/repo", "project-2") }) }, + }); + await assert.rejects( + (tools(client).session_read as any).execute({ sessionID: "foreign" }, context()), + /does not belong to the current OpenCode project/, + ); + }); + + test("filters session lists to the current project", async () => { + const queries: any[] = []; + const client = createClient({ + session: { + list: async (args: any) => { + queries.push(args); + return response({ + data: [session("owned"), session("foreign", "/repo", "project-2")], + cursor: {}, + }); + }, + }, + }); + const output = JSON.parse(await (tools(client).session_list as any).execute({}, context())); + + assert.equal(queries[0].project, "project-1"); + assert.deepEqual(output.sessions.map((item: any) => item.sessionID), ["owned"]); + }); + + test("rejects arbitrary existing worktree paths", async () => { + await assert.rejects( + (tools().session_create as any).execute({ + prompt: "work", + environment: { type: "existing_worktree", directory: "/tmp/arbitrary" }, + }, context()), + /not a managed OpenCode worktree/, + ); + }); + + test("rejects arbitrary session-list directories", async () => { + await assert.rejects( + (tools().session_list as any).execute({ directory: "/tmp/arbitrary" }, context()), + /not a managed OpenCode worktree/, + ); + }); + + test("creates in the selected directory and admits the prompt", async () => { + const creates: any[] = []; + const prompts: any[] = []; + const client = createClient({ + session: { + create: async (args: any) => { + creates.push(args); + return response({ data: session("created", args.location.directory) }); + }, + prompt: async (args: any) => { + prompts.push(args); + return response({ data: { sessionID: args.sessionID } }); + }, + }, + }); + const output = JSON.parse(await (tools(client).session_create as any).execute({ + prompt: "implement it", + environment: { type: "existing_worktree", directory: "/repo-worktree" }, + }, context())); + + assert.equal(output.directory, "/repo-worktree"); + assert.equal(creates[0].location.directory, "/repo-worktree"); + assert.equal(prompts[0].prompt.text, "implement it"); + }); + + test("returns new worktree details", async () => { + const output = JSON.parse(await (tools().session_create as any).execute({ + prompt: "implement it", + environment: { type: "new_worktree", name: "feature" }, + }, context())); + assert.deepEqual(output.worktree, { created: true, name: "new", branch: "new" }); + }); + + test("reports resources created before a prompt failure", async () => { + const client = createClient({ session: { prompt: async () => { throw new Error("admission failed"); } } }); + await assert.rejects( + (tools(client).session_create as any).execute({ + prompt: "implement it", + environment: { type: "new_worktree" }, + }, context()), + /Session created: created.*Worktree created: new \(\/repo-new\)/, + ); + }); + + test("reports a worktree created before a session failure", async () => { + const client = createClient({ session: { create: async () => { throw new Error("create failed"); } } }); + await assert.rejects( + (tools(client).session_create as any).execute({ + prompt: "implement it", + environment: { type: "new_worktree" }, + }, context()), + /Worktree created: new \(\/repo-new\).*was not removed/, + ); + }); + + test("discovers a worktree left behind by a failed native create", async () => { + let created = false; + const client = createClient({ + worktree: { + list: async () => response(created ? ["/repo-partial"] : []), + create: async () => { + created = true; + throw new Error("start command failed"); + }, + }, + }); + await assert.rejects( + (tools(client).session_create as any).execute({ + prompt: "implement it", + environment: { type: "new_worktree", startCommand: "false" }, + }, context()), + /Worktrees created and not removed: \/repo-partial/, + ); + }); + + test("rejects self send, wait, and interrupt", async () => { + const navigator = tools(); + await assert.rejects( + (navigator.session_send as any).execute({ sessionID: "caller", prompt: "x" }, context()), + /cannot target the calling session/, + ); + await assert.rejects( + (navigator.session_wait as any).execute({ targets: [{ sessionID: "caller" }] }, context()), + /cannot target the calling session/, + ); + await assert.rejects( + (navigator.session_interrupt as any).execute({ sessionID: "caller" }, context()), + /cannot target the calling session/, + ); + }); + + test("sends steer by default, supports queue, and interrupts through the SDK", async () => { + const prompts: any[] = []; + const interrupts: any[] = []; + const client = createClient({ + session: { + active: async () => response({ data: { "session-1": { type: "running" } } }), + prompt: async (args: any) => { + prompts.push(args); + return response({ data: { sessionID: args.sessionID } }); + }, + interrupt: async (args: any) => { + interrupts.push(args); + return response(undefined); + }, + }, + }); + const navigator = tools(client); + await (navigator.session_send as any).execute( + { sessionID: "session-1", prompt: "steer" }, + context(), + ); + await (navigator.session_send as any).execute( + { sessionID: "session-1", prompt: "later", delivery: "queue" }, + context(), + ); + await (navigator.session_interrupt as any).execute( + { sessionID: "session-1" }, + context(), + ); + + assert.equal(prompts[0].delivery, "steer"); + assert.equal(prompts[1].delivery, "queue"); + assert.deepEqual(interrupts, [{ sessionID: "session-1" }]); + }); + + test("wait returns the first completion and aborts losing waits", async () => { + const aborted: string[] = []; + const client = createClient({ + session: { + active: async () => response({ data: { one: { type: "running" }, two: { type: "running" } } }), + wait: ({ sessionID }: any, options: any) => new Promise((resolve, reject) => { + options.signal.addEventListener("abort", () => { + aborted.push(sessionID); + reject(new DOMException("Aborted", "AbortError")); + }); + if (sessionID === "one") setTimeout(() => resolve(response(undefined)), 5); + }), + }, + }); + const output = JSON.parse(await (tools(client).session_wait as any).execute({ + targets: [{ sessionID: "one" }, { sessionID: "two" }], + timeoutMs: 100, + }, context())); + assert.equal(output.status, "completed"); + assert.equal(output.completed.session.sessionID, "one"); + assert.ok(aborted.includes("two")); + }); + + test("immediate wait returns an active snapshot", async () => { + const client = createClient({ + session: { active: async () => response({ data: { one: { type: "running" } } }) }, + }); + const output = JSON.parse(await (tools(client).session_wait as any).execute({ + targets: [{ sessionID: "one" }], + timeoutMs: 0, + }, context())); + assert.equal(output.status, "timeout"); + assert.equal(output.active[0].state, "active"); + }); + + test("bounds transcript items and total output", async () => { + const client = createClient({ + session: { + messages: async () => response({ + data: [ + { id: "m1", type: "user", time: { created: 1 }, text: "abcdefghij" }, + { id: "m2", type: "user", time: { created: 2 }, text: "klmnopqrst" }, + ], + cursor: {}, + }), + }, + }); + const output = JSON.parse(await (tools(client, { + ...navigatorConfig, + maxReadChars: 7, + maxOutputCharsPerItem: 5, + }).session_read as any).execute({ sessionID: "session-1" }, context())); + assert.equal(output.messages[0].items[0].text.length, 5); + assert.equal(output.messages[0].items[0].truncated, true); + assert.equal(output.messages[1].items[0].text.length, 2); + }); + + test("guards checkout, unmanaged, and active worktree removal", async () => { + const navigator = tools(); + await assert.rejects( + (navigator.worktree_remove as any).execute({ directory: "/repo" }, context()), + /main checkout/, + ); + await assert.rejects( + (navigator.worktree_remove as any).execute({ directory: "/other" }, context()), + /not a managed OpenCode worktree/, + ); + const activeClient = createClient({ + session: { + active: async () => response({ data: { active: { type: "running" } } }), + get: async () => response({ data: session("active", "/repo-worktree") }), + }, + }); + await assert.rejects( + (tools(activeClient).worktree_remove as any).execute({ directory: "/repo-worktree" }, context()), + /active session/, + ); + + const output = JSON.parse(await (navigator.worktree_remove as any).execute( + { directory: "/repo-worktree" }, + context(), + )); + assert.deepEqual(output, { removed: true }); + }); + + test("fails closed when active-session lookup fails during worktree removal", async () => { + const client = createClient({ + session: { + active: async () => response({ data: { active: { type: "running" } } }), + get: async () => { throw new Error("connection lost"); }, + }, + }); + await assert.rejects( + (tools(client).worktree_remove as any).execute({ directory: "/repo-worktree" }, context()), + /connection lost/, + ); + }); + + test("discovers existing sessions after reinitialization", async () => { + const client = createClient(); + const first = JSON.parse(await (tools(client).session_list as any).execute({}, context())); + const second = JSON.parse(await (tools(client).session_list as any).execute({}, context())); + assert.deepEqual(second.sessions, first.sessions); + }); + + test("reports incompatible OpenCode runtime versions", async () => { + const client = createClient({ + global: { health: async () => response({ version: "1.17.11" }) }, + }); + assert.match( + await getNavigatorCompatibilityWarning(client as never) ?? "", + /requires OpenCode 1\.17\.12 or newer/, + ); + + client.global.health = async () => response({ version: "1.17.12" }); + assert.equal(await getNavigatorCompatibilityWarning(client as never), undefined); + }); +}); diff --git a/packages/opencode/test/tool-registration.test.ts b/packages/opencode/test/tool-registration.test.ts index 03d8f8a..c0397aa 100644 --- a/packages/opencode/test/tool-registration.test.ts +++ b/packages/opencode/test/tool-registration.test.ts @@ -127,6 +127,81 @@ async function createTempGitRepo() { } describe("createOpenCodeTools", () => { + 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, + projectID: "project-1", + }); + assert.ok(tools.kompass_session_create); + assert.ok(tools.kompass_worktree_list); + }); + }); + + test("allows Navigator to be disabled as a feature", async () => { + await withTempHome(async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "kompass-navigator-disabled-")); + try { + await mkdir(path.join(tempDir, ".opencode"), { recursive: true }); + await writeFile(path.join(tempDir, ".opencode", "kompass.jsonc"), `{ + "adapters": { "opencode": { "navigator": { "enabled": false } } } + }`); + const tools = await createOpenCodeTools(createMockClient() as never, tempDir, { + client: {} as never, + projectID: "project-1", + }); + + assert.equal(tools.kompass_session_create, undefined); + assert.equal(tools.kompass_worktree_list, undefined); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + }); + + test("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, + projectID: "project-1", + }); + + assert.ok(tools.kompass_worktree_list); + assert.ok(tools.delegate_session); + assert.ok(tools.kompass_session_list); + assert.ok(tools.kompass_session_read); + assert.ok(tools.kompass_session_send); + assert.ok(tools.kompass_session_wait); + assert.ok(tools.kompass_worktree_remove); + assert.equal(tools.kompass_session_create, undefined); + assert.equal(tools.kompass_session_interrupt, undefined); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + }); + test("registers Kompass tools with prefixed names", async () => { await withTempHome(async () => { const tools = await createOpenCodeTools(createMockClient() as never, process.cwd()); diff --git a/packages/web/src/content/docs/docs/adapters/opencode.mdx b/packages/web/src/content/docs/docs/adapters/opencode.mdx index 0e333d5..95ae6a0 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,35 @@ You can use overrides to: - remap tool names - change defaults such as the base branch - adjust adapter behavior such as `agentMode` +- enable and limit Navigator + +## Kompass Navigator + +Navigator uses OpenCode's native server and SDK to create, inspect, continue, wait for, and interrupt sessions in the current checkout or managed Git worktrees. It requires OpenCode `1.17.12` or newer and is 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. Removal rejects the checkout, unmanaged worktrees, and worktrees containing active sessions. Navigator does not force removal, merge branches, create pull requests, or maintain a separate job database. ## Agent model diff --git a/packages/web/src/content/docs/docs/config/overview.mdx b/packages/web/src/content/docs/docs/config/overview.mdx index 352f2e9..1a5295f 100644 --- a/packages/web/src/content/docs/docs/config/overview.mdx +++ b/packages/web/src/content/docs/docs/config/overview.mdx @@ -64,6 +64,7 @@ Shared defaults such as `baseBranch`. Adapter-specific configuration. Today this includes: - `adapters.opencode.agentMode` +- `adapters.opencode.navigator`, including its feature gate and concurrency, read, output, and wait limits ## Tool alias example diff --git a/packages/web/src/content/docs/docs/config/schema.mdx b/packages/web/src/content/docs/docs/config/schema.mdx index 38b1333..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..7bec614 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 worktree, or a newly created worktree; admits its initial prompt and returns immediately. + +### `session_list` + +Lists paginated sessions filtered to the current OpenCode project, with location, agent, model, timestamps, and active or idle state. + +### `session_read` + +Reads a bounded page of recent projected messages. Tool outputs are excluded by default, and per-item and total truncation metadata is returned. + +### `session_send` + +Sends a follow-up with native `steer` delivery by default or queues it until the session would otherwise become idle. + +### `session_wait` + +Waits for the first of up to eight sessions to become idle, aborts losing waits, and returns bounded recent output. A zero timeout returns an immediate snapshot. + +### `session_interrupt` + +Interrupts active native execution for another session in the current project. + +### `worktree_remove` + +Removes an idle native managed worktree. It rejects the main checkout, unmanaged paths, and worktrees containing active sessions; force removal is not available. diff --git a/packages/web/src/content/docs/docs/reference/tools/navigator.mdx b/packages/web/src/content/docs/docs/reference/tools/navigator.mdx new file mode 100644 index 0000000..aef4f52 --- /dev/null +++ b/packages/web/src/content/docs/docs/reference/tools/navigator.mdx @@ -0,0 +1,28 @@ +--- +title: Kompass Navigator +description: Native OpenCode session and managed-worktree orchestration tools. +--- + +Navigator is 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 V2 session in the checkout, an existing worktree, or a newly created worktree and admit its first prompt asynchronously. | +| `session_list` | List paginated sessions filtered to the current project. | +| `session_read` | Read a bounded page of recent messages with optional tool outputs. | +| `session_send` | Steer an active session or queue work for its next idle point. | +| `session_wait` | Wait for the first of one to eight sessions to become idle. A zero timeout returns an immediate snapshot. | +| `session_interrupt` | Interrupt another session's active execution. | +| `worktree_remove` | Remove an idle native managed worktree without force. | + +## Safety and limits + +- Every session ID is validated against the current OpenCode project. +- Existing-worktree paths must appear in OpenCode's native worktree list. +- Send, wait, and interrupt reject the calling session. +- Reads default to ten recent messages, exclude tool outputs, and enforce configured per-item and total character limits. +- Worktree removal rejects the main checkout, unmanaged worktrees, and worktrees containing active sessions. +- Partial creation errors identify resources already created and never automatically delete them. + +Navigator does not keep a separate job database. Existing native sessions and worktrees remain discoverable after plugin restarts. diff --git a/packages/web/src/pages/index.astro b/packages/web/src/pages/index.astro index 1d1acc9..3250888 100644 --- a/packages/web/src/pages/index.astro +++ b/packages/web/src/pages/index.astro @@ -12,7 +12,7 @@ const heroHighlights = [ }, { eyebrow: "Adapters", - title: "OpenCode today, more surfaces next" + title: "OpenCode workflows and native session navigation" }, { eyebrow: "Integrations",