Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ The recommended project override path is `.opencode/kompass.jsonc`.

## Kompass Navigator

Navigator is an OpenCode capability for orchestrating native sessions in the current checkout and OpenCode-managed Git worktrees. It is enabled by default, 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.
Navigator is an OpenCode capability for explicitly requested orchestration of native sessions in the current checkout and OpenCode-managed Git worktrees. It is not a subagent mechanism; ordinary delegation should use OpenCode's built-in `task` tool. Navigator 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:

Expand All @@ -78,7 +78,7 @@ The default runtime names are `kompass_worktree_list`, `kompass_session_create`,

Set `adapters.opencode.navigator.enabled` to `false` to disable all Navigator tools.

Navigator accepts only sessions from the current OpenCode project and only worktrees returned by OpenCode. It rejects self-targeting lifecycle calls, arbitrary directories, main-checkout removal, unmanaged worktrees, and removal while a worktree has active sessions. `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.
Navigator accepts only sessions from the current OpenCode project and only worktrees returned by OpenCode. New sessions inherit the calling session's agent, model, and variant unless explicitly overridden. It rejects self-targeting lifecycle calls, arbitrary directories, unknown V2 agent overrides, 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.

Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ The recommended project override path is `.opencode/kompass.jsonc`.

## Kompass Navigator

Navigator is an OpenCode capability for orchestrating native sessions in the current checkout and OpenCode-managed Git worktrees. It is enabled by default, 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.
Navigator is an OpenCode capability for explicitly requested orchestration of native sessions in the current checkout and OpenCode-managed Git worktrees. It is not a subagent mechanism; ordinary delegation should use OpenCode's built-in `task` tool. Navigator 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:

Expand All @@ -78,7 +78,7 @@ The default runtime names are `kompass_worktree_list`, `kompass_session_create`,

Set `adapters.opencode.navigator.enabled` to `false` to disable all Navigator tools.

Navigator accepts only sessions from the current OpenCode project and only worktrees returned by OpenCode. It rejects self-targeting lifecycle calls, arbitrary directories, main-checkout removal, unmanaged worktrees, and removal while a worktree has active sessions. `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.
Navigator accepts only sessions from the current OpenCode project and only worktrees returned by OpenCode. New sessions inherit the calling session's agent, model, and variant unless explicitly overridden. It rejects self-targeting lifecycle calls, arbitrary directories, unknown V2 agent overrides, 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.

Expand Down
70 changes: 56 additions & 14 deletions packages/opencode/navigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ type NavigatorContext = {

type NativeWorktree = { directory: string; name: string; branch?: string; id?: string; type: "worktree" | "rift" };

const explicitNavigatorUse = "Use only when the user explicitly asks to create or manage native OpenCode sessions, worktrees, or a multi-session workflow. Do not use for subagent delegation; use the built-in task tool instead.";

function failResponse(error: unknown, operation: string): never {
const message = error instanceof Error
? error.message
Expand All @@ -108,6 +110,15 @@ function envelopeData<T>(response: { data?: { data: T }; error?: unknown }, oper
return responseData(response, operation).data;
}

async function assertKnownV2Agent(client: NavigatorClient, directory: string, agent: string) {
const agents = envelopeData<Array<{ id: string }>>(
await client.v2.agent.list({ location: { directory } }),
"OpenCode agent list",
);
if (agents.some((item) => item.id === agent)) return;
throw new Error(`Unknown OpenCode agent "${agent}". Available agents: ${agents.map((item) => item.id).join(", ")}`);
}

function normalizeDirectory(directory: string) {
return path.resolve(directory);
}
Expand Down Expand Up @@ -471,7 +482,7 @@ export function createNavigatorTools(
const { client, ...navigator } = input;
return {
worktree_list: tool({
description: "List the current OpenCode project checkout and its managed native worktrees.",
description: `${explicitNavigatorUse} List the current OpenCode project checkout and its managed native worktrees.`,
args: {},
async execute() {
return json({
Expand All @@ -482,7 +493,7 @@ export function createNavigatorTools(
}),

session_create: tool({
description: "Create and asynchronously prompt a native OpenCode session in the checkout or a managed worktree.",
description: `${explicitNavigatorUse} 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", [
Expand All @@ -501,16 +512,31 @@ export function createNavigatorTools(
model: tool.schema.object({
providerID: tool.schema.string().min(1),
modelID: tool.schema.string().min(1),
variant: tool.schema.string().min(1).optional(),
}).optional(),
},
async execute(args) {
async execute(args, context) {
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`);
}

const caller = await getOwnedSession(client, navigator.projectID, context.sessionID).catch(() => undefined);
const selectedAgent = args.agent ?? caller?.agent;
const selectedModel = args.model ?? (caller?.model
? {
providerID: caller.model.providerID,
modelID: caller.model.id,
...(caller.model.variant ? { variant: caller.model.variant } : {}),
}
: undefined);

if (navigator.protocol === "v2" && selectedAgent) {
await assertKnownV2Agent(client, navigator.checkout, selectedAgent);
}

let directory = navigator.checkout;
let createdWorktree: NativeWorktree | undefined;
if (args.environment.type === "existing_worktree") {
Expand Down Expand Up @@ -567,8 +593,16 @@ export function createNavigatorTools(
) 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 } } : {}),
...(selectedAgent ? { agent: selectedAgent } : {}),
...(selectedModel
? {
model: {
providerID: selectedModel.providerID,
id: selectedModel.modelID,
...(selectedModel.variant ? { variant: selectedModel.variant } : {}),
},
}
: {}),
location: { directory },
}),
"OpenCode session create",
Expand All @@ -589,8 +623,13 @@ export function createNavigatorTools(
path: { id: session.id },
body: {
parts: [{ type: "text", text: args.prompt }],
...(args.agent ? { agent: args.agent } : {}),
...(args.model ? { model: args.model } : {}),
...(selectedAgent ? { agent: selectedAgent } : {}),
...(selectedModel
? {
model: { providerID: selectedModel.providerID, modelID: selectedModel.modelID },
...(selectedModel.variant ? { variant: selectedModel.variant } : {}),
}
: {}),
},
});
if (response.error !== undefined) failResponse(response.error, `OpenCode prompt admission for session ${session.id}`);
Expand Down Expand Up @@ -622,7 +661,7 @@ export function createNavigatorTools(
}),

session_list: tool({
description: "List native OpenCode sessions owned by the current project.",
description: `${explicitNavigatorUse} List native OpenCode sessions owned by the current project.`,
args: {
directory: tool.schema.string().optional(),
search: tool.schema.string().optional(),
Expand Down Expand Up @@ -655,7 +694,7 @@ export function createNavigatorTools(
}),

session_read: tool({
description: "Read a bounded page of recent messages from a current-project OpenCode session.",
description: `${explicitNavigatorUse} 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(),
Expand All @@ -669,7 +708,7 @@ export function createNavigatorTools(
}),

session_send: tool({
description: "Steer a prompt for an existing current-project OpenCode session, optionally switching agent or model first.",
description: `${explicitNavigatorUse} 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),
Expand All @@ -695,6 +734,7 @@ export function createNavigatorTools(
return json({ sessionID: args.sessionID, admitted: true });
}
if (args.agent) {
await assertKnownV2Agent(client, session.location.directory, args.agent);
const response = await client.v2.session.switchAgent({
sessionID: args.sessionID,
agent: args.agent,
Expand All @@ -718,7 +758,7 @@ export function createNavigatorTools(
}),

session_wait: tool({
description: "Wait for the first of one to eight current-project OpenCode sessions to become idle.",
description: `${explicitNavigatorUse} 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(),
Expand Down Expand Up @@ -762,7 +802,7 @@ export function createNavigatorTools(
}),

session_interrupt: tool({
description: "Interrupt active execution in a current-project OpenCode session.",
description: `${explicitNavigatorUse} 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");
Expand All @@ -776,7 +816,7 @@ export function createNavigatorTools(
}),

worktree_remove: tool({
description: "Remove an idle managed OpenCode worktree from the current project without force.",
description: `${explicitNavigatorUse} 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)) {
Expand Down Expand Up @@ -852,7 +892,9 @@ export async function getNavigatorCompatibilityWarning(
protocol: NavigatorProtocol,
legacyClient?: NavigatorLegacyClient,
) {
const v2Metadata = hasMethods(client.worktree, ["list"]) && hasMethods(client.v2?.session, ["list", "get"]);
const v2Metadata = hasMethods(client.worktree, ["list"])
&& hasMethods(client.v2?.agent, ["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"]);
Expand Down
71 changes: 71 additions & 0 deletions packages/opencode/test/navigator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ function createClient(overrides: Record<string, any> = {}) {
remove: async () => response(true),
},
v2: {
agent: {
list: async () => response({ location: { directory: "/repo" }, data: [{ id: "build" }, { id: "reviewer" }, { id: "worker" }] }),
},
session: {
active: async () => response({ data: {} }),
get: async ({ sessionID }: { sessionID: string }) => response({
Expand All @@ -59,6 +62,7 @@ function createClient(overrides: Record<string, any> = {}) {
};
for (const [key, value] of Object.entries(overrides)) {
if (key === "worktree") Object.assign(client.worktree, value);
else if (key === "agent") Object.assign(client.v2.agent, value);
else if (key === "session") Object.assign(client.v2.session, value);
else if (key === "legacySession") Object.assign(client.session, value);
else client[key] = value;
Expand All @@ -82,6 +86,14 @@ function context(sessionID = "caller", abort = new AbortController().signal) {
}

describe("Kompass Navigator", () => {
test("reserves Navigator tools for explicit native-session workflows", () => {
for (const definition of Object.values(tools())) {
assert.match(definition.description, /explicitly asks/);
assert.match(definition.description, /Do not use for subagent delegation/);
assert.match(definition.description, /built-in task tool/);
}
});

test("matches Desktop protocol detection", async () => {
const legacy = createClient({
global: { health: async () => response({ healthy: true }) },
Expand Down Expand Up @@ -172,6 +184,58 @@ describe("Kompass Navigator", () => {
assert.equal(prompts[0].prompt.text, "implement it");
});

test("inherits the calling session agent, model, and variant", async () => {
const creates: any[] = [];
const client = createClient({
session: {
get: async ({ sessionID }: { sessionID: string }) => response({
data: {
...session(sessionID),
agent: "reviewer",
model: { providerID: "openai", id: "gpt-5.6-sol", variant: "xhigh" },
},
}),
create: async (args: any) => {
creates.push(args);
return response({ data: session("created", args.location.directory) });
},
},
});

await (tools(client).session_create as any).execute({
prompt: "review it",
environment: { type: "checkout" },
}, context());

assert.equal(creates[0].agent, "reviewer");
assert.deepEqual(creates[0].model, {
providerID: "openai",
id: "gpt-5.6-sol",
variant: "xhigh",
});
});

test("rejects an unknown V2 agent before creating a worktree or session", async () => {
let worktreeCreates = 0;
let sessionCreates = 0;
const client = createClient({
agent: { list: async () => response({ location: { directory: "/repo" }, data: [{ id: "reviewer" }] }) },
worktree: { create: async () => { worktreeCreates += 1; return response({ directory: "/repo-new", name: "new" }); } },
session: { create: async () => { sessionCreates += 1; return response({ data: session("created") }); } },
});

await assert.rejects(
(tools(client).session_create as any).execute({
prompt: "review it",
agent: "review",
environment: { type: "new_worktree" },
}, context()),
/Unknown OpenCode agent "review".*reviewer/,
);
assert.equal(worktreeCreates, 0);
assert.equal(sessionCreates, 0);
});

test("uses one legacy transcript path when Desktop selects V1", async () => {
const prompts: any[] = [];
const client = createClient({
Expand Down Expand Up @@ -602,6 +666,13 @@ describe("Kompass Navigator", () => {
});

test("reports incompatible OpenCode runtime versions", async () => {
const missingAgentList = createClient();
delete missingAgentList.v2.agent.list;
assert.match(
await getNavigatorCompatibilityWarning(missingAgentList as never, "v2") ?? "",
/requires OpenCode 1\.17\.12 or newer/,
);

const client = createClient({
global: { health: async () => response({ version: "1.17.11" }) },
});
Expand Down
Loading
Loading