From 135ed860dcaba81d83bbc2dae00f4c8d9b195deb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 14 Jul 2026 17:14:55 +0200 Subject: [PATCH 1/6] feat(cli): support remote slash commands --- .../src/kilo-sessions/remote-command.ts | 287 +++++++++ .../src/kilo-sessions/remote-sender.ts | 97 +++ .../kilocode/sessions/remote-command.test.ts | 568 ++++++++++++++++++ .../kilocode/sessions/remote-sender.test.ts | 347 +++++++++++ 4 files changed, 1299 insertions(+) create mode 100644 packages/opencode/src/kilo-sessions/remote-command.ts create mode 100644 packages/opencode/test/kilocode/sessions/remote-command.test.ts diff --git a/packages/opencode/src/kilo-sessions/remote-command.ts b/packages/opencode/src/kilo-sessions/remote-command.ts new file mode 100644 index 00000000000..eef76df0f4b --- /dev/null +++ b/packages/opencode/src/kilo-sessions/remote-command.ts @@ -0,0 +1,287 @@ +import type { Info as CommandInfo } from "@/command" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import type { MessageV2 } from "@/session/message-v2" +import type { SessionPrompt } from "@/session/prompt" +import type { Info as SessionInfo } from "@/session/session" +import { MessageID, type SessionID } from "@/session/schema" +import z from "zod" + +export namespace RemoteCommand { + export const MAX_COMMANDS = 256 + export const MAX_STRING_LENGTH = 2_000 + export const MAX_ARGUMENTS_LENGTH = 32_768 + export const MAX_HINTS = 32 + export const MAX_RESULT_BYTES = 512 * 1024 + + export const ListRequest = z + .object({ + protocolVersion: z.literal(1), + }) + .strict() + + export const SendRequest = z + .object({ + protocolVersion: z.literal(1), + command: z.string().min(1).max(MAX_STRING_LENGTH), + arguments: z.string().max(MAX_ARGUMENTS_LENGTH), + messageID: z.string().startsWith("msg").max(MAX_STRING_LENGTH).optional(), + model: z + .object({ + providerID: z.string().min(1).max(MAX_STRING_LENGTH), + modelID: z.string().min(1).max(MAX_STRING_LENGTH), + }) + .strict() + .optional(), + variant: z.string().max(MAX_STRING_LENGTH).optional(), + }) + .strict() + export type SendRequest = z.infer + + export const Info = z + .object({ + name: z.string().min(1).max(MAX_STRING_LENGTH), + description: z.string().max(MAX_STRING_LENGTH).optional(), + agent: z.string().max(MAX_STRING_LENGTH).optional(), + model: z.string().max(MAX_STRING_LENGTH).optional(), + source: z.enum(["command", "mcp", "skill"]).optional(), + hints: z.array(z.string().max(MAX_STRING_LENGTH)).max(MAX_HINTS), + subtask: z.boolean().optional(), + }) + .strict() + export type Info = z.infer + + export const Response = z + .object({ + protocolVersion: z.literal(1), + commands: z.array(Info).max(MAX_COMMANDS), + }) + .strict() + export type Response = z.infer + + // The only entry from BUILTIN_COMMANDS (kilocode/session/builtin-commands) exposed + // remotely: `summarize` is a local alias for the same compaction flow, so listing + // both would just duplicate the suggestion. + const compact: Info = { + name: "compact", + description: "compact the current session context", + hints: [], + } + + function compare(a: Info, b: Info) { + if (a.name < b.name) return -1 + if (a.name > b.name) return 1 + return 0 + } + + // Truncates the alphabetical tail to stay within the count and byte caps. + // The `compact` entry is seeded first so truncation can never take remote + // compaction away; sizes are accumulated per entry to keep this a single pass. + function truncate(commands: Info[]): Info[] { + const encoder = new TextEncoder() + const measure = (value: unknown) => encoder.encode(JSON.stringify(value)).byteLength + const required = commands.find((item) => item.name === compact.name) ?? compact + const selected: Info[] = [required] + let budget = MAX_RESULT_BYTES - measure({ protocolVersion: 1, commands: [] }) - measure(required) + for (const item of commands) { + if (item === required) continue + if (selected.length >= MAX_COMMANDS) break + const bytes = measure(item) + 1 // +1 for the separating comma + if (bytes > budget) break + budget -= bytes + selected.push(item) + } + return selected.sort(compare) + } + + // Validates a single source against the catalog caps, dropping skills and + // entries whose fields exceed the per-field limits. Shared by build() and + // the compact shadow check so discovery and execution apply the same rules. + function parse(source: CommandInfo): Info | undefined { + if (source.source === "skill") return + const item = Info.safeParse({ + name: source.name, + ...(source.description !== undefined ? { description: source.description } : {}), + ...(source.agent !== undefined ? { agent: source.agent } : {}), + ...(source.model !== undefined ? { model: source.model } : {}), + ...(source.source !== undefined ? { source: source.source } : {}), + hints: source.hints, + ...(source.subtask !== undefined ? { subtask: source.subtask } : {}), + }) + return item.success ? item.data : undefined + } + + export function build(items: ReadonlyArray): Response { + const names = new Set() + const commands: Info[] = [] + + for (const source of items) { + const item = parse(source) + if (!item || names.has(item.name)) continue + names.add(item.name) + commands.push(item) + } + + // truncate() sorts its output and seeds the synthesized "compact" first, so + // the response stays alphabetized regardless of input order. + if (!names.has(compact.name)) commands.push(compact) + return Response.parse({ protocolVersion: 1, commands: truncate(commands) }) + } + + export type ExecuteInput = SendRequest & { sessionID: SessionID; catalog: Response } + + export type Services = { + list: () => Promise + command: (input: SessionPrompt.CommandInput) => Promise + session: { + get: (sessionID: SessionID) => Promise + messages: (sessionID: SessionID) => Promise + } + agent: { default: () => Promise } + provider: { default: () => Promise<{ providerID: string; modelID: string }> } + revert: { cleanup: (session: SessionInfo) => Promise } + compaction: { + create: (input: { + sessionID: SessionID + agent: string + model: { providerID: ProviderV2.ID; modelID: ModelV2.ID } + auto: boolean + }) => Promise + } + prompt: { loop: (sessionID: SessionID) => Promise } + } + + export type Interface = { + list: () => Promise + execute: (input: ExecuteInput) => Promise + } + + export function create(services: Services): Interface { + return { + list: async () => build(await services.list()), + execute: async (input) => { + // A registered command named `compact` shadows the built-in whenever it + // appears in the preflight catalog. The built-in "compact" sentinel + // carries no `source`, so checking for source=="command"|"mcp" rules it + // out and falls back to the built-in path. + const registeredCompact = input.catalog.commands.find( + (item) => item.name === compact.name && (item.source === "command" || item.source === "mcp"), + ) + const shadowed = input.command === compact.name && !!registeredCompact + if (input.command === compact.name && !shadowed) { + const session = await services.session.get(input.sessionID) + await services.revert.cleanup(session) + const messages = await services.session.messages(input.sessionID) + const user = messages.findLast((message) => message.info.role === "user") + const agent = + (user?.info.role === "user" ? user.info.agent : undefined) ?? + session.agent ?? + (await services.agent.default()) + const model = + input.model ?? + (session.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined) ?? + (user?.info.role === "user" + ? { providerID: user.info.model.providerID, modelID: user.info.model.modelID } + : undefined) ?? + (await services.provider.default()) + await services.compaction.create({ + sessionID: input.sessionID, + agent, + model: { + providerID: ProviderV2.ID.make(model.providerID), + modelID: ModelV2.ID.make(model.modelID), + }, + auto: false, + }) + await services.prompt.loop(input.sessionID) + return + } + await services.command({ + sessionID: input.sessionID, + command: input.command, + arguments: input.arguments, + ...(input.messageID ? { messageID: MessageID.make(input.messageID) } : {}), + ...(input.model ? { model: `${input.model.providerID}/${input.model.modelID}` } : {}), + ...(input.variant !== undefined ? { variant: input.variant } : {}), + }) + }, + } + } + + export function live(): Interface { + return create({ + list: async () => { + const [{ AppRuntime }, { Command }] = await Promise.all([import("@/effect/app-runtime"), import("@/command")]) + return AppRuntime.runPromise(Command.Service.use((service) => service.list())) + }, + command: async (input) => { + const [{ AppRuntime }, { SessionPrompt }] = await Promise.all([ + import("@/effect/app-runtime"), + import("@/session/prompt"), + ]) + await AppRuntime.runPromise(SessionPrompt.Service.use((service) => service.command(input))) + }, + session: { + get: async (sessionID) => { + const [{ AppRuntime }, { Session }] = await Promise.all([ + import("@/effect/app-runtime"), + import("@/session/session"), + ]) + return AppRuntime.runPromise(Session.Service.use((service) => service.get(sessionID))) + }, + messages: async (sessionID) => { + const [{ AppRuntime }, { Session }] = await Promise.all([ + import("@/effect/app-runtime"), + import("@/session/session"), + ]) + return AppRuntime.runPromise(Session.Service.use((service) => service.messages({ sessionID }))) + }, + }, + agent: { + default: async () => { + const [{ AppRuntime }, { Agent }] = await Promise.all([ + import("@/effect/app-runtime"), + import("@/agent/agent"), + ]) + return AppRuntime.runPromise(Agent.Service.use((service) => service.defaultAgent())) + }, + }, + provider: { + default: async () => { + const [{ AppRuntime }, { Provider }] = await Promise.all([ + import("@/effect/app-runtime"), + import("@/provider/provider"), + ]) + return AppRuntime.runPromise(Provider.Service.use((service) => service.defaultModel())) + }, + }, + revert: { + cleanup: async (session) => { + const [{ AppRuntime }, { SessionRevert }] = await Promise.all([ + import("@/effect/app-runtime"), + import("@/session/revert"), + ]) + await AppRuntime.runPromise(SessionRevert.Service.use((service) => service.cleanup(session))) + }, + }, + compaction: { + create: async (input) => { + const [{ AppRuntime }, { SessionCompaction }] = await Promise.all([ + import("@/effect/app-runtime"), + import("@/session/compaction"), + ]) + await AppRuntime.runPromise(SessionCompaction.Service.use((service) => service.create(input))) + }, + }, + prompt: { + loop: async (sessionID) => { + const [{ AppRuntime }, { SessionPrompt }] = await Promise.all([ + import("@/effect/app-runtime"), + import("@/session/prompt"), + ]) + await AppRuntime.runPromise(SessionPrompt.Service.use((service) => service.loop({ sessionID }))) + }, + }, + }) + } +} diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index 8d63294cabd..160026e1a35 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -1,3 +1,4 @@ +import { RemoteCommand } from "@/kilo-sessions/remote-command" import { RemoteModelCatalog } from "@/kilo-sessions/remote-model-catalog" import { RemoteProtocol } from "@/kilo-sessions/remote-protocol" import type { RemoteWS } from "@/kilo-sessions/remote-ws" @@ -43,6 +44,14 @@ const SuggestionData = z.object({ const decodeSessionID = Schema.decodeUnknownOption(SessionID) +// kilocode_change start - redact anything but the error class so messages/credentials +// never end up in logs +function errorName(error: unknown): string { + if (error instanceof Error && error.name) return error.name + return typeof error +} +// kilocode_change end + // kilocode_change start — lazy init to avoid circular dependency // (Server → RemoteRoutes → RemoteSender → SessionPrompt at module load time) type RemotePromptInput = Omit & { @@ -109,6 +118,7 @@ export namespace RemoteSender { readonly providers: () => Promise> readonly default: () => Promise } + commands?: RemoteCommand.Interface } export type Sender = { @@ -190,6 +200,9 @@ export namespace RemoteSender { return AppRuntime.runPromise(Session.Service.use((svc) => svc.children(sessionID))) }, } + // kilocode_change start - injectable slash command discovery + execution + const commands = options.commands ?? RemoteCommand.live() + // kilocode_change end const sub = options.subscribe ?? @@ -369,6 +382,90 @@ export namespace RemoteSender { } function dispatch(msg: RemoteProtocol.Command) { + // kilocode_change start - slash command discovery and execution + if (msg.command === "list_commands") { + const parsed = RemoteCommand.ListRequest.safeParse(msg.data) + const session = msg.sessionId ? decodeSessionID(msg.sessionId) : Option.none() + if (!parsed.success || Option.isNone(session)) { + options.conn.send({ + type: "response", + id: msg.id, + error: "invalid list_commands request", + }) + return + } + const run = options.provide ?? provide + void (async () => { + try { + const info = await catalog.get(session.value) + const result = await run({ directory: info.directory, fn: () => commands.list() }) + options.conn.send({ type: "response", id: msg.id, result }) + } catch (error) { + options.log.error("list commands failed", { id: msg.id, error: errorName(error) }) + options.conn.send({ type: "response", id: msg.id, error: "failed to list commands" }) + } + })() + return + } + if (msg.command === "send_command") { + const parsed = RemoteCommand.SendRequest.safeParse(msg.data) + const session = msg.sessionId ? decodeSessionID(msg.sessionId) : Option.none() + if (!parsed.success || Option.isNone(session)) { + options.conn.send({ + type: "response", + id: msg.id, + error: "invalid send_command request", + }) + return + } + const run = options.provide ?? provide + const state = { acked: false } + void (async () => { + try { + const info = await catalog.get(session.value) + await run({ + directory: info.directory, + fn: async () => { + // Reject stale catalog entries (command deleted or renamed since the + // client listed) before the ACK — after it, failures are only logged. + const available = await commands.list() + if (!available.commands.some((item) => item.name === parsed.data.command)) { + options.conn.send({ type: "response", id: msg.id, error: "unknown slash command" }) + return + } + state.acked = true + options.conn.send({ type: "response", id: msg.id, result: {} }) + try { + await commands.execute({ ...parsed.data, sessionID: session.value, catalog: available }) + } catch (error) { + options.log.error("send command failed after ACK", { + id: msg.id, + operation: "send_command", + error: errorName(error), + }) + } + }, + }) + } catch (error) { + if (state.acked) { + options.log.error("send command context failed after ACK", { + id: msg.id, + operation: "send_command", + error: errorName(error), + }) + return + } + options.log.error("send command preflight failed", { + id: msg.id, + operation: "send_command", + error: errorName(error), + }) + options.conn.send({ type: "response", id: msg.id, error: "failed to send command" }) + } + })() + return + } + // kilocode_change end if (msg.command === "list_models") { const parsed = RemoteModelCatalog.Request.safeParse(msg.data) const session = msg.sessionId ? decodeSessionID(msg.sessionId) : Option.none() diff --git a/packages/opencode/test/kilocode/sessions/remote-command.test.ts b/packages/opencode/test/kilocode/sessions/remote-command.test.ts new file mode 100644 index 00000000000..150bf80296c --- /dev/null +++ b/packages/opencode/test/kilocode/sessions/remote-command.test.ts @@ -0,0 +1,568 @@ +import { describe, expect, test } from "bun:test" +import { RemoteCommand } from "../../../src/kilo-sessions/remote-command" +import type { Info as SessionInfo } from "../../../src/session/session" +import { SessionID } from "../../../src/session/schema" + +describe("RemoteCommand", () => { + test("validates list command protocol requests", () => { + expect(RemoteCommand.ListRequest.safeParse({ protocolVersion: 1 }).success).toBe(true) + expect(RemoteCommand.ListRequest.safeParse({ protocolVersion: 2 }).success).toBe(false) + expect(RemoteCommand.ListRequest.safeParse({ protocolVersion: 1, extra: true }).success).toBe(false) + }) + + test("validates structured command requests without duplicating session identity", () => { + const valid = { + protocolVersion: 1 as const, + command: "review", + arguments: " main\nkeep spacing ", + messageID: "msg_remote", + model: { providerID: "kilo", modelID: "anthropic/claude-sonnet-4" }, + variant: "high", + } + + expect(RemoteCommand.SendRequest.parse(valid)).toEqual(valid) + expect(RemoteCommand.SendRequest.safeParse({ ...valid, protocolVersion: 2 }).success).toBe(false) + expect(RemoteCommand.SendRequest.safeParse({ ...valid, sessionID: "ses_duplicate" }).success).toBe(false) + expect(RemoteCommand.SendRequest.safeParse({ ...valid, messageID: "bad" }).success).toBe(false) + expect( + RemoteCommand.SendRequest.safeParse({ ...valid, arguments: "x".repeat(RemoteCommand.MAX_ARGUMENTS_LENGTH + 1) }) + .success, + ).toBe(false) + expect( + RemoteCommand.SendRequest.safeParse({ ...valid, messageID: "msg" + "x".repeat(RemoteCommand.MAX_STRING_LENGTH) }) + .success, + ).toBe(false) + }) + + test("builds an allowlisted command catalog", () => { + const catalog = RemoteCommand.build([ + { + name: "review", + description: "Review changes", + agent: "reviewer", + model: "kilo/review-model", + source: "command", + hints: ["$ARGUMENTS"], + subtask: true, + template: "must-not-leak", + }, + { + name: "alpha", + description: "MCP prompt", + source: "mcp", + hints: ["$1"], + get template(): string { + throw new Error("template must not be read") + }, + }, + { + name: "secret-skill", + description: "Skill", + source: "skill", + hints: [], + template: "must-not-leak", + }, + { + name: "review", + description: "Duplicate", + source: "mcp", + hints: [], + template: "must-not-leak", + }, + ]) + + expect(catalog).toEqual({ + protocolVersion: 1, + commands: [ + { + name: "alpha", + description: "MCP prompt", + source: "mcp", + hints: ["$1"], + }, + { + name: "compact", + description: "compact the current session context", + hints: [], + }, + { + name: "review", + description: "Review changes", + agent: "reviewer", + model: "kilo/review-model", + source: "command", + hints: ["$ARGUMENTS"], + subtask: true, + }, + ], + }) + expect(JSON.stringify(catalog)).not.toContain("template") + expect(JSON.stringify(catalog)).not.toContain("secret-skill") + }) + + test("truncates catalogs over the command limit", () => { + const commands = Array.from({ length: RemoteCommand.MAX_COMMANDS + 10 }, (_, index) => ({ + name: `command-${String(index).padStart(3, "0")}`, + source: "command" as const, + hints: [], + template: "hidden", + })) + + const catalog = RemoteCommand.build(commands) + expect(catalog.commands).toHaveLength(RemoteCommand.MAX_COMMANDS) + expect(catalog.commands[0]?.name).toBe("command-000") + expect(catalog.commands.some((item) => item.name === "compact")).toBe(true) + }) + + test("skips entries over the per-field caps instead of failing the catalog", () => { + const catalog = RemoteCommand.build([ + { + name: "x".repeat(RemoteCommand.MAX_STRING_LENGTH + 1), + source: "command", + hints: [], + template: "hidden", + }, + { + name: "too-many-hints", + source: "command", + hints: Array.from({ length: RemoteCommand.MAX_HINTS + 1 }, () => "$ARGUMENTS"), + template: "hidden", + }, + { + name: "review", + source: "command", + hints: ["$ARGUMENTS"], + template: "hidden", + }, + ]) + + expect(catalog.commands.map((item) => item.name)).toEqual(["compact", "review"]) + }) + + test("truncates to the serialized catalog limit measured in UTF-8 bytes", () => { + const commands = Array.from({ length: RemoteCommand.MAX_COMMANDS - 1 }, (_, index) => ({ + name: `command-${index}`, + description: "🧪".repeat(900), + source: "command" as const, + hints: [], + template: "hidden", + })) + + const catalog = RemoteCommand.build(commands) + expect(catalog.commands.length).toBeGreaterThan(0) + expect(catalog.commands.length).toBeLessThan(commands.length) + expect(catalog.commands.some((item) => item.name === "compact")).toBe(true) + expect(new TextEncoder().encode(JSON.stringify(catalog)).byteLength).toBeLessThanOrEqual( + RemoteCommand.MAX_RESULT_BYTES, + ) + }) + + test("executes registered commands with verbatim structured input", async () => { + const calls: unknown[] = [] + const remote = RemoteCommand.create({ + list: async () => [], + command: async (input) => { + calls.push(input) + }, + session: { + get: async () => { + throw new Error("unexpected session lookup") + }, + messages: async () => { + throw new Error("unexpected message lookup") + }, + }, + agent: { default: async () => "unexpected-agent" }, + provider: { default: async () => ({ providerID: "unexpected", modelID: "unexpected" }) }, + revert: { cleanup: async () => {} }, + compaction: { create: async () => {} }, + prompt: { loop: async () => {} }, + }) + + await remote.execute({ + sessionID: SessionID.make("ses_remote"), + protocolVersion: 1, + command: "review", + arguments: " main\nkeep spacing ", + messageID: "msg_remote", + model: { providerID: "custom:edge", modelID: "deployment/model" }, + variant: "high", + catalog: { protocolVersion: 1, commands: [{ name: "review", hints: [] }] }, + }) + + expect(calls).toEqual([ + { + sessionID: SessionID.make("ses_remote"), + command: "review", + arguments: " main\nkeep spacing ", + messageID: "msg_remote", + model: "custom:edge/deployment/model", + variant: "high", + }, + ]) + }) + + test("lets a registered compact command shadow the built-in", async () => { + const calls: unknown[] = [] + const remote = RemoteCommand.create({ + list: async () => [{ name: "compact", source: "command", hints: [], template: "custom compact" }], + command: async (input) => { + calls.push(input) + }, + session: { + get: async () => { + throw new Error("unexpected session lookup") + }, + messages: async () => { + throw new Error("unexpected message lookup") + }, + }, + agent: { default: async () => "unexpected-agent" }, + provider: { default: async () => ({ providerID: "unexpected", modelID: "unexpected" }) }, + revert: { cleanup: async () => {} }, + compaction: { + create: async () => { + throw new Error("unexpected built-in compaction") + }, + }, + prompt: { loop: async () => {} }, + }) + + await remote.execute({ + sessionID: SessionID.make("ses_remote"), + protocolVersion: 1, + command: "compact", + arguments: "", + catalog: { protocolVersion: 1, commands: [{ name: "compact", source: "command", hints: [] }] }, + }) + + expect(calls).toEqual([ + { + sessionID: SessionID.make("ses_remote"), + command: "compact", + arguments: "", + }, + ]) + }) + + test("a registered compact in the preflight catalog shadows the built-in", async () => { + const calls: unknown[] = [] + const steps: unknown[] = [] + const session = { + id: SessionID.make("ses_remote"), + agent: "session-agent", + model: { providerID: "session-provider", id: "session-model" }, + } as SessionInfo + const remote = RemoteCommand.create({ + list: async () => [], + command: async (input) => { + calls.push(input) + }, + session: { + get: async () => { + steps.push("get") + return session + }, + messages: async () => { + steps.push("messages") + return [] + }, + }, + agent: { default: async () => "unexpected-agent" }, + provider: { default: async () => ({ providerID: "unexpected", modelID: "unexpected" }) }, + revert: { + cleanup: async () => { + steps.push("cleanup") + }, + }, + compaction: { + create: async () => { + steps.push("create") + }, + }, + prompt: { + loop: async () => { + steps.push("loop") + }, + }, + }) + + const catalog = RemoteCommand.build([ + { + name: "compact", + source: "command", + hints: [], + template: "custom compact", + }, + ]) + expect(catalog.commands.some((item) => item.name === "compact")).toBe(true) + + await remote.execute({ + sessionID: SessionID.make("ses_remote"), + protocolVersion: 1, + command: "compact", + arguments: "", + catalog, + }) + + // No compact path steps were taken — the registered command was invoked instead. + expect(steps).toEqual([]) + expect(calls).toEqual([ + { + sessionID: SessionID.make("ses_remote"), + command: "compact", + arguments: "", + }, + ]) + }) + + test("a built-in compact in the preflight catalog falls back to the built-in path", async () => { + const steps: unknown[] = [] + const session = { + id: SessionID.make("ses_remote"), + agent: "session-agent", + model: { providerID: "session-provider", id: "session-model" }, + } as SessionInfo + const remote = RemoteCommand.create({ + list: async () => [], + command: async () => { + throw new Error("unexpected registered command") + }, + session: { + get: async () => { + steps.push("get") + return session + }, + messages: async () => { + steps.push("messages") + return [] + }, + }, + agent: { default: async () => "default-agent" }, + provider: { default: async () => ({ providerID: "default-provider", modelID: "default-model" }) }, + revert: { + cleanup: async () => { + steps.push("cleanup") + }, + }, + compaction: { + create: async () => { + steps.push("create") + }, + }, + prompt: { + loop: async () => { + steps.push("loop") + }, + }, + }) + + // The preflight catalog contains only the synthesized built-in "compact" (no source). + const catalog = RemoteCommand.build([]) + expect(catalog.commands.find((item) => item.name === "compact")).toEqual({ + name: "compact", + description: "compact the current session context", + hints: [], + }) + + await remote.execute({ + sessionID: SessionID.make("ses_remote"), + protocolVersion: 1, + command: "compact", + arguments: "", + catalog, + }) + + expect(steps).toEqual(["get", "cleanup", "messages", "create", "loop"]) + }) + + test("executes compact through cleanup, compaction, and prompt loop", async () => { + const steps: unknown[] = [] + const session = { + id: SessionID.make("ses_remote"), + agent: "session-agent", + model: { providerID: "session-provider", id: "session-model" }, + } as SessionInfo + const remote = RemoteCommand.create({ + list: async () => [], + command: async () => { + throw new Error("unexpected registered command") + }, + session: { + get: async () => { + steps.push("get") + return session + }, + messages: async () => { + steps.push("messages") + return [] + }, + }, + agent: { default: async () => "default-agent" }, + provider: { default: async () => ({ providerID: "default-provider", modelID: "default-model" }) }, + revert: { + cleanup: async (info) => { + steps.push(["cleanup", info.id]) + }, + }, + compaction: { + create: async (input) => { + steps.push(["create", input]) + }, + }, + prompt: { + loop: async (sessionID) => { + steps.push(["loop", sessionID]) + }, + }, + }) + + await remote.execute({ + sessionID: SessionID.make("ses_remote"), + protocolVersion: 1, + command: "compact", + arguments: "", + model: { providerID: "request-provider", modelID: "request-model" }, + catalog: { protocolVersion: 1, commands: [{ name: "compact", hints: [] }] }, + }) + + expect(steps).toEqual([ + "get", + ["cleanup", SessionID.make("ses_remote")], + "messages", + [ + "create", + { + sessionID: SessionID.make("ses_remote"), + agent: "session-agent", + model: { providerID: "request-provider", modelID: "request-model" }, + auto: false, + }, + ], + ["loop", SessionID.make("ses_remote")], + ]) + }) + + test("compact fallback uses the latest retained user message when no request or session overrides exist", async () => { + const steps: unknown[] = [] + const session = { + id: SessionID.make("ses_remote"), + // No session.agent and no session.model + } as unknown as SessionInfo + const retained = [ + // older assistant turn retained (must be ignored) + { + info: { id: "msg_old", role: "assistant", sessionID: SessionID.make("ses_remote") }, + parts: [], + }, + // the latest retained user message supplies agent + model + { + info: { + id: "msg_user", + role: "user", + sessionID: SessionID.make("ses_remote"), + agent: "user-agent", + model: { providerID: "user-provider", modelID: "user-model" }, + }, + parts: [], + }, + ] as any + const remote = RemoteCommand.create({ + list: async () => [], + command: async () => { + throw new Error("unexpected registered command") + }, + session: { + get: async () => { + steps.push("get") + return session + }, + messages: async () => { + steps.push("messages") + return retained + }, + }, + agent: { default: async () => "default-agent" }, + provider: { default: async () => ({ providerID: "default-provider", modelID: "default-model" }) }, + revert: { cleanup: async () => {} }, + compaction: { + create: async (input) => { + steps.push(["create", input]) + }, + }, + prompt: { loop: async () => {} }, + }) + + await remote.execute({ + sessionID: SessionID.make("ses_remote"), + protocolVersion: 1, + command: "compact", + arguments: "", + catalog: { protocolVersion: 1, commands: [{ name: "compact", hints: [] }] }, + }) + + expect(steps).toEqual([ + "get", + "messages", + [ + "create", + { + sessionID: SessionID.make("ses_remote"), + agent: "user-agent", + model: { providerID: "user-provider", modelID: "user-model" }, + auto: false, + }, + ], + ]) + }) + + test("execute reuses the supplied catalog and never calls services.list() a second time", async () => { + let listCalls = 0 + const list = async () => { + listCalls++ + return [] + } + const calls: unknown[] = [] + const remote = RemoteCommand.create({ + list, + command: async (input) => { + calls.push(input) + }, + session: { + get: async () => { + throw new Error("unexpected session lookup") + }, + messages: async () => { + throw new Error("unexpected message lookup") + }, + }, + agent: { default: async () => "default-agent" }, + provider: { default: async () => ({ providerID: "default-provider", modelID: "default-model" }) }, + revert: { cleanup: async () => {} }, + compaction: { create: async () => {} }, + prompt: { loop: async () => {} }, + }) + + const catalog: RemoteCommand.Response = { + protocolVersion: 1, + commands: [{ name: "review", hints: [] }], + } + + await remote.execute({ + sessionID: SessionID.make("ses_remote"), + protocolVersion: 1, + command: "review", + arguments: "", + catalog, + }) + + expect(listCalls).toBe(0) + expect(calls).toEqual([ + { + sessionID: SessionID.make("ses_remote"), + command: "review", + arguments: "", + }, + ]) + }) +}) diff --git a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts index ea24ef007b6..d47ae11f279 100644 --- a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts @@ -1732,3 +1732,350 @@ describe("RemoteSender", () => { expect(sent).toHaveLength(0) }) }) + +// kilocode_change start - remote slash command discovery and execution +describe("RemoteSender slash commands", () => { + // Wraps a Connection to expose a promise that resolves when a response with the + // given id is sent. Keeps new tests deterministic without setTimeout polling. + // Wrappers chain so multiple pending responses can be awaited on the same + // connection; the original fakeConn.send still records each emission once. + function expectResponse(conn: RemoteWS.Connection, _sent: any[], id: string) { + const resolvers = Promise.withResolvers() + const previous = conn.send.bind(conn) + const spy = (message: any) => { + if (message?.type === "response" && message.id === id) resolvers.resolve(message) + previous(message) + } + conn.send = spy as any + return { + promise: resolvers.promise, + restore: () => { + conn.send = previous + }, + } + } + + test("list_commands validates v1 and returns the bounded catalog from the target session directory", async () => { + const { conn, sent } = fakeConn() + const dirs: string[] = [] + const state = { directory: "" } + const first = expectResponse(conn, sent, "req_commands_first") + const second = expectResponse(conn, sent, "req_commands_second") + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => { + dirs.push(input.directory) + state.directory = input.directory + const result = await input.fn() + state.directory = "" + return result + }, + commands: { + list: async () => ({ + protocolVersion: 1 as const, + commands: [{ name: state.directory.endsWith("first") ? "first" : "second", hints: [] }], + }), + execute: async () => {}, + }, + catalog: { + get: async (sessionID) => + ({ + id: sessionID, + directory: sessionID === SessionID.make("ses_first") ? "/workspace/first" : "/workspace/second", + }) as any, + messages: async () => [], + providers: async () => ({}), + default: async () => undefined, + }, + }) + + sender.handle({ + type: "command", + id: "req_commands_first", + command: "list_commands", + sessionId: "ses_first", + data: { protocolVersion: 1 }, + }) + await first.promise + sender.handle({ + type: "command", + id: "req_commands_second", + command: "list_commands", + sessionId: "ses_second", + data: { protocolVersion: 1 }, + }) + await second.promise + + first.restore() + second.restore() + + expect(dirs).toEqual(["/workspace/first", "/workspace/second"]) + expect(sent).toEqual([ + { + type: "response", + id: "req_commands_first", + result: { protocolVersion: 1, commands: [{ name: "first", hints: [] }] }, + }, + { + type: "response", + id: "req_commands_second", + result: { protocolVersion: 1, commands: [{ name: "second", hints: [] }] }, + }, + ]) + }) + + test("list_commands rejects unsupported protocol versions and missing session IDs before ACK", () => { + const { conn, sent } = fakeConn() + const sender = RemoteSender.create({ + conn, + directory: "/tmp/test", + log: nolog, + subscribe: fakeBus().subscribe, + commands: { + list: async () => ({ protocolVersion: 1 as const, commands: [] }), + execute: async () => {}, + }, + }) + + sender.handle({ + type: "command", + id: "req_commands_v2", + command: "list_commands", + sessionId: "ses_x", + data: { protocolVersion: 2 }, + }) + sender.handle({ + type: "command", + id: "req_commands_no_session", + command: "list_commands", + data: { protocolVersion: 1 }, + }) + sender.handle({ + type: "command", + id: "req_commands_invalid_session", + command: "list_commands", + sessionId: "not-a-session-id", + data: { protocolVersion: 1 }, + }) + + expect(sent).toEqual([ + { type: "response", id: "req_commands_v2", error: "invalid list_commands request" }, + { type: "response", id: "req_commands_no_session", error: "invalid list_commands request" }, + { type: "response", id: "req_commands_invalid_session", error: "invalid list_commands request" }, + ]) + }) + + test("send_command validates protocol, session, and catalog membership before ACK", async () => { + const { conn, sent } = fakeConn() + const calls: unknown[] = [] + const started = Promise.withResolvers() + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + commands: { + list: async () => ({ protocolVersion: 1 as const, commands: [{ name: "review", hints: [] }] }), + execute: async (input) => { + calls.push(input) + started.resolve() + await new Promise(() => {}) + }, + }, + catalog: { + get: async () => ({ id: SessionID.make("ses_commands"), directory: "/workspace/project-a" }) as any, + messages: async () => [], + providers: async () => ({}), + default: async () => undefined, + }, + }) + + // Pre-ACK: invalid protocol + sender.handle({ + type: "command", + id: "req_bad_version", + command: "send_command", + sessionId: "ses_commands", + data: { protocolVersion: 2, command: "review", arguments: "main" }, + }) + // Pre-ACK: missing session id + sender.handle({ + type: "command", + id: "req_no_session", + command: "send_command", + data: { protocolVersion: 1, command: "review", arguments: "main" }, + }) + + // Wait for the async catalog preflight that rejects the unknown command and ACKs the valid one. + const unknownResponse = expectResponse(conn, sent, "req_unknown") + const ackResponse = expectResponse(conn, sent, "req_send_command") + + // Pre-ACK: command not in catalog + sender.handle({ + type: "command", + id: "req_unknown", + command: "send_command", + sessionId: "ses_commands", + data: { protocolVersion: 1, command: "missing", arguments: "main" }, + }) + // Valid path: ACK first + sender.handle({ + type: "command", + id: "req_send_command", + command: "send_command", + sessionId: "ses_commands", + data: { + protocolVersion: 1, + command: "review", + arguments: " main ", + messageID: "msg_remote", + }, + }) + + expect(sent.slice(0, 2)).toEqual([ + { type: "response", id: "req_bad_version", error: "invalid send_command request" }, + { type: "response", id: "req_no_session", error: "invalid send_command request" }, + ]) + + await unknownResponse.promise + unknownResponse.restore() + await ackResponse.promise + ackResponse.restore() + + expect(sent.find((m: any) => m.id === "req_unknown")).toEqual({ + type: "response", + id: "req_unknown", + error: "unknown slash command", + }) + expect(sent.find((m: any) => m.id === "req_send_command")).toEqual({ + type: "response", + id: "req_send_command", + result: {}, + }) + + await started.promise + expect(calls).toEqual([ + { + sessionID: SessionID.make("ses_commands"), + protocolVersion: 1, + command: "review", + arguments: " main ", + messageID: "msg_remote", + catalog: { protocolVersion: 1, commands: [{ name: "review", hints: [] }] }, + }, + ]) + }) + + test("send_command returns a sanitized error without ACK when the session is missing", async () => { + const { conn, sent } = fakeConn() + const dirs: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => { + dirs.push(input.directory) + return input.fn() + }, + commands: { + list: async () => ({ protocolVersion: 1 as const, commands: [{ name: "review", hints: [] }] }), + execute: async () => { + throw new Error("must not execute") + }, + }, + catalog: { + get: async () => { + throw new Error("private lookup detail with secret") + }, + messages: async () => [], + providers: async () => ({}), + default: async () => undefined, + }, + }) + + const response = expectResponse(conn, sent, "req_missing_command") + sender.handle({ + type: "command", + id: "req_missing_command", + command: "send_command", + sessionId: "ses_missing", + data: { protocolVersion: 1, command: "review", arguments: "main" }, + }) + + await response.promise + response.restore() + + expect(dirs).toEqual([]) + expect(sent).toEqual([{ type: "response", id: "req_missing_command", error: "failed to send command" }]) + expect(JSON.stringify(sent)).not.toContain("private lookup detail with secret") + }) + + test("send_command logs only the error class after ACK and does not leak arguments or tokens", async () => { + class CredentialLeakError extends Error { + override name = "CredentialLeakError" + } + const { conn, sent } = fakeConn() + const logEntries: unknown[][] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: { + ...nolog, + error: (...args: unknown[]) => logEntries.push(args), + }, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + commands: { + list: async () => ({ protocolVersion: 1 as const, commands: [{ name: "review", hints: [] }] }), + execute: async () => { + throw new CredentialLeakError("credential=must-not-leak") + }, + }, + catalog: { + get: async () => ({ id: SessionID.make("ses_commands"), directory: "/workspace/project-a" }) as any, + messages: async () => [], + providers: async () => ({}), + default: async () => undefined, + }, + }) + + const ack = expectResponse(conn, sent, "req_failed_command") + sender.handle({ + type: "command", + id: "req_failed_command", + command: "send_command", + sessionId: "ses_commands", + data: { + protocolVersion: 1, + command: "review", + arguments: "secret=token-must-not-leak", + model: { providerID: "custom/edge", modelID: "deployment/model" }, + }, + }) + + await ack.promise + // The post-ACK throw is logged via the same adapter; give the microtask a + // chance to drain so the log entry is captured before assertions. + await Promise.resolve() + ack.restore() + + expect(sent).toEqual([{ type: "response", id: "req_failed_command", result: {} }]) + expect(logEntries).toHaveLength(1) + expect(logEntries[0]?.[0]).toBe("send command failed after ACK") + expect(logEntries[0]?.[1]).toEqual({ + id: "req_failed_command", + operation: "send_command", + error: "CredentialLeakError", + }) + const flattened = JSON.stringify(logEntries) + expect(flattened).not.toContain("token-must-not-leak") + expect(flattened).not.toContain("credential=must-not-leak") + expect(flattened).not.toContain("secret=") + }) +}) +// kilocode_change end From 75159877458d9ca2788a7298371f2c34a8c6e219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 14 Jul 2026 18:00:13 +0200 Subject: [PATCH 2/6] feat(cli): create sessions from remote clients --- .changeset/remote-session-slash-commands.md | 5 + .../src/kilo-sessions/attached-state.ts | 125 ++++++ .../src/kilo-sessions/kilo-sessions.ts | 54 ++- .../src/kilo-sessions/remote-sender.ts | 75 ++++ .../kilocode/sessions/attached-state.test.ts | 342 +++++++++++++++ .../kilocode/sessions/remote-sender.test.ts | 410 ++++++++++++++++++ 6 files changed, 999 insertions(+), 12 deletions(-) create mode 100644 .changeset/remote-session-slash-commands.md create mode 100644 packages/opencode/src/kilo-sessions/attached-state.ts create mode 100644 packages/opencode/test/kilocode/sessions/attached-state.test.ts diff --git a/.changeset/remote-session-slash-commands.md b/.changeset/remote-session-slash-commands.md new file mode 100644 index 00000000000..eb48e0c9380 --- /dev/null +++ b/.changeset/remote-session-slash-commands.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Remote CLI: expose slash command discovery, execution, and `/new` session creation over the relay. `list_commands` and `send_command` (including the built-in `compact` flow) are scoped to the current session's directory. `create_session` creates a root session in that directory, attaches it to the relay heartbeat set, and returns the new session id only after the heartbeat completes so the mobile client can navigate immediately. Failures are sanitized; the command is not auto-retried and the user may retry manually after a transient relay failure. diff --git a/packages/opencode/src/kilo-sessions/attached-state.ts b/packages/opencode/src/kilo-sessions/attached-state.ts new file mode 100644 index 00000000000..8972a9b47c1 --- /dev/null +++ b/packages/opencode/src/kilo-sessions/attached-state.ts @@ -0,0 +1,125 @@ +// kilocode_change - extracted state machine that separates presence-owned +// attached session ids from newly-created (pending) session announcements. +// `setPresence` (driven by the presence service) is authoritative for the +// presence set and adopts any pending ids it now covers. `announce` (driven +// by the create_session command) is duplicate-safe across both sets and rolls +// back only its own pending entry on heartbeat failure so a presence-owned id +// is never removed by a remote create failure. +// +// Concurrency invariant: `lastSentKey` is the union the relay last observed +// through a successfully completed heartbeat. It is updated: +// - synchronously by `setPresence` (fire-and-forget, but the union we +// record is the current union which includes any in-flight pending ids, +// so a later `setPresence` with the same union correctly skips) +// - by `announce` only on a successful awaited heartbeat +// It is NEVER updated on: +// - the synchronous prefix of `announce` (would let a concurrent +// `setPresence` skip its heartbeat because the cache falsely claims the +// relay is up to date, leaving the relay desynced if the announce then +// fails) +// - the failure branch of `announce` (the relay never saw the new union, +// and a concurrent `setPresence` may have already advanced lastSentKey to +// a newer state via its own fire-and-forget heartbeat) + +export namespace AttachedState { + export type Options = { + /** Fires the relay heartbeat. May be fire-and-forget or awaited. Must + * reject (not resolve) when no relay connection is available so that + * `announce` cannot silently mark a session as attached. */ + heartbeat: () => Promise + log?: { warn: (msg: string, meta?: unknown) => void } + } + + export type Interface = { + /** Replace the presence-owned set. Adopts any pending ids now covered by + * presence and fires a heartbeat if and only if the current union + * diverges from `lastSentKey`. The fire-and-forget heartbeat's union + * is recorded into `lastSentKey` synchronously (the current union + * already includes any in-flight pending ids). */ + setPresence(ids: readonly string[]): void + /** Awaitable duplicate-safe announcement. No-ops when the id is already + * present in either set. On heartbeat failure rolls back only its own + * pending entry, does NOT touch `lastSentKey`, and re-throws. On + * success advances `lastSentKey` to the current union. */ + announce(id: string): Promise + /** Current union of presence ∪ pending for the next heartbeat payload. */ + union(): ReadonlySet + /** Clear both sets across a connection lifecycle. The next setPresence + * call after reset will fire a heartbeat because the baseline key is + * empty. */ + reset(): void + } + + function keyOf(ids: Iterable): string { + return [...ids].sort().join("|") + } + + export function create(options: Options): Interface { + const presence = new Set() + const pending = new Set() + let lastSentKey = "" + + function union(): Set { + const out = new Set(presence) + for (const id of pending) out.add(id) + return out + } + + function fireHeartbeat() { + void options.heartbeat().catch((err) => + options.log?.warn("attached-state heartbeat failed", { error: String(err) }), + ) + } + + return { + setPresence(ids) { + const next = new Set(ids) + presence.clear() + for (const id of next) presence.add(id) + // Adopt any pending ids that presence now covers so the relay does + // not receive redundant heartbeat updates for ids it already knows. + for (const id of [...pending]) { + if (presence.has(id)) pending.delete(id) + } + const key = keyOf(union()) + if (key === lastSentKey) return + // Record the union synchronously so a subsequent setPresence with + // the same union is a no-op. The union already includes any + // in-flight pending ids, so a concurrent announce cannot poison it. + lastSentKey = key + fireHeartbeat() + }, + + async announce(id) { + if (presence.has(id) || pending.has(id)) return + pending.add(id) + try { + await options.heartbeat() + } catch (err) { + // Roll back only the entry this call added. A presence-owned id is + // never reachable here because the early return above guards it. + // Do NOT touch lastSentKey: the relay never observed the new + // union, and a concurrent setPresence may have already advanced + // it. Overwriting it here would clobber that newer state. + pending.delete(id) + throw err + } + // Success: the relay now has the union that includes this id. + // Advance lastSentKey so the next setPresence with the same union + // is a no-op. The id stays in `pending` until presence adopts it; + // this keeps the union stable across presence churn. + lastSentKey = keyOf(union()) + }, + + union() { + return union() + }, + + reset() { + presence.clear() + pending.clear() + lastSentKey = "" + }, + } + } +} diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index 016e5b9bc19..65f4fcd95a7 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -25,6 +25,7 @@ import { Vcs } from "@/project/vcs" import simpleGit from "simple-git" import { RemoteWS } from "@/kilo-sessions/remote-ws" import { RemoteSender } from "@/kilo-sessions/remote-sender" +import { AttachedState } from "@/kilo-sessions/attached-state" import { SessionStatus } from "@/session/status" import { Telemetry } from "@kilocode/kilo-telemetry" import { Question } from "@/question" @@ -38,11 +39,8 @@ async function provide(input: { directory: string; fn: () => R }): Promise return provide(input) } -function same(a: Set, b: Set): boolean { - if (a.size !== b.size) return false - for (const id of a) if (!b.has(id)) return false - return true -} +// kilocode_change removed: `same` helper is no longer used now that the +// presence/pending set logic lives in AttachedState. export namespace KiloSessions { export const Event = { @@ -62,6 +60,11 @@ export namespace KiloSessions { export class Service extends Context.Service()("@kilocode/KiloSessions") {} const log = Log.create({ service: "kilo-sessions" }) + // kilocode_change - narrow `log` to the warn-only shape AttachedState needs. + // The full Logger has a typed `extra` arg that does not match the generic + // `meta?: unknown` contract; the warn body is forwarded to log.warn via an + // `unknown` cast below. + const attachedLog = { warn: (msg: string, meta?: unknown) => log.warn(msg, meta as never) } const runtime = makeRuntime(Auth.Service, Auth.defaultLayer) const Uuid = z.uuid() @@ -209,7 +212,19 @@ export namespace KiloSessions { let remote: { conn: RemoteWS.Connection; sender: RemoteSender.Sender } | undefined let enabling: Promise | undefined let remoteSeq = 0 - const attached = new Set() + // kilocode_change start - separate presence-owned attached session ids from + // newly-created (pending) session announcements so a concurrent presence + // update cannot drop a pending id and a heartbeat failure cannot delete a + // presence-owned id. The heartbeat closure throws when no remote connection + // is available so `announce` cannot silently mark a session as attached; + // create_session's catch block turns that into the sanitized failure + // response and the user retries manually. + const attachedState = AttachedState.create({ + heartbeat: () => + remote ? remote.conn.heartbeat() : Promise.reject(new Error("attachRemoteSession: no remote connection")), + log: attachedLog, + }) + // kilocode_change end const statusSyncs = new Map() const STATUS_TIMEOUT_MS = 3_000 @@ -420,8 +435,11 @@ export namespace KiloSessions { const { AppRuntime } = await import("@/effect/app-runtime") const statusMap = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list())) const statuses: Record = Object.fromEntries(statusMap) + // kilocode_change - advertise both presence-owned and pending-created ids + // so the relay learns about new sessions before the next periodic + // heartbeat and the create_session response can be sent. const ids = new Set(Object.keys(statuses)) - for (const id of attached) ids.add(id) + for (const id of attachedState.union()) ids.add(id) const results = await AppRuntime.runPromise( Session.Service.use((svc) => Effect.all( @@ -498,6 +516,10 @@ export namespace KiloSessions { remoteSeq += 1 const pending = !!enabling enabling = undefined + // kilocode_change - clear both presence and pending-created ids so the + // next remote connection lifecycle starts with a clean slate and stale + // pending announcements from a previous connection do not leak. + attachedState.reset() if (!remote) { if (pending) void Bus.publish(Instance.current, Event.RemoteStatusChanged, { enabled: false, connected: false }) return @@ -516,12 +538,20 @@ export namespace KiloSessions { } } export function setAttachedSessions(ids: readonly string[]) { - const next = new Set(ids) - if (same(next, attached)) return - attached.clear() - for (const id of next) attached.add(id) - if (remote) void remote.conn.heartbeat().catch((err) => log.warn("heartbeat failed", { error: String(err) })) + // kilocode_change - delegate to the two-set state so a concurrent create + // announcement is not dropped by a presence clear+rebuild. + attachedState.setPresence(ids) + } + + // kilocode_change start - duplicate-safe single-session attach used by the + // remote create_session command. Delegates to the two-set state so the + // announcement is preserved across a concurrent presence replacement and a + // heartbeat failure rolls back only the entry this call added (a + // presence-owned id is never reachable here because the factory guards it). + export async function attachRemoteSession(id: string) { + await attachedState.announce(id) } + // kilocode_change end export async function create(sessionId: string) { const result = await bootstrap(sessionId) diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index 160026e1a35..60de983dd26 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -42,6 +42,14 @@ const SuggestionData = z.object({ index: z.number().int().nonnegative(), }) +// kilocode_change start - create_session: strict v1 request, no other fields accepted +const CreateSessionRequest = z + .object({ + protocolVersion: z.literal(1), + }) + .strict() +// kilocode_change end + const decodeSessionID = Schema.decodeUnknownOption(SessionID) // kilocode_change start - redact anything but the error class so messages/credentials @@ -111,7 +119,19 @@ export namespace RemoteSender { session?: { readonly get: (sessionID: SessionID) => Promise readonly children: (sessionID: SessionID) => Promise + // kilocode_change start - injectable create hook for create_session. + // create_session only ever calls `create({})` for a root session, so the + // test hook is typed as the loose `() => Promise` shape. + // Production falls back to Session.Service.create with `{}`. + readonly create?: (input?: Record) => Promise + // kilocode_change end } + // kilocode_change start - duplicate-safe attach hook used by create_session. + // Production wires this to KiloSessions.attachRemoteSession so the attached + // set is mutated exactly once and the relay heartbeat fires only when the + // set actually changes. + attachSession?: (sessionID: SessionID) => Promise + // kilocode_change end catalog?: { readonly get: (sessionID: SessionID) => Promise readonly messages: (sessionID: SessionID) => Promise @@ -200,6 +220,22 @@ export namespace RemoteSender { return AppRuntime.runPromise(Session.Service.use((svc) => svc.children(sessionID))) }, } + // kilocode_change start - session create + duplicate-safe attach used by create_session + const sessionCreate = + session.create ?? + (async (input?: Record) => { + const { AppRuntime } = await import("@/effect/app-runtime") + return AppRuntime.runPromise( + Session.Service.use((svc) => svc.create(input as Parameters[0])), + ) + }) + const attachSession = + options.attachSession ?? + (async (id: SessionID) => { + const { KiloSessions } = await import("@/kilo-sessions/kilo-sessions") + await KiloSessions.attachRemoteSession(id) + }) + // kilocode_change end // kilocode_change start - injectable slash command discovery + execution const commands = options.commands ?? RemoteCommand.live() // kilocode_change end @@ -465,6 +501,45 @@ export namespace RemoteSender { })() return } + if (msg.command === "create_session") { + // kilocode_change start - remote /new creation: root session, attached + heartbeat before response + const parsed = CreateSessionRequest.safeParse(msg.data) + const current = msg.sessionId ? decodeSessionID(msg.sessionId) : Option.none() + if (!parsed.success || Option.isNone(current)) { + options.conn.send({ + type: "response", + id: msg.id, + error: "invalid create_session command", + }) + return + } + const run = options.provide ?? provide + void (async () => { + try { + const result = await run({ + directory: (await session.get(current.value)).directory, + fn: async () => { + const created = await sessionCreate({}) + // attachSession is the duplicate-safe seam: it mutates the + // attached set exactly once and fires conn.heartbeat() only + // when the set actually changes, so the relay learns about + // the new session before we respond. + await attachSession(created.id) + return created + }, + }) + options.conn.send({ + type: "response", + id: msg.id, + result: { protocolVersion: 1, sessionID: result.id }, + }) + } catch (error) { + options.log.error("create session failed", { id: msg.id, error: errorName(error) }) + options.conn.send({ type: "response", id: msg.id, error: "failed to create session" }) + } + })() + return + } // kilocode_change end if (msg.command === "list_models") { const parsed = RemoteModelCatalog.Request.safeParse(msg.data) diff --git a/packages/opencode/test/kilocode/sessions/attached-state.test.ts b/packages/opencode/test/kilocode/sessions/attached-state.test.ts new file mode 100644 index 00000000000..5ce8252b7cf --- /dev/null +++ b/packages/opencode/test/kilocode/sessions/attached-state.test.ts @@ -0,0 +1,342 @@ +import { describe, expect, test } from "bun:test" +import { AttachedState } from "../../../src/kilo-sessions/attached-state" + +const nolog = { warn: () => {} } + +function key(ids: Iterable) { + return [...ids].sort().join("|") +} + +describe("AttachedState", () => { + test("announce adds the id to the union and fires heartbeat once", async () => { + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return Promise.resolve() + }, + log: nolog, + }) + + await state.announce("ses_new") + + expect(heartbeatCalls).toBe(1) + expect([...state.union()].sort()).toEqual(["ses_new"]) + }) + + test("announce is a no-op when the id is already in the union (presence-owned)", async () => { + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return Promise.resolve() + }, + log: nolog, + }) + // setPresence is the only thing that fires a heartbeat in this test; it + // is the one call the assertion below counts. + state.setPresence(["ses_existing"]) + const heartbeatsAfterPresence = heartbeatCalls + + await state.announce("ses_existing") + + // No new heartbeat — the announce short-circuited because the id was + // already owned by presence. + expect(heartbeatCalls).toBe(heartbeatsAfterPresence) + expect([...state.union()].sort()).toEqual(["ses_existing"]) + }) + + test("announce is a no-op when the same id is announced twice and avoids an extra heartbeat", async () => { + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return Promise.resolve() + }, + log: nolog, + }) + + await state.announce("ses_dup") + await state.announce("ses_dup") + await state.announce("ses_dup") + + expect(heartbeatCalls).toBe(1) + expect([...state.union()].sort()).toEqual(["ses_dup"]) + }) + + test("presence adoption removes the id from the pending set without an extra heartbeat", async () => { + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return Promise.resolve() + }, + log: nolog, + }) + + await state.announce("ses_adopt") + // announce heartbeat fired once + expect(heartbeatCalls).toBe(1) + + // Presence reports the same id — it should be adopted and dropped from + // pending; the union key is unchanged so no second heartbeat is required. + state.setPresence(["ses_adopt"]) + + expect(heartbeatCalls).toBe(1) + expect([...state.union()].sort()).toEqual(["ses_adopt"]) + }) + + test("heartbeat failure on announce rolls back only the pending entry, never a presence-owned id", async () => { + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return Promise.reject(new Error("private relay detail: credential=must-not-leak")) + }, + log: nolog, + }) + + // Presence owns "ses_owned" first; setPresence fires the first heartbeat. + state.setPresence(["ses_owned"]) + const heartbeatsAfterPresence = heartbeatCalls + + // Now announce "ses_new" — heartbeat throws. The rollback must NOT touch + // presence-owned "ses_owned". + await expect(state.announce("ses_new")).rejects.toThrow("private relay detail") + + expect(heartbeatCalls).toBe(heartbeatsAfterPresence + 1) + expect([...state.union()].sort()).toEqual(["ses_owned"]) + }) + + test("concurrent setPresence during an in-flight announce retains the announced id", async () => { + let resolveHeartbeat: (() => void) | undefined + const heartbeatStarted = new Promise((resolve) => { + resolveHeartbeat = resolve + }) + const heartbeatDone = Promise.withResolvers() + const calls: { announce: number; presence: number } = { announce: 0, presence: 0 } + const state = AttachedState.create({ + // The same factory heartbeat is used by both paths; the slow path is the + // announce (waits on heartbeatDone) and the fast path is setPresence. + heartbeat: () => { + // The announce call is the first one we expect; subsequent calls are + // from setPresence while the announce is still in flight. Record + // arrival order so the assertion can prove the announce happened + // before setPresence. + calls[calls.announce === 0 ? "announce" : "presence"] += 1 + resolveHeartbeat!() + return heartbeatDone.promise + }, + log: nolog, + }) + + // Start the announce but do not await yet. + const announcePromise = state.announce("ses_new") + await heartbeatStarted + // Concurrent presence replacement while the announce heartbeat is in flight. + // setPresence must NOT drop the pending "ses_new". + state.setPresence(["ses_other"]) + heartbeatDone.resolve() + await announcePromise + + expect(calls.announce).toBe(1) + expect(calls.presence).toBe(1) + // The announced id survived the concurrent presence replacement and is + // still in the union alongside the presence-owned id. + expect([...state.union()].sort()).toEqual(["ses_new", "ses_other"]) + }) + + test("setPresence fires heartbeat only when the union actually changes", () => { + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return Promise.resolve() + }, + log: nolog, + }) + + state.setPresence(["ses_a", "ses_b"]) + expect(heartbeatCalls).toBe(1) + + // Same set — no heartbeat. + state.setPresence(["ses_b", "ses_a"]) + expect(heartbeatCalls).toBe(1) + + // Changed union — heartbeat. + state.setPresence(["ses_a"]) + expect(heartbeatCalls).toBe(2) + }) + + test("reset clears both presence and pending and a subsequent setPresence fires heartbeat again", async () => { + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return Promise.resolve() + }, + log: nolog, + }) + state.setPresence(["ses_a"]) + await state.announce("ses_b") + expect(heartbeatCalls).toBe(2) + expect([...state.union()].sort()).toEqual(["ses_a", "ses_b"]) + + state.reset() + expect([...state.union()]).toEqual([]) + + // A fresh presence replacement after reset must fire heartbeat because the + // baseline union key is empty. + state.setPresence(["ses_c"]) + expect(heartbeatCalls).toBe(3) + expect([...state.union()].sort()).toEqual(["ses_c"]) + }) + + test("union key remains stable for the same set of ids regardless of insertion order", () => { + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return Promise.resolve() + }, + log: nolog, + }) + state.setPresence(["a", "b", "c"]) + expect(heartbeatCalls).toBe(1) + state.setPresence(["c", "b", "a"]) + expect(heartbeatCalls).toBe(1) + }) + + test("announce after the same id was rolled back re-attaches it", async () => { + let attempts = 0 + const state = AttachedState.create({ + heartbeat: () => { + attempts += 1 + if (attempts === 1) return Promise.reject(new Error("transient")) + return Promise.resolve() + }, + log: nolog, + }) + + await expect(state.announce("ses_retry")).rejects.toThrow("transient") + expect([...state.union()]).toEqual([]) + + await state.announce("ses_retry") + expect([...state.union()].sort()).toEqual(["ses_retry"]) + }) + + test("warn log captures heartbeat failures from setPresence without surfacing them", () => { + const warnings: unknown[][] = [] + const state = AttachedState.create({ + heartbeat: () => Promise.reject(new Error("presence heartbeat down")), + log: { warn: (...args: unknown[]) => warnings.push(args) }, + }) + + state.setPresence(["ses_a"]) + + // setPresence fires heartbeat fire-and-forget; allow the microtask to drain. + return Promise.resolve().then(() => { + expect(warnings).toHaveLength(1) + expect(String(warnings[0]?.[0])).toContain("heartbeat") + const meta = warnings[0]?.[1] as { error?: unknown } | undefined + expect(String(meta?.error ?? "")).toContain("presence heartbeat down") + // Union still reflects presence even when heartbeat fails. + expect([...state.union()].sort()).toEqual(["ses_a"]) + // key helper sanity-check (not part of the contract, but useful for the + // reviewer to read the union key format). + expect(key(state.union())).toBe("ses_a") + }) + }) + + // Regression: lastKey was being updated BEFORE the awaited heartbeat, so a + // concurrent setPresence that adopted the same id would skip its heartbeat + // because the (false) cache said the relay already knew. When the announce + // then failed, the relay was left believing the old union. The fix is to + // only update the last-sent key on a successful heartbeat, and to let + // setPresence always fire when the current union diverges from it. + test("setPresence adopts an in-flight pending id and still fires heartbeat when the announce later fails", async () => { + // Controlled resolvers — no setTimeout, no real timers. + let resolveAnnounceHeartbeat: ((value: void) => void) | undefined + let rejectAnnounceHeartbeat: ((reason: unknown) => void) | undefined + const announceHeartbeat = Promise.withResolvers() + resolveAnnounceHeartbeat = announceHeartbeat.resolve + rejectAnnounceHeartbeat = announceHeartbeat.reject + + // Distinguish the two call sites so the assertion can prove setPresence + // fired (not just the announce path). + const calls: { announce: number; presence: number } = { announce: 0, presence: 0 } + const state = AttachedState.create({ + heartbeat: () => { + // First call is the announce (blocks on announceHeartbeat). Any + // subsequent call is from setPresence (returns immediately). + if (calls.announce === 0) { + calls.announce += 1 + return announceHeartbeat.promise + } + calls.presence += 1 + return Promise.resolve() + }, + log: nolog, + }) + + // Existing presence-owned id, successfully heartbeated earlier. + state.setPresence(["ses_existing"]) + // Drain the first setPresence's fire-and-forget heartbeat microtask. + await Promise.resolve() + calls.announce = 0 + calls.presence = 0 + + // Announce "ses_new" — it will block until we resolve/reject the heartbeat. + const announcePromise = state.announce("ses_new") + // Yield so the announce reaches its `await`. + await Promise.resolve() + + // Concurrent presence replacement adopts the in-flight pending id. + state.setPresence(["ses_existing", "ses_new"]) + // Drain the setPresence fire-and-forget heartbeat microtask. + await Promise.resolve() + + // setPresence MUST have fired a heartbeat; otherwise the relay would be + // left believing the old union after the announce fails below. + expect(calls.presence).toBe(1) + // The pending id was adopted by presence, so the announce's pending + // entry is gone before the announce heartbeat resolves. + expect([...state.union()].sort()).toEqual(["ses_existing", "ses_new"]) + + // Now reject the announce heartbeat. The factory must NOT have poisoned + // the last-sent key with the pre-success union, and the rollback must + // not leave the relay desynced. + rejectAnnounceHeartbeat!(new Error("announce failed: credential=must-not-leak")) + await expect(announcePromise).rejects.toThrow("must-not-leak") + + // Final local state reflects the presence set. + expect([...state.union()].sort()).toEqual(["ses_existing", "ses_new"]) + + // A subsequent setPresence with the same set must NOT fire another + // heartbeat (the relay already received {ses_existing, ses_new} from the + // setPresence call above). + calls.presence = 0 + state.setPresence(["ses_existing", "ses_new"]) + await Promise.resolve() + expect(calls.presence).toBe(0) + + // A subsequent setPresence that REMOVES ses_new must fire a heartbeat + // because the relay's last sent union was {ses_existing, ses_new} and + // the new union is {ses_existing}. + calls.presence = 0 + state.setPresence(["ses_existing"]) + await Promise.resolve() + expect(calls.presence).toBe(1) + }) + + test("announce rolls back and throws when the heartbeat rejects (no remote)", async () => { + const state = AttachedState.create({ + heartbeat: () => Promise.reject(new Error("no remote connection")), + log: nolog, + }) + + await expect(state.announce("ses_new")).rejects.toThrow("no remote connection") + // The id must not linger in the union after a failed announce. + expect([...state.union()]).toEqual([]) + }) +}) diff --git a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts index d47ae11f279..a15bc7e5d74 100644 --- a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts @@ -2077,5 +2077,415 @@ describe("RemoteSender slash commands", () => { expect(flattened).not.toContain("credential=must-not-leak") expect(flattened).not.toContain("secret=") }) + + test("create_session creates a root session in the current directory and responds in order", async () => { + const { conn, sent } = fakeConn() + const dirs: string[] = [] + const createCalls: { input: unknown; calls: number } = { input: undefined, calls: 0 } + const attachCalls: string[] = [] + const order: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => { + dirs.push(input.directory) + return input.fn() + }, + session: { + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" } as any), + children: async () => [], + create: async (input) => { + createCalls.calls += 1 + createCalls.input = input + order.push("create") + return { id: SessionID.make("ses_new"), directory: "/workspace/project-a", parentID: undefined } as any + }, + }, + attachSession: async (id) => { + attachCalls.push(id) + order.push("attach") + // The production attachSession is responsible for the heartbeat; the + // mock follows the same contract so the ordering assertion below + // exercises the real shape of: create -> attach -> heartbeat -> response. + await (conn as any).heartbeat() + }, + }) + ;(conn as any).heartbeat = async () => { + order.push("heartbeat") + } + + const response = expectResponse(conn, sent, "req_create") + sender.handle({ + type: "command", + id: "req_create", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await response.promise + response.restore() + + expect(dirs).toEqual(["/workspace/project-a"]) + expect(createCalls.calls).toBe(1) + expect(createCalls.input).toEqual({}) + expect(attachCalls).toEqual(["ses_new"]) + expect(order).toEqual(["create", "attach", "heartbeat"]) + expect(sent).toEqual([ + { type: "response", id: "req_create", result: { protocolVersion: 1, sessionID: "ses_new" } }, + ]) + }) + + test("create_session rejects unsupported protocol versions and missing or invalid session IDs", async () => { + const { conn, sent } = fakeConn() + const createCalls: unknown[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/test", + log: nolog, + subscribe: fakeBus().subscribe, + session: { + get: async () => { + throw new Error("must not look up session for invalid request") + }, + children: async () => [], + create: async (input) => { + createCalls.push(input) + return { id: SessionID.make("ses_unused") } as any + }, + }, + attachSession: async () => { + throw new Error("must not attach for invalid request") + }, + }) + ;(conn as any).heartbeat = async () => { + throw new Error("must not heartbeat for invalid request") + } + + sender.handle({ + type: "command", + id: "req_v2", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 2 }, + }) + sender.handle({ + type: "command", + id: "req_no_session", + command: "create_session", + data: { protocolVersion: 1 }, + }) + sender.handle({ + type: "command", + id: "req_bad_session", + command: "create_session", + sessionId: "not-a-session-id", + data: { protocolVersion: 1 }, + }) + sender.handle({ + type: "command", + id: "req_extra_field", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1, extra: true }, + }) + + expect(sent).toEqual([ + { type: "response", id: "req_v2", error: "invalid create_session command" }, + { type: "response", id: "req_no_session", error: "invalid create_session command" }, + { type: "response", id: "req_bad_session", error: "invalid create_session command" }, + { type: "response", id: "req_extra_field", error: "invalid create_session command" }, + ]) + expect(createCalls).toHaveLength(0) + }) + + test("create_session returns a sanitized error and never reports success when creation throws", async () => { + const { conn, sent } = fakeConn() + const logEntries: unknown[][] = [] + const attachCalls: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: { ...nolog, error: (...args: unknown[]) => logEntries.push(args) }, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + session: { + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" } as any), + children: async () => [], + create: async () => { + throw new Error("private failure detail: token=must-not-leak") + }, + }, + attachSession: async (id) => { + attachCalls.push(id) + }, + }) + ;(conn as any).heartbeat = async () => {} + + const response = expectResponse(conn, sent, "req_create_failed") + sender.handle({ + type: "command", + id: "req_create_failed", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await response.promise + response.restore() + + expect(sent).toEqual([{ type: "response", id: "req_create_failed", error: "failed to create session" }]) + expect(attachCalls).toEqual([]) + expect(logEntries).toHaveLength(1) + expect(logEntries[0]?.[0]).toBe("create session failed") + expect(logEntries[0]?.[1]).toEqual({ id: "req_create_failed", error: "Error" }) + const flattened = JSON.stringify(logEntries) + expect(flattened).not.toContain("must-not-leak") + expect(flattened).not.toContain("token=") + }) + + test("create_session returns a sanitized error and never reports success when heartbeat throws", async () => { + const { conn, sent } = fakeConn() + const logEntries: unknown[][] = [] + const attachCalls: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: { ...nolog, error: (...args: unknown[]) => logEntries.push(args) }, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + session: { + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" } as any), + children: async () => [], + create: async () => + ({ id: SessionID.make("ses_new"), directory: "/workspace/project-a" } as any), + }, + attachSession: async (id) => { + // The production contract puts the heartbeat inside attachSession so + // a duplicate-safe set mutation can skip the network round trip. + attachCalls.push(id) + await (conn as any).heartbeat() + }, + }) + ;(conn as any).heartbeat = async () => { + throw new Error("private relay detail: credential=must-not-leak") + } + + const response = expectResponse(conn, sent, "req_heartbeat_failed") + sender.handle({ + type: "command", + id: "req_heartbeat_failed", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await response.promise + response.restore() + + expect(sent).toEqual([{ type: "response", id: "req_heartbeat_failed", error: "failed to create session" }]) + expect(attachCalls).toEqual(["ses_new"]) + expect(logEntries).toHaveLength(1) + expect(logEntries[0]?.[0]).toBe("create session failed") + const flattened = JSON.stringify(logEntries) + expect(flattened).not.toContain("must-not-leak") + expect(flattened).not.toContain("credential=") + }) + + test("create_session runs in the current session's directory", async () => { + const { conn, sent } = fakeConn() + const dirs: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => { + dirs.push(input.directory) + return input.fn() + }, + session: { + get: async (sessionID) => { + if (sessionID === SessionID.make("ses_alpha")) return { id: sessionID, directory: "/workspace/alpha" } as any + if (sessionID === SessionID.make("ses_beta")) return { id: sessionID, directory: "/workspace/beta" } as any + throw new Error("unknown session") + }, + children: async () => [], + create: async () => ({ id: SessionID.make("ses_new") } as any), + }, + attachSession: async () => {}, + }) + ;(conn as any).heartbeat = async () => {} + + const first = expectResponse(conn, sent, "req_create_alpha") + sender.handle({ + type: "command", + id: "req_create_alpha", + command: "create_session", + sessionId: "ses_alpha", + data: { protocolVersion: 1 }, + }) + await first.promise + first.restore() + + const second = expectResponse(conn, sent, "req_create_beta") + sender.handle({ + type: "command", + id: "req_create_beta", + command: "create_session", + sessionId: "ses_beta", + data: { protocolVersion: 1 }, + }) + await second.promise + second.restore() + + expect(dirs).toEqual(["/workspace/alpha", "/workspace/beta"]) + }) + + test("create_session dispatches attach and heartbeat for each call", async () => { + const { conn, sent } = fakeConn() + const attachCalls: string[] = [] + let heartbeatCalls = 0 + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + session: { + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" } as any), + children: async () => [], + create: async () => ({ id: SessionID.make("ses_same") } as any), + }, + attachSession: async (id) => { + attachCalls.push(id) + await (conn as any).heartbeat() + }, + }) + ;(conn as any).heartbeat = async () => { + heartbeatCalls += 1 + } + + const first = expectResponse(conn, sent, "req_create_same_first") + sender.handle({ + type: "command", + id: "req_create_same_first", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await first.promise + first.restore() + + const second = expectResponse(conn, sent, "req_create_same_second") + sender.handle({ + type: "command", + id: "req_create_same_second", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await second.promise + second.restore() + + // Each request is a separate create_session call, so the production + // attachSession is invoked twice. The de-duplication of the attached set + // itself is the responsibility of the attachSession hook (see the + // duplicate-safe test below). + expect(attachCalls).toEqual(["ses_same", "ses_same"]) + expect(heartbeatCalls).toBe(2) + }) + + test("create_session does not call heartbeat when the new session is already attached", async () => { + const { conn, sent } = fakeConn() + let heartbeatCalls = 0 + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + session: { + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" } as any), + children: async () => [], + create: async () => ({ id: SessionID.make("ses_existing") } as any), + }, + attachSession: async () => { + // Simulate a duplicate-safe attach: nothing to do, no heartbeat needed. + }, + }) + ;(conn as any).heartbeat = async () => { + heartbeatCalls += 1 + } + + const response = expectResponse(conn, sent, "req_create_existing") + sender.handle({ + type: "command", + id: "req_create_existing", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await response.promise + response.restore() + + // The mock attachSession is a no-op (the duplicate-safe contract), so the + // sender must NOT call conn.heartbeat() on its own. + expect(heartbeatCalls).toBe(0) + expect(sent).toEqual([ + { type: "response", id: "req_create_existing", result: { protocolVersion: 1, sessionID: "ses_existing" } }, + ]) + }) + + test("create_session returns a sanitized error and never reports success when current session.get throws", async () => { + const { conn, sent } = fakeConn() + const logEntries: unknown[][] = [] + const createCalls: unknown[] = [] + const attachCalls: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: { ...nolog, error: (...args: unknown[]) => logEntries.push(args) }, + subscribe: fakeBus().subscribe, + session: { + get: async () => { + throw new Error("private lookup failure: token=must-not-leak and path=/workspace/private") + }, + children: async () => [], + create: async (input) => { + createCalls.push(input) + return { id: SessionID.make("ses_unused") } as any + }, + }, + attachSession: async (id) => { + attachCalls.push(id) + }, + }) + ;(conn as any).heartbeat = async () => {} + + const response = expectResponse(conn, sent, "req_create_get_failed") + sender.handle({ + type: "command", + id: "req_create_get_failed", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await response.promise + response.restore() + + expect(sent).toEqual([{ type: "response", id: "req_create_get_failed", error: "failed to create session" }]) + expect(createCalls).toEqual([]) + expect(attachCalls).toEqual([]) + expect(logEntries).toHaveLength(1) + expect(logEntries[0]?.[0]).toBe("create session failed") + // Only the error class is logged — no message, no path, no token. + expect(logEntries[0]?.[1]).toEqual({ id: "req_create_get_failed", error: "Error" }) + const flattened = JSON.stringify(logEntries) + expect(flattened).not.toContain("must-not-leak") + expect(flattened).not.toContain("token=") + expect(flattened).not.toContain("/workspace/private") + // The request payload itself must never reach the log. + expect(flattened).not.toContain("ses_current") + }) }) // kilocode_change end From 6c04176723f2281883a5372caf12659df3c9f418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 15 Jul 2026 13:47:12 +0200 Subject: [PATCH 3/6] fix(cli): harden remote session attach and command boundaries --- .../src/kilo-sessions/attached-state.ts | 123 +++-- .../src/kilo-sessions/remote-command.ts | 11 + .../src/kilo-sessions/remote-sender.ts | 35 +- .../kilocode/sessions/attached-state.test.ts | 453 +++++++++++++++++- .../kilocode/sessions/remote-command.test.ts | 121 ++++- .../kilocode/sessions/remote-sender.test.ts | 172 ++++++- 6 files changed, 833 insertions(+), 82 deletions(-) diff --git a/packages/opencode/src/kilo-sessions/attached-state.ts b/packages/opencode/src/kilo-sessions/attached-state.ts index 8972a9b47c1..a9af1638e2e 100644 --- a/packages/opencode/src/kilo-sessions/attached-state.ts +++ b/packages/opencode/src/kilo-sessions/attached-state.ts @@ -2,24 +2,29 @@ // attached session ids from newly-created (pending) session announcements. // `setPresence` (driven by the presence service) is authoritative for the // presence set and adopts any pending ids it now covers. `announce` (driven -// by the create_session command) is duplicate-safe across both sets and rolls -// back only its own pending entry on heartbeat failure so a presence-owned id -// is never removed by a remote create failure. +// by the create_session command) is duplicate-safe across both sets and +// resolves coherently with presence semantics on heartbeat failure so a +// presence-adopted id is never reported as an attach failure. // // Concurrency invariant: `lastSentKey` is the union the relay last observed // through a successfully completed heartbeat. It is updated: // - synchronously by `setPresence` (fire-and-forget, but the union we // record is the current union which includes any in-flight pending ids, // so a later `setPresence` with the same union correctly skips) -// - by `announce` only on a successful awaited heartbeat +// - by `announce` only on a successful awaited heartbeat AND only when +// the announce still belongs to the current lifecycle (the captured +// `myGeneration` matches the current `generation`) // It is NEVER updated on: // - the synchronous prefix of `announce` (would let a concurrent // `setPresence` skip its heartbeat because the cache falsely claims the // relay is up to date, leaving the relay desynced if the announce then // fails) // - the failure branch of `announce` (the relay never saw the new union, -// and a concurrent `setPresence` may have already advanced lastSentKey to +// and a concurrent setPresence may have already advanced lastSentKey to // a newer state via its own fire-and-forget heartbeat) +// - a stale success after `reset()` (the relay's observed state belongs +// to the old lifecycle; the new lifecycle's setPresence calls must +// drive the new baseline, not a stale overwrite) export namespace AttachedState { export type Options = { @@ -39,8 +44,10 @@ export namespace AttachedState { setPresence(ids: readonly string[]): void /** Awaitable duplicate-safe announcement. No-ops when the id is already * present in either set. On heartbeat failure rolls back only its own - * pending entry, does NOT touch `lastSentKey`, and re-throws. On - * success advances `lastSentKey` to the current union. */ + * pending entry, does NOT touch `lastSentKey`, and re-throws — unless + * presence adopted the id while the heartbeat was in flight, in which + * case presence is authoritative and the attach resolves successfully. + * On success advances `lastSentKey` to the current union. */ announce(id: string): Promise /** Current union of presence ∪ pending for the next heartbeat payload. */ union(): ReadonlySet @@ -50,14 +57,37 @@ export namespace AttachedState { reset(): void } + // kilocode_change - collision-safe union key. The historical "|" join + // was ambiguous: {"a", "b"} and {"a|b"} both encoded to "a|b" so two + // distinct id sets could collide. Length-prefixing each id makes the + // encoding unambiguous for any string id: knowing the prefix length + // tells the decoder exactly how many characters of id follow, so no + // delimiter can ever escape its enclosing id. function keyOf(ids: Iterable): string { - return [...ids].sort().join("|") + const sorted = [...ids].sort() + const parts: string[] = [] + for (const id of sorted) parts.push(`${id.length}:${id}`) + return parts.join(",") } export function create(options: Options): Interface { const presence = new Set() const pending = new Set() + // kilocode_change - in-flight dedup. Concurrent announce(id) callers + // share the same Promise so they observe one consistent outcome and + // the heartbeat fires at most once per id. The owner is the caller + // that installed the Promise; only it manages the map entry. On settle + // the owner clears the entry if the map still points to its Promise + // (a later announce may have replaced it). Joiners only await. + const inflight = new Map>() + // kilocode_change end let lastSentKey = "" + // kilocode_change - lifecycle generation. Incremented on reset() so a + // late success from an in-flight announce started before the reset + // cannot overwrite the new lifecycle's lastSentKey. The old completion + // would otherwise write keyOf(union()) using the new generation's union, + // causing redundant/stale change detection in subsequent setPresence calls. + let generation = 0 function union(): Set { const out = new Set(presence) @@ -66,9 +96,9 @@ export namespace AttachedState { } function fireHeartbeat() { - void options.heartbeat().catch((err) => - options.log?.warn("attached-state heartbeat failed", { error: String(err) }), - ) + void options + .heartbeat() + .catch((err) => options.log?.warn("attached-state heartbeat failed", { error: String(err) })) } return { @@ -91,24 +121,58 @@ export namespace AttachedState { }, async announce(id) { - if (presence.has(id) || pending.has(id)) return - pending.add(id) + if (presence.has(id)) return + // kilocode_change - join an in-flight Promise for this id instead + // of starting a second heartbeat. + const existing = inflight.get(id) + if (existing) { + await existing + return + } + if (pending.has(id)) { + // A previous announce already resolved and is awaiting presence + // adoption. No further work to do. + return + } + // kilocode_change - capture the lifecycle generation so a late + // success after reset() cannot overwrite the new lifecycle's + // lastSentKey with keyOf(union()) computed from the new state. + const myGeneration = generation + const owned = (async () => { + pending.add(id) + try { + await options.heartbeat() + } catch (err) { + // Roll back only the entry this call added. If presence adopted + // the id while the heartbeat was in flight, presence is the + // authoritative owner and the attach succeeded from the + // caller's perspective — resolve cleanly and leave the union + // alone. Otherwise the id is truly unattached: drop the pending + // entry and surface the failure. lastSentKey is never touched + // here because the relay never observed the new union and a + // concurrent setPresence may have already advanced it. + if (presence.has(id)) return + pending.delete(id) + throw err + } + // Success: the relay now has the union that includes this id. + // Advance lastSentKey so the next setPresence with the same union + // is a no-op. The id stays in `pending` until presence adopts it; + // this keeps the union stable across presence churn. If a + // reset() happened while the heartbeat was in flight, the + // captured generation no longer matches and we must NOT write + // lastSentKey — the new lifecycle owns the baseline now. + if (myGeneration !== generation) return + lastSentKey = keyOf(union()) + })() + inflight.set(id, owned) try { - await options.heartbeat() - } catch (err) { - // Roll back only the entry this call added. A presence-owned id is - // never reachable here because the early return above guards it. - // Do NOT touch lastSentKey: the relay never observed the new - // union, and a concurrent setPresence may have already advanced - // it. Overwriting it here would clobber that newer state. - pending.delete(id) - throw err + await owned + } finally { + // Only clear if the map still points to OUR promise; a later + // announce may have replaced it and we must not clobber that. + if (inflight.get(id) === owned) inflight.delete(id) } - // Success: the relay now has the union that includes this id. - // Advance lastSentKey so the next setPresence with the same union - // is a no-op. The id stays in `pending` until presence adopts it; - // this keeps the union stable across presence churn. - lastSentKey = keyOf(union()) }, union() { @@ -118,7 +182,12 @@ export namespace AttachedState { reset() { presence.clear() pending.clear() + inflight.clear() lastSentKey = "" + // kilocode_change - bump the lifecycle generation so any in-flight + // announce started before this reset will skip its lastSentKey + // write on success (its captured generation no longer matches). + generation += 1 }, } } diff --git a/packages/opencode/src/kilo-sessions/remote-command.ts b/packages/opencode/src/kilo-sessions/remote-command.ts index eef76df0f4b..a60f9b65cb6 100644 --- a/packages/opencode/src/kilo-sessions/remote-command.ts +++ b/packages/opencode/src/kilo-sessions/remote-command.ts @@ -160,6 +160,17 @@ export namespace RemoteCommand { return { list: async () => build(await services.list()), execute: async (input) => { + // kilocode_change - enforce membership in the supplied bounded + // remote-safe catalog. The dispatcher's preflight also gates + // membership before the ACK, but execute() is the last line of + // defense: a caller (or a future caller) that bypasses the + // dispatcher must still not reach services.command() for a name + // the mobile client was never offered. Reject with a specific + // error so the failure is distinguishable from runtime faults. + const catalogNames = new Set(input.catalog.commands.map((item) => item.name)) + if (!catalogNames.has(input.command)) { + throw new Error(`unknown slash command: ${input.command}`) + } // A registered command named `compact` shadows the built-in whenever it // appears in the preflight catalog. The built-in "compact" sentinel // carries no `source`, so checking for source=="command"|"mcp" rules it diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index 60de983dd26..0692c7fff0b 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -124,6 +124,11 @@ export namespace RemoteSender { // test hook is typed as the loose `() => Promise` shape. // Production falls back to Session.Service.create with `{}`. readonly create?: (input?: Record) => Promise + // kilocode_change - injectable remove hook used to roll back an orphan + // root session when attachSession fails after creation. The default + // delegates to Session.Service.remove and only swallows its own errors + // so the original attach failure is what reaches the caller. + readonly remove?: (sessionID: SessionID) => Promise // kilocode_change end } // kilocode_change start - duplicate-safe attach hook used by create_session. @@ -220,6 +225,18 @@ export namespace RemoteSender { return AppRuntime.runPromise(Session.Service.use((svc) => svc.children(sessionID))) }, } + // kilocode_change start - orphan rollback for create_session: when + // sessionCreate succeeds but attachSession fails, the newly-created root + // session would otherwise stay in the DB with no relay awareness. The + // default remove() delegates to Session.Service.remove and swallows its + // own errors so the caller still observes the original attach failure. + const sessionRemove = + session.remove ?? + (async (id: SessionID) => { + const { AppRuntime } = await import("@/effect/app-runtime") + await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(id))) + }) + // kilocode_change end // kilocode_change start - session create + duplicate-safe attach used by create_session const sessionCreate = session.create ?? @@ -524,7 +541,23 @@ export namespace RemoteSender { // attached set exactly once and fires conn.heartbeat() only // when the set actually changes, so the relay learns about // the new session before we respond. - await attachSession(created.id) + try { + await attachSession(created.id) + } catch (attachError) { + // Roll back the newly-created root session so the DB does + // not keep an orphan the relay never learned about. Swallow + // the cleanup error here — the original attach failure is + // what the caller must see, so we re-throw it below. + try { + await sessionRemove(created.id) + } catch (cleanupError) { + options.log.error("create session cleanup failed", { + id: msg.id, + error: errorName(cleanupError), + }) + } + throw attachError + } return created }, }) diff --git a/packages/opencode/test/kilocode/sessions/attached-state.test.ts b/packages/opencode/test/kilocode/sessions/attached-state.test.ts index 5ce8252b7cf..676ba79b8c1 100644 --- a/packages/opencode/test/kilocode/sessions/attached-state.test.ts +++ b/packages/opencode/test/kilocode/sessions/attached-state.test.ts @@ -4,7 +4,11 @@ import { AttachedState } from "../../../src/kilo-sessions/attached-state" const nolog = { warn: () => {} } function key(ids: Iterable) { - return [...ids].sort().join("|") + // Mirrors the collision-safe keyOf used by the implementation under test. + return [...ids] + .sort() + .map((id) => `${id.length}:${id}`) + .join(",") } describe("AttachedState", () => { @@ -244,7 +248,7 @@ describe("AttachedState", () => { expect([...state.union()].sort()).toEqual(["ses_a"]) // key helper sanity-check (not part of the contract, but useful for the // reviewer to read the union key format). - expect(key(state.union())).toBe("ses_a") + expect(key(state.union())).toBe("5:ses_a") }) }) @@ -254,22 +258,18 @@ describe("AttachedState", () => { // then failed, the relay was left believing the old union. The fix is to // only update the last-sent key on a successful heartbeat, and to let // setPresence always fire when the current union diverges from it. + // Updated for the presence-adoption-shields-failure contract: when + // setPresence adopts the in-flight id, presence becomes authoritative + // and the old announce heartbeat rejection is suppressed (the attach + // is already successful from the caller's perspective). test("setPresence adopts an in-flight pending id and still fires heartbeat when the announce later fails", async () => { - // Controlled resolvers — no setTimeout, no real timers. - let resolveAnnounceHeartbeat: ((value: void) => void) | undefined - let rejectAnnounceHeartbeat: ((reason: unknown) => void) | undefined const announceHeartbeat = Promise.withResolvers() - resolveAnnounceHeartbeat = announceHeartbeat.resolve - rejectAnnounceHeartbeat = announceHeartbeat.reject - // Distinguish the two call sites so the assertion can prove setPresence - // fired (not just the announce path). const calls: { announce: number; presence: number } = { announce: 0, presence: 0 } + let announcing = false const state = AttachedState.create({ heartbeat: () => { - // First call is the announce (blocks on announceHeartbeat). Any - // subsequent call is from setPresence (returns immediately). - if (calls.announce === 0) { + if (announcing && calls.announce === 0) { calls.announce += 1 return announceHeartbeat.promise } @@ -281,40 +281,35 @@ describe("AttachedState", () => { // Existing presence-owned id, successfully heartbeated earlier. state.setPresence(["ses_existing"]) - // Drain the first setPresence's fire-and-forget heartbeat microtask. await Promise.resolve() calls.announce = 0 calls.presence = 0 // Announce "ses_new" — it will block until we resolve/reject the heartbeat. + announcing = true const announcePromise = state.announce("ses_new") - // Yield so the announce reaches its `await`. await Promise.resolve() // Concurrent presence replacement adopts the in-flight pending id. state.setPresence(["ses_existing", "ses_new"]) - // Drain the setPresence fire-and-forget heartbeat microtask. await Promise.resolve() - // setPresence MUST have fired a heartbeat; otherwise the relay would be - // left believing the old union after the announce fails below. expect(calls.presence).toBe(1) // The pending id was adopted by presence, so the announce's pending // entry is gone before the announce heartbeat resolves. expect([...state.union()].sort()).toEqual(["ses_existing", "ses_new"]) - // Now reject the announce heartbeat. The factory must NOT have poisoned - // the last-sent key with the pre-success union, and the rollback must - // not leave the relay desynced. - rejectAnnounceHeartbeat!(new Error("announce failed: credential=must-not-leak")) - await expect(announcePromise).rejects.toThrow("must-not-leak") + // Reject the announce heartbeat. With the adoption-shield contract, + // the announce resolves cleanly because presence owns the id. + announceHeartbeat.reject(new Error("announce failed: credential=must-not-leak")) + await announcePromise // Final local state reflects the presence set. expect([...state.union()].sort()).toEqual(["ses_existing", "ses_new"]) // A subsequent setPresence with the same set must NOT fire another - // heartbeat (the relay already received {ses_existing, ses_new} from the - // setPresence call above). + // heartbeat (the relay already received {ses_existing, ses_new} from + // the setPresence call above). calls.presence = 0 state.setPresence(["ses_existing", "ses_new"]) await Promise.resolve() @@ -339,4 +334,414 @@ describe("AttachedState", () => { // The id must not linger in the union after a failed announce. expect([...state.union()]).toEqual([]) }) + + // Concurrent announce(id) callers must share the same in-flight outcome + // and fire the heartbeat exactly once. Driven by published promises + // (no setTimeout / sleep) so the assertion is deterministic. + test("concurrent announce callers share one in-flight promise and fire one heartbeat", async () => { + const heartbeat = Promise.withResolvers() + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return heartbeat.promise + }, + log: nolog, + }) + + // Both callers start before the heartbeat resolves. They must converge + // on a single in-flight promise and not race two heartbeats. + const a = state.announce("ses_concurrent") + const b = state.announce("ses_concurrent") + // Yield so both synchronous prefixes have run before we resolve. + await Promise.resolve() + expect(heartbeatCalls).toBe(1) + expect([...state.union()].sort()).toEqual(["ses_concurrent"]) + + heartbeat.resolve() + await Promise.all([a, b]) + expect(heartbeatCalls).toBe(1) + expect([...state.union()].sort()).toEqual(["ses_concurrent"]) + }) + + // If the shared heartbeat rejects, every concurrent caller observes the + // same error and the id is rolled back exactly once. + test("concurrent announce callers observe the same rejection and roll back once", async () => { + const heartbeat = Promise.withResolvers() + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return heartbeat.promise + }, + log: nolog, + }) + + const a = state.announce("ses_shared_fail") + const b = state.announce("ses_shared_fail") + const c = state.announce("ses_shared_fail") + await Promise.resolve() + expect(heartbeatCalls).toBe(1) + + heartbeat.reject(new Error("shared failure")) + const results = await Promise.allSettled([a, b, c]) + expect(results.every((r) => r.status === "rejected")).toBe(true) + // The id must not linger in the union after a shared failure. + expect([...state.union()]).toEqual([]) + }) + + // Presence adopting an in-flight announce must leave a coherent outcome: + // - the union reflects both presence and pending, + // - a later setPresence with the same union does NOT fire a redundant + // heartbeat (the relay already learned about it from setPresence's + // own fire-and-forget). + test("presence adoption during an in-flight announce keeps the union coherent and avoids a redundant heartbeat", async () => { + const announceHeartbeat = Promise.withResolvers() + const calls: { announce: number; presence: number } = { announce: 0, presence: 0 } + const state = AttachedState.create({ + heartbeat: () => { + if (calls.announce === 0) { + // First call is the announce (blocks on announceHeartbeat). + calls.announce += 1 + return announceHeartbeat.promise + } + // Subsequent calls are setPresence fire-and-forget. + calls.presence += 1 + return Promise.resolve() + }, + log: nolog, + }) + + const announcePromise = state.announce("ses_coexist") + await Promise.resolve() + // The announce reached its first `await`; the in-flight heartbeat is the + // announce heartbeat (not yet resolved). + expect(calls.announce).toBe(1) + expect(calls.presence).toBe(0) + + // Concurrent presence replacement that includes the pending id. + state.setPresence(["ses_coexist"]) + // Drain the setPresence fire-and-forget heartbeat microtask. + await Promise.resolve() + // setPresence fired its own heartbeat. + expect(calls.presence).toBe(1) + // The pending id is now in the union via presence; the pending entry + // is gone so the announce's success path leaves the state consistent. + expect([...state.union()].sort()).toEqual(["ses_coexist"]) + + // Resolve the in-flight announce heartbeat. It must NOT fire another + // heartbeat of its own (the contract is "exactly one per attach"). + announceHeartbeat.resolve() + await announcePromise + expect(calls.presence).toBe(1) + expect([...state.union()].sort()).toEqual(["ses_coexist"]) + }) + + // Gap 3: presence adopts an in-flight announce, then the OLD announce + // heartbeat rejects. The attach is successful because presence is the + // authoritative owner — the announce caller must resolve cleanly, the + // pending entry must stay clean (already deleted by setPresence), and + // the in-flight map must not retain a stale entry. + test("presence adoption during an in-flight announce shields a later heartbeat failure", async () => { + const announceHeartbeat = Promise.withResolvers() + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return announceHeartbeat.promise + }, + log: nolog, + }) + + // Start the announce; it blocks on the heartbeat. + const announcePromise = state.announce("ses_shield") + await Promise.resolve() + expect(heartbeatCalls).toBe(1) + + // Presence adopts the in-flight id and fires its own heartbeat. + state.setPresence(["ses_shield"]) + await Promise.resolve() + expect(heartbeatCalls).toBe(2) + // Presence adopted; the pending entry was already cleared. + expect([...state.union()].sort()).toEqual(["ses_shield"]) + + // Now the OLD announce heartbeat rejects. The attach was successful + // (presence owns the id), so the announce caller must NOT see this + // as an attach failure. + announceHeartbeat.reject(new Error("late relay failure")) + await expect(announcePromise).resolves.toBeUndefined() + // Presence is still authoritative; the union still reflects the id. + expect([...state.union()].sort()).toEqual(["ses_shield"]) + + // A subsequent announce for the same id must short-circuit on the + // presence check, NOT start a new heartbeat (no stale inflight entry). + const followup = state.announce("ses_shield") + await followup + expect(heartbeatCalls).toBe(2) + }) + + // Truly unadopted announce heartbeat failure must still reject and roll + // back. Pair with the shield test above to prove both paths behave + // coherently. + test("unadopted heartbeat failure still rejects and rolls back", async () => { + let resolve: () => void = () => {} + let reject: (reason: unknown) => void = () => {} + const make = () => + new Promise((res, rej) => { + resolve = res + reject = rej + }) + let current = make() + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return current + }, + log: nolog, + }) + + const announcePromise = state.announce("ses_unadopted") + // Mark the announce's eventual rejection as handled so the unhandled + // rejection tracker does not stall the test. expect.rejects.toThrow + // only attaches a handler when awaited; the explicit catch keeps the + // runtime happy between the two await points. + announcePromise.catch(() => {}) + + await Promise.resolve() + expect(heartbeatCalls).toBe(1) + + // Presence does NOT adopt the id — its set is empty. + reject(new Error("relay down")) + await expect(announcePromise).rejects.toThrow("relay down") + expect([...state.union()]).toEqual([]) + + // A fresh announce must start a new heartbeat; no stale inflight entry. + current = make() + const second = state.announce("ses_unadopted") + await Promise.resolve() + expect(heartbeatCalls).toBe(2) + resolve() + await second + expect([...state.union()].sort()).toEqual(["ses_unadopted"]) + }) + + // Gap 2 (lifecycle): after a settled in-flight entry, a later announce + // for the same id must NOT be pinned to the old promise. With the + // map design, the owner clears its entry in `finally` only + // if the map still points to its own promise. A new announce can + // therefore start a fresh heartbeat, and a stale promise never blocks + // later traffic. + test("a settled in-flight entry is cleared and does not pin later announces", async () => { + let resolve: () => void = () => {} + let reject: (reason: unknown) => void = () => {} + const make = () => + new Promise((res, rej) => { + resolve = res + reject = rej + }) + let current = make() + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return current + }, + log: nolog, + }) + + // First announce runs to completion. After it settles, its inflight + // entry must be gone (the owner finally clears it). + const a = state.announce("ses_pinned") + await Promise.resolve() + resolve() + await a + expect(heartbeatCalls).toBe(1) + expect([...state.union()].sort()).toEqual(["ses_pinned"]) + + // The id is still in pending (presence has not adopted it), so a + // second announce short-circuits via the pending check without + // starting a new heartbeat. The inflight map is empty; the + // settled promise is not pinned. + const b = state.announce("ses_pinned") + await b + expect(heartbeatCalls).toBe(1) + expect([...state.union()].sort()).toEqual(["ses_pinned"]) + + // Drop the id from the union (reset) and announce again. The new + // announce must take a fresh heartbeat path with no stale pin. + state.reset() + current = make() + const c = state.announce("ses_pinned") + await Promise.resolve() + expect(heartbeatCalls).toBe(2) + resolve() + await c + expect([...state.union()].sort()).toEqual(["ses_pinned"]) + }) + + // reset() must drop every in-flight entry so new announces after reset + // can start fresh heartbeats. + test("reset drops in-flight entries and a follow-up announce starts fresh", async () => { + let resolve: () => void = () => {} + let reject: (reason: unknown) => void = () => {} + const make = () => + new Promise((res, rej) => { + resolve = res + reject = rej + }) + let current = make() + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + return current + }, + log: nolog, + }) + + // Start an announce, then reset before it settles. + const a = state.announce("ses_reset") + await Promise.resolve() + expect(heartbeatCalls).toBe(1) + state.reset() + // The map was cleared by reset; resolving the old promise is a no-op + // for any future caller because the entry is gone. + resolve() + await a + expect([...state.union()]).toEqual([]) + + // After reset, a fresh announce must start a new heartbeat (the + // cleared inflight entry does not block it). + current = make() + const b = state.announce("ses_reset") + await Promise.resolve() + expect(heartbeatCalls).toBe(2) + resolve() + await b + expect([...state.union()].sort()).toEqual(["ses_reset"]) + }) + + // Collision regression: two distinct id sets must never produce the + // same union key. The historical "|" separator was unsafe — sets + // ["a", "b"] and ["a|b"] both encoded to the same key, which let + // setPresence skip a heartbeat it should have fired. The fix length- + // prefixes each id; this test pins the observable consequence + // (the two distinct sets fire two heartbeats). + test("union key is collision-safe across distinct id sets containing the historical separator", () => { + let calls = 0 + const state = AttachedState.create({ + heartbeat: () => { + calls += 1 + return Promise.resolve() + }, + log: nolog, + }) + state.setPresence(["a", "b"]) + state.setPresence(["a|b"]) + // With the old key, the second setPresence would have been a no-op + // because the keys collided. With the collision-safe key it must fire. + expect(calls).toBe(2) + expect([...state.union()]).toEqual(["a|b"]) + }) + + // Gap 4 (lifecycle): after reset(), a late success from an in-flight + // announce started before the reset must not overwrite lastSentKey. + // Without the generation guard, the old promise's + // `lastSentKey = keyOf(union())` writes a value derived from the new + // generation's union, causing redundant or stale change detection in + // subsequent setPresence calls. The old completion must be a no-op + // for lastSentKey; the new lifecycle's setPresence calls drive the + // baseline. + test("stale announce completion after reset does not overwrite lastSentKey", async () => { + const staleHeartbeat = Promise.withResolvers() + const presenceHeartbeat = Promise.withResolvers() + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + if (heartbeatCalls === 1) return staleHeartbeat.promise + return presenceHeartbeat.promise + }, + log: nolog, + }) + + // Start the stale announce; it blocks on the controlled heartbeat. + const stale = state.announce("ses_stale") + await Promise.resolve() + expect(heartbeatCalls).toBe(1) + + // Reset clears state and the in-flight map; the stale announce's + // captured generation no longer matches the current one. + state.reset() + + // Fresh presence replacement after reset fires a new heartbeat. + state.setPresence(["ses_fresh"]) + await Promise.resolve() + expect(heartbeatCalls).toBe(2) + + // Resolve the stale heartbeat. The stale announce's success path must + // NOT overwrite lastSentKey (the new lifecycle's baseline). + staleHeartbeat.resolve() + await stale + + // Resolve the presence heartbeat so it does not stall the test. + presenceHeartbeat.resolve() + + // Union reflects the new presence set; the stale id is not added back. + expect([...state.union()].sort()).toEqual(["ses_fresh"]) + + // Repeat the same setPresence; it must NOT fire a redundant heartbeat + // because lastSentKey is still accurate. + const heartbeatsBeforeRepeat = heartbeatCalls + state.setPresence(["ses_fresh"]) + expect(heartbeatCalls).toBe(heartbeatsBeforeRepeat) + expect([...state.union()].sort()).toEqual(["ses_fresh"]) + }) + + // Gap 4 (lifecycle, replacement): after reset(), a late completion from + // an in-flight announce started before the reset must not clobber a + // replacement in-flight entry installed after the reset. The existing + // `finally` guard (`inflight.get(id) === owned`) already handles the + // in-flight map; this test pins the contract end-to-end so a future + // refactor cannot accidentally drop the replacement entry when an old + // promise settles. + test("stale announce completion after reset does not clobber a replacement in-flight entry", async () => { + const staleHeartbeat = Promise.withResolvers() + const replacementHeartbeat = Promise.withResolvers() + let heartbeatCalls = 0 + const state = AttachedState.create({ + heartbeat: () => { + heartbeatCalls += 1 + if (heartbeatCalls === 1) return staleHeartbeat.promise + return replacementHeartbeat.promise + }, + log: nolog, + }) + + // Start the stale announce; it blocks on the controlled heartbeat. + const stale = state.announce("ses_x") + await Promise.resolve() + expect(heartbeatCalls).toBe(1) + + // Reset clears state and the in-flight map. + state.reset() + + // Install a replacement in-flight entry for the same id. + const replacement = state.announce("ses_x") + await Promise.resolve() + expect(heartbeatCalls).toBe(2) + + // Resolve the stale heartbeat. The stale announce's finally must NOT + // delete the replacement in-flight entry; the map still points to + // the replacement's Promise, not the stale one. + staleHeartbeat.resolve() + await stale + + // The replacement in-flight entry is still in the map. Resolve the + // replacement heartbeat and verify it settles cleanly. + replacementHeartbeat.resolve() + await replacement + expect([...state.union()].sort()).toEqual(["ses_x"]) + }) }) diff --git a/packages/opencode/test/kilocode/sessions/remote-command.test.ts b/packages/opencode/test/kilocode/sessions/remote-command.test.ts index 150bf80296c..8b48958b6c5 100644 --- a/packages/opencode/test/kilocode/sessions/remote-command.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-command.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test" import { RemoteCommand } from "../../../src/kilo-sessions/remote-command" import type { Info as SessionInfo } from "../../../src/session/session" -import { SessionID } from "../../../src/session/schema" +import { MessageV2 } from "../../../src/session/message-v2" +import { MessageID, SessionID } from "../../../src/session/schema" describe("RemoteCommand", () => { test("validates list command protocol requests", () => { @@ -449,24 +450,42 @@ describe("RemoteCommand", () => { id: SessionID.make("ses_remote"), // No session.agent and no session.model } as unknown as SessionInfo - const retained = [ - // older assistant turn retained (must be ignored) + // Typed retained-message fixture: every field the production + // session.messages() / compaction.create() path reads is present and + // matches MessageV2.WithParts. The Assistant entry exists only to + // exercise the findLast(user) path; the latest User entry is the one + // that must supply agent + model. + const sid = SessionID.make("ses_remote") + const retained: MessageV2.WithParts[] = [ { - info: { id: "msg_old", role: "assistant", sessionID: SessionID.make("ses_remote") }, + info: { + id: MessageID.make("msg_old"), + role: "assistant", + sessionID: sid, + time: { created: 0 }, + parentID: MessageID.make("msg_root"), + providerID: "user-provider" as never, + modelID: "user-model" as never, + mode: "primary", + agent: "user-agent", + path: { cwd: "/", root: "/" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, parts: [], }, - // the latest retained user message supplies agent + model { info: { - id: "msg_user", + id: MessageID.make("msg_user"), role: "user", - sessionID: SessionID.make("ses_remote"), + sessionID: sid, + time: { created: 1 }, agent: "user-agent", - model: { providerID: "user-provider", modelID: "user-model" }, + model: { providerID: "user-provider" as never, modelID: "user-model" as never }, }, parts: [], }, - ] as any + ] const remote = RemoteCommand.create({ list: async () => [], command: async () => { @@ -565,4 +584,88 @@ describe("RemoteCommand", () => { }, ]) }) + + // execute() is the last line of defense: even if a caller bypasses the + // dispatcher's preflight, a command absent from the supplied bounded + // catalog must never reach services.command(). + test("execute rejects commands absent from the supplied catalog before invoking services.command()", async () => { + const calls: unknown[] = [] + const remote = RemoteCommand.create({ + list: async () => [], + command: async (input) => { + calls.push(input) + }, + session: { + get: async () => { + throw new Error("unexpected session lookup") + }, + messages: async () => { + throw new Error("unexpected message lookup") + }, + }, + agent: { default: async () => "default-agent" }, + provider: { default: async () => ({ providerID: "default-provider", modelID: "default-model" }) }, + revert: { cleanup: async () => {} }, + compaction: { create: async () => {} }, + prompt: { loop: async () => {} }, + }) + + const catalog: RemoteCommand.Response = { + protocolVersion: 1, + commands: [{ name: "review", hints: [] }], + } + + await expect( + remote.execute({ + sessionID: SessionID.make("ses_remote"), + protocolVersion: 1, + command: "missing", + arguments: "", + catalog, + }), + ).rejects.toThrow("unknown slash command: missing") + expect(calls).toEqual([]) + }) + + // Even when the catalog advertises "compact" (the synthesized built-in), + // a name not in the catalog must still be rejected. + test("execute rejects arbitrary names even when the catalog advertises built-in compact", async () => { + const calls: unknown[] = [] + const remote = RemoteCommand.create({ + list: async () => [], + command: async (input) => { + calls.push(input) + }, + session: { + get: async () => { + throw new Error("unexpected session lookup") + }, + messages: async () => { + throw new Error("unexpected message lookup") + }, + }, + agent: { default: async () => "default-agent" }, + provider: { default: async () => ({ providerID: "default-provider", modelID: "default-model" }) }, + revert: { cleanup: async () => {} }, + compaction: { create: async () => {} }, + prompt: { loop: async () => {} }, + }) + + // Catalog seeded with just the built-in compact (no registered "compact" + // with source=="command"|"mcp"), so the shadow check would fall back to + // the built-in path for "compact". The interesting case is a different + // unknown name — it must be rejected regardless. + const catalog = RemoteCommand.build([]) + + await expect( + remote.execute({ + sessionID: SessionID.make("ses_remote"), + protocolVersion: 1, + command: "rm", + arguments: "", + catalog, + }), + ).rejects.toThrow("unknown slash command: rm") + expect(calls).toEqual([]) + }) }) diff --git a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts index a15bc7e5d74..95072210fc2 100644 --- a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts @@ -428,7 +428,10 @@ describe("RemoteSender", () => { }, }, }) as any, - default: async () => ({ providerID: ProviderV2.ID.make("custom"), modelID: ModelV2.ID.make("deployment/model") }), + default: async () => ({ + providerID: ProviderV2.ID.make("custom"), + modelID: ModelV2.ID.make("deployment/model"), + }), }, }) @@ -793,7 +796,10 @@ describe("RemoteSender", () => { { sessionID: SessionID.make("ses_x"), parts: [{ type: "text", text: "hello" }], - model: { providerID: ProviderV2.ID.make("kilo"), modelID: ModelV2.ID.make("anthropic/claude-sonnet-4-20250514") }, + model: { + providerID: ProviderV2.ID.make("kilo"), + modelID: ModelV2.ID.make("anthropic/claude-sonnet-4-20250514"), + }, ephemeralTools: { interactive_terminal: false }, }, ]) @@ -1596,9 +1602,7 @@ describe("RemoteSender", () => { provide: async (input: any) => input.fn(), session: { get: async (sessionID) => - sessionID === child.id - ? child - : ({ id: sessionID, directory: "/workspace/root" } as Session.Info), + sessionID === child.id ? child : ({ id: sessionID, directory: "/workspace/root" } as Session.Info), children: async (sessionID) => (sessionID === SessionID.make("ses_root") ? [child] : []), }, question: questions(), @@ -1740,13 +1744,13 @@ describe("RemoteSender slash commands", () => { // Wrappers chain so multiple pending responses can be awaited on the same // connection; the original fakeConn.send still records each emission once. function expectResponse(conn: RemoteWS.Connection, _sent: any[], id: string) { - const resolvers = Promise.withResolvers() + const resolvers = Promise.withResolvers() const previous = conn.send.bind(conn) - const spy = (message: any) => { + const spy = (message: RemoteProtocol.Outbound) => { if (message?.type === "response" && message.id === id) resolvers.resolve(message) previous(message) } - conn.send = spy as any + conn.send = spy return { promise: resolvers.promise, restore: () => { @@ -2094,7 +2098,7 @@ describe("RemoteSender slash commands", () => { return input.fn() }, session: { - get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" } as any), + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any, children: async () => [], create: async (input) => { createCalls.calls += 1 @@ -2132,9 +2136,7 @@ describe("RemoteSender slash commands", () => { expect(createCalls.input).toEqual({}) expect(attachCalls).toEqual(["ses_new"]) expect(order).toEqual(["create", "attach", "heartbeat"]) - expect(sent).toEqual([ - { type: "response", id: "req_create", result: { protocolVersion: 1, sessionID: "ses_new" } }, - ]) + expect(sent).toEqual([{ type: "response", id: "req_create", result: { protocolVersion: 1, sessionID: "ses_new" } }]) }) test("create_session rejects unsupported protocol versions and missing or invalid session IDs", async () => { @@ -2211,7 +2213,7 @@ describe("RemoteSender slash commands", () => { subscribe: fakeBus().subscribe, provide: async (input: { directory: string; fn: () => R }) => input.fn(), session: { - get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" } as any), + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any, children: async () => [], create: async () => { throw new Error("private failure detail: token=must-not-leak") @@ -2248,6 +2250,7 @@ describe("RemoteSender slash commands", () => { const { conn, sent } = fakeConn() const logEntries: unknown[][] = [] const attachCalls: string[] = [] + const removeCalls: string[] = [] const sender = RemoteSender.create({ conn, directory: "/tmp/process-default", @@ -2255,10 +2258,12 @@ describe("RemoteSender slash commands", () => { subscribe: fakeBus().subscribe, provide: async (input: { directory: string; fn: () => R }) => input.fn(), session: { - get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" } as any), + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any, children: async () => [], - create: async () => - ({ id: SessionID.make("ses_new"), directory: "/workspace/project-a" } as any), + create: async () => ({ id: SessionID.make("ses_new"), directory: "/workspace/project-a" }) as any, + remove: async (id) => { + removeCalls.push(id) + }, }, attachSession: async (id) => { // The production contract puts the heartbeat inside attachSession so @@ -2284,6 +2289,8 @@ describe("RemoteSender slash commands", () => { expect(sent).toEqual([{ type: "response", id: "req_heartbeat_failed", error: "failed to create session" }]) expect(attachCalls).toEqual(["ses_new"]) + // The orphan rollback must have been attempted for the created session. + expect(removeCalls).toEqual(["ses_new"]) expect(logEntries).toHaveLength(1) expect(logEntries[0]?.[0]).toBe("create session failed") const flattened = JSON.stringify(logEntries) @@ -2291,6 +2298,129 @@ describe("RemoteSender slash commands", () => { expect(flattened).not.toContain("credential=") }) + test("create_session rolls back the created session when attachSession fails", async () => { + const { conn, sent } = fakeConn() + const removeCalls: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + session: { + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any, + children: async () => [], + create: async () => ({ id: SessionID.make("ses_new"), directory: "/workspace/project-a" }) as any, + remove: async (id) => { + removeCalls.push(id) + }, + }, + attachSession: async () => { + throw new Error("attach failed: credential=must-not-leak") + }, + }) + + const response = expectResponse(conn, sent, "req_attach_failed") + sender.handle({ + type: "command", + id: "req_attach_failed", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await response.promise + response.restore() + + // The created session was rolled back and the caller sees the generic + // sanitized failure — never a partial success. + expect(removeCalls).toEqual(["ses_new"]) + expect(sent).toEqual([{ type: "response", id: "req_attach_failed", error: "failed to create session" }]) + }) + + test("create_session preserves the original attach error when the rollback itself fails", async () => { + const { conn, sent } = fakeConn() + const logEntries: unknown[][] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: { ...nolog, error: (...args: unknown[]) => logEntries.push(args) }, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + session: { + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any, + children: async () => [], + create: async () => ({ id: SessionID.make("ses_new"), directory: "/workspace/project-a" }) as any, + remove: async () => { + throw new Error("cleanup secondary failure") + }, + }, + attachSession: async () => { + throw new Error("primary attach failure: credential=must-not-leak") + }, + }) + + const response = expectResponse(conn, sent, "req_attach_then_cleanup_fail") + sender.handle({ + type: "command", + id: "req_attach_then_cleanup_fail", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await response.promise + response.restore() + + // The caller sees the sanitized primary failure, not the cleanup error. + expect(sent).toEqual([{ type: "response", id: "req_attach_then_cleanup_fail", error: "failed to create session" }]) + // The cleanup failure is logged for observability but does not leak the + // primary attach error message to the response or to the cleanup log. + const cleanupLog = logEntries.find((entry) => entry[0] === "create session cleanup failed") + expect(cleanupLog).toBeDefined() + const flattened = JSON.stringify(logEntries) + expect(flattened).not.toContain("must-not-leak") + expect(flattened).not.toContain("credential=") + }) + + test("create_session does not remove the created session when attach succeeds", async () => { + const { conn, sent } = fakeConn() + const removeCalls: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + session: { + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any, + children: async () => [], + create: async () => ({ id: SessionID.make("ses_new"), directory: "/workspace/project-a" }) as any, + remove: async (id) => { + removeCalls.push(id) + }, + }, + attachSession: async () => { + await (conn as any).heartbeat() + }, + }) + ;(conn as any).heartbeat = async () => {} + + const response = expectResponse(conn, sent, "req_create_success") + sender.handle({ + type: "command", + id: "req_create_success", + command: "create_session", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await response.promise + response.restore() + + expect(removeCalls).toEqual([]) + expect(sent).toEqual([ + { type: "response", id: "req_create_success", result: { protocolVersion: 1, sessionID: "ses_new" } }, + ]) + }) + test("create_session runs in the current session's directory", async () => { const { conn, sent } = fakeConn() const dirs: string[] = [] @@ -2310,7 +2440,7 @@ describe("RemoteSender slash commands", () => { throw new Error("unknown session") }, children: async () => [], - create: async () => ({ id: SessionID.make("ses_new") } as any), + create: async () => ({ id: SessionID.make("ses_new") }) as any, }, attachSession: async () => {}, }) @@ -2352,9 +2482,9 @@ describe("RemoteSender slash commands", () => { subscribe: fakeBus().subscribe, provide: async (input: { directory: string; fn: () => R }) => input.fn(), session: { - get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" } as any), + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any, children: async () => [], - create: async () => ({ id: SessionID.make("ses_same") } as any), + create: async () => ({ id: SessionID.make("ses_same") }) as any, }, attachSession: async (id) => { attachCalls.push(id) @@ -2405,9 +2535,9 @@ describe("RemoteSender slash commands", () => { subscribe: fakeBus().subscribe, provide: async (input: { directory: string; fn: () => R }) => input.fn(), session: { - get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" } as any), + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any, children: async () => [], - create: async () => ({ id: SessionID.make("ses_existing") } as any), + create: async () => ({ id: SessionID.make("ses_existing") }) as any, }, attachSession: async () => { // Simulate a duplicate-safe attach: nothing to do, no heartbeat needed. From 8f9b36f812a4d76df9c065a664b5f32f0316ce89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 15 Jul 2026 22:59:29 +0200 Subject: [PATCH 4/6] chore: trigger latest-head review From 4aa4fff57077d8591fda550fa21a68e97be17cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 16 Jul 2026 18:51:49 +0200 Subject: [PATCH 5/6] feat(cli): support remote exit --- .../src/cli/cmd/tui/remote-exit-bridge.ts | 30 ++ .../src/cli/cmd/tui/remote-exit-rpc.ts | 3 + .../src/cli/cmd/tui/remote-exit-worker.ts | 22 ++ packages/opencode/src/cli/cmd/tui/thread.ts | 81 +++-- packages/opencode/src/cli/cmd/tui/worker.ts | 9 + .../src/kilo-sessions/remote-command.ts | 49 ++- .../opencode/src/kilo-sessions/remote-exit.ts | 17 + .../src/kilo-sessions/remote-sender.ts | 42 ++- .../cli/tui/remote-exit-bridge.test.ts | 138 ++++++++ .../cli/tui/remote-exit-worker.test.ts | 37 +++ .../test/kilocode/cli/tui/thread.test.ts | 48 ++- .../kilocode/sessions/remote-command.test.ts | 84 +++++ .../kilocode/sessions/remote-exit.test.ts | 42 +++ .../kilocode/sessions/remote-sender.test.ts | 307 ++++++++++++++++++ 14 files changed, 872 insertions(+), 37 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/remote-exit-bridge.ts create mode 100644 packages/opencode/src/cli/cmd/tui/remote-exit-rpc.ts create mode 100644 packages/opencode/src/cli/cmd/tui/remote-exit-worker.ts create mode 100644 packages/opencode/src/kilo-sessions/remote-exit.ts create mode 100644 packages/opencode/test/kilocode/cli/tui/remote-exit-bridge.test.ts create mode 100644 packages/opencode/test/kilocode/cli/tui/remote-exit-worker.test.ts create mode 100644 packages/opencode/test/kilocode/sessions/remote-exit.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/remote-exit-bridge.ts b/packages/opencode/src/cli/cmd/tui/remote-exit-bridge.ts new file mode 100644 index 00000000000..04c64c3370c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/remote-exit-bridge.ts @@ -0,0 +1,30 @@ +import type { Exit } from "@/cli/cmd/tui/context/exit" +import { RemoteExitRpc } from "@/cli/cmd/tui/remote-exit-rpc" +import { withTimeout } from "@/util/timeout" + +export type RemoteExitBridgeClient = { + on: (event: string, handler: () => void) => () => void + call: (method: "tuiReady" | "tuiGone", input: undefined) => Promise +} + +export function createParentRemoteExitBridge(client: RemoteExitBridgeClient, exit: Exit) { + const unsubscribe = client.on(RemoteExitRpc.Event, () => { + void exit() + }) + let disposed = false + + return { + ready() { + return client.call("tuiReady", undefined) + }, + async dispose(timeoutMs = 5_000) { + if (disposed) return + disposed = true + try { + await withTimeout(client.call("tuiGone", undefined), timeoutMs, "remote exit cleanup timed out") + } finally { + unsubscribe() + } + }, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/remote-exit-rpc.ts b/packages/opencode/src/cli/cmd/tui/remote-exit-rpc.ts new file mode 100644 index 00000000000..380931d6d1b --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/remote-exit-rpc.ts @@ -0,0 +1,3 @@ +export namespace RemoteExitRpc { + export const Event = "tui.exit" +} diff --git a/packages/opencode/src/cli/cmd/tui/remote-exit-worker.ts b/packages/opencode/src/cli/cmd/tui/remote-exit-worker.ts new file mode 100644 index 00000000000..027eb310593 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/remote-exit-worker.ts @@ -0,0 +1,22 @@ +import { RemoteExitRpc } from "@/cli/cmd/tui/remote-exit-rpc" +import { RemoteExit } from "@/kilo-sessions/remote-exit" + +export function createWorkerRemoteExit(emit: (event: string, data: undefined) => void) { + let unregister: (() => void) | undefined + + const gone = () => { + unregister?.() + unregister = undefined + } + + return { + ready() { + gone() + unregister = RemoteExit.register(async () => { + emit(RemoteExitRpc.Event, undefined) + }) + }, + gone, + shutdown: gone, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index f1e84f9cf0a..2759f7d3343 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -24,6 +24,8 @@ import { sanitizedProcessEnv, } from "@opencode-ai/core/util/opencode-process" import { validateSession } from "./validate-session" +import { createParentRemoteExitBridge, type RemoteExitBridgeClient } from "./remote-exit-bridge" // kilocode_change +import type { Exit } from "./context/exit" // kilocode_change declare global { const KILO_WORKER_PATH: string @@ -31,13 +33,43 @@ declare global { type RpcClient = ReturnType> +export function embeddedRemoteExitClient(external: boolean, client: T | undefined): T | undefined { + return external ? undefined : client +} + +export async function runEmbeddedRemoteExitBridge(input: { + client: RemoteExitBridgeClient + exit: Exit + done: Promise + timeoutMs?: number +}) { + const timeoutMs = input.timeoutMs ?? 5_000 + const bridge = createParentRemoteExitBridge(input.client, input.exit) + let ready = false + try { + try { + await withTimeout(bridge.ready(), timeoutMs, "remote exit startup timed out") + ready = true + } catch { + await bridge.dispose(timeoutMs).catch(() => {}) + } + await input.done + } finally { + if (ready) await bridge.dispose(timeoutMs).catch(() => {}) + } +} + // kilocode_change start - lazy-load the TUI app after daemon attach in source mode -async function start(input: StartInput) { +async function start(input: StartInput, remoteExitClient?: RpcClient) { const app = await import("./app") // start() creates the renderer for both paths, daemon/worker const renderer = await app.createTuiRenderer(input.config) const handle = app.tui({ ...input, renderer }) - await handle.done + if (!remoteExitClient) { + await handle.done + return + } + await runEmbeddedRemoteExitBridge({ client: remoteExitClient, exit: handle.exit, done: handle.done }) // kilocode_change } // kilocode_change end @@ -360,28 +392,31 @@ export const TuiThreadCommand = cmd({ } // kilocode_change start - await start({ - // kilocode_change - shared lazy loader also supports daemon attach - url: transport.url, - async onSnapshot() { - const tui = writeHeapSnapshot("tui.heapsnapshot") - const server = await client.call("snapshot", undefined) - return [tui, server] - }, - config, - directory: cwd, - fetch: transport.fetch, - headers: transport.headers, // kilocode_change - events: transport.events, - args: { - continue: args.continue, - sessionID: args.session, - agent: args.agent, - model: args.model, - prompt, - fork: args.fork, + await start( + { + // kilocode_change - shared lazy loader also supports daemon attach + url: transport.url, + async onSnapshot() { + const tui = writeHeapSnapshot("tui.heapsnapshot") + const server = await client.call("snapshot", undefined) + return [tui, server] + }, + config, + directory: cwd, + fetch: transport.fetch, + headers: transport.headers, // kilocode_change + events: transport.events, + args: { + continue: args.continue, + sessionID: args.session, + agent: args.agent, + model: args.model, + prompt, + fork: args.fork, + }, }, - }) + embeddedRemoteExitClient(external, client), + ) // kilocode_change end } finally { await stop() diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index fe989ac7643..412e83508f9 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -13,6 +13,7 @@ import { AppRuntime } from "@/effect/app-runtime" import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" import { Effect } from "effect" import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" +import { createWorkerRemoteExit } from "@/cli/cmd/tui/remote-exit-worker" // kilocode_change ensureProcessMetadata("worker") @@ -45,8 +46,15 @@ GlobalBus.on("event", (event) => { }) let server: Awaited> | undefined +const remoteExit = createWorkerRemoteExit(Rpc.emit) // kilocode_change export const rpc = { + tuiReady() { + remoteExit.ready() // kilocode_change + }, + tuiGone() { + remoteExit.gone() // kilocode_change + }, async fetch(input: { url: string; method: string; headers: Record; body?: string }) { const headers = { ...input.headers } const auth = ServerAuth.header() @@ -90,6 +98,7 @@ export const rpc = { }, async shutdown() { Log.Default.info("worker shutting down") + remoteExit.shutdown() // kilocode_change await InstanceRuntime.disposeAllInstances() if (server) await server.stop(true) diff --git a/packages/opencode/src/kilo-sessions/remote-command.ts b/packages/opencode/src/kilo-sessions/remote-command.ts index a60f9b65cb6..3e9ac87d72b 100644 --- a/packages/opencode/src/kilo-sessions/remote-command.ts +++ b/packages/opencode/src/kilo-sessions/remote-command.ts @@ -5,6 +5,7 @@ import type { MessageV2 } from "@/session/message-v2" import type { SessionPrompt } from "@/session/prompt" import type { Info as SessionInfo } from "@/session/session" import { MessageID, type SessionID } from "@/session/schema" +import { RemoteExit } from "@/kilo-sessions/remote-exit" import z from "zod" export namespace RemoteCommand { @@ -20,6 +21,12 @@ export namespace RemoteCommand { }) .strict() + export const ExitRequest = z + .object({ + protocolVersion: z.literal(1), + }) + .strict() + export const SendRequest = z .object({ protocolVersion: z.literal(1), @@ -68,6 +75,16 @@ export namespace RemoteCommand { hints: [], } + const exit: Info = { + name: "exit", + description: "Exit the CLI", + hints: [], + } + + export function executable(name: string): boolean { + return name !== exit.name + } + function compare(a: Info, b: Info) { if (a.name < b.name) return -1 if (a.name > b.name) return 1 @@ -75,16 +92,21 @@ export namespace RemoteCommand { } // Truncates the alphabetical tail to stay within the count and byte caps. - // The `compact` entry is seeded first so truncation can never take remote - // compaction away; sizes are accumulated per entry to keep this a single pass. + // Required synthesized entries are seeded first so truncation cannot remove + // them; sizes are accumulated per entry to keep this a single pass. function truncate(commands: Info[]): Info[] { const encoder = new TextEncoder() const measure = (value: unknown) => encoder.encode(JSON.stringify(value)).byteLength - const required = commands.find((item) => item.name === compact.name) ?? compact - const selected: Info[] = [required] - let budget = MAX_RESULT_BYTES - measure({ protocolVersion: 1, commands: [] }) - measure(required) + const required = [commands.find((item) => item.name === compact.name) ?? compact] + const processExit = commands.find((item) => item.name === exit.name) + if (processExit) required.push(processExit) + const selected: Info[] = [...required] + let budget = + MAX_RESULT_BYTES - + measure({ protocolVersion: 1, commands: [] }) - + required.reduce((total, item, index) => total + measure(item) + (index ? 1 : 0), 0) for (const item of commands) { - if (item === required) continue + if (required.includes(item)) continue if (selected.length >= MAX_COMMANDS) break const bytes = measure(item) + 1 // +1 for the separating comma if (bytes > budget) break @@ -98,7 +120,7 @@ export namespace RemoteCommand { // entries whose fields exceed the per-field limits. Shared by build() and // the compact shadow check so discovery and execution apply the same rules. function parse(source: CommandInfo): Info | undefined { - if (source.source === "skill") return + if (source.source === "skill" || source.name === exit.name) return const item = Info.safeParse({ name: source.name, ...(source.description !== undefined ? { description: source.description } : {}), @@ -111,7 +133,7 @@ export namespace RemoteCommand { return item.success ? item.data : undefined } - export function build(items: ReadonlyArray): Response { + export function build(items: ReadonlyArray, exitAvailable = false): Response { const names = new Set() const commands: Info[] = [] @@ -122,9 +144,10 @@ export namespace RemoteCommand { commands.push(item) } - // truncate() sorts its output and seeds the synthesized "compact" first, so - // the response stays alphabetized regardless of input order. + // truncate() sorts its output after preserving synthesized entries, so the + // response stays alphabetized regardless of input order. if (!names.has(compact.name)) commands.push(compact) + if (exitAvailable) commands.push(exit) return Response.parse({ protocolVersion: 1, commands: truncate(commands) }) } @@ -132,6 +155,7 @@ export namespace RemoteCommand { export type Services = { list: () => Promise + exitAvailable?: () => boolean command: (input: SessionPrompt.CommandInput) => Promise session: { get: (sessionID: SessionID) => Promise @@ -158,7 +182,7 @@ export namespace RemoteCommand { export function create(services: Services): Interface { return { - list: async () => build(await services.list()), + list: async () => build(await services.list(), services.exitAvailable?.() ?? false), execute: async (input) => { // kilocode_change - enforce membership in the supplied bounded // remote-safe catalog. The dispatcher's preflight also gates @@ -168,7 +192,7 @@ export namespace RemoteCommand { // the mobile client was never offered. Reject with a specific // error so the failure is distinguishable from runtime faults. const catalogNames = new Set(input.catalog.commands.map((item) => item.name)) - if (!catalogNames.has(input.command)) { + if (!executable(input.command) || !catalogNames.has(input.command)) { throw new Error(`unknown slash command: ${input.command}`) } // A registered command named `compact` shadows the built-in whenever it @@ -221,6 +245,7 @@ export namespace RemoteCommand { export function live(): Interface { return create({ + exitAvailable: () => !!RemoteExit.get(), list: async () => { const [{ AppRuntime }, { Command }] = await Promise.all([import("@/effect/app-runtime"), import("@/command")]) return AppRuntime.runPromise(Command.Service.use((service) => service.list())) diff --git a/packages/opencode/src/kilo-sessions/remote-exit.ts b/packages/opencode/src/kilo-sessions/remote-exit.ts new file mode 100644 index 00000000000..de5482e5d29 --- /dev/null +++ b/packages/opencode/src/kilo-sessions/remote-exit.ts @@ -0,0 +1,17 @@ +export namespace RemoteExit { + export type Callback = () => Promise + + let current: { callback: Callback; token: symbol } | undefined + + export function register(callback: Callback): () => void { + const token = Symbol() + current = { callback, token } + return () => { + if (current?.token === token) current = undefined + } + } + + export function get(): Callback | undefined { + return current?.callback + } +} diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index 0692c7fff0b..84a747560a8 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -1,4 +1,5 @@ import { RemoteCommand } from "@/kilo-sessions/remote-command" +import { RemoteExit } from "@/kilo-sessions/remote-exit" import { RemoteModelCatalog } from "@/kilo-sessions/remote-model-catalog" import { RemoteProtocol } from "@/kilo-sessions/remote-protocol" import type { RemoteWS } from "@/kilo-sessions/remote-ws" @@ -144,6 +145,9 @@ export namespace RemoteSender { readonly default: () => Promise } commands?: RemoteCommand.Interface + remoteExit?: { + get: () => RemoteExit.Callback | undefined + } } export type Sender = { @@ -255,6 +259,7 @@ export namespace RemoteSender { // kilocode_change end // kilocode_change start - injectable slash command discovery + execution const commands = options.commands ?? RemoteCommand.live() + const remoteExit = options.remoteExit ?? RemoteExit // kilocode_change end const sub = @@ -482,7 +487,10 @@ export namespace RemoteSender { // Reject stale catalog entries (command deleted or renamed since the // client listed) before the ACK — after it, failures are only logged. const available = await commands.list() - if (!available.commands.some((item) => item.name === parsed.data.command)) { + if ( + !RemoteCommand.executable(parsed.data.command) || + !available.commands.some((item) => item.name === parsed.data.command) + ) { options.conn.send({ type: "response", id: msg.id, error: "unknown slash command" }) return } @@ -518,6 +526,38 @@ export namespace RemoteSender { })() return } + if (msg.command === "exit_cli") { + const parsed = RemoteCommand.ExitRequest.safeParse(msg.data) + const current = msg.sessionId ? decodeSessionID(msg.sessionId) : Option.none() + if (!parsed.success || Option.isNone(current)) { + options.conn.send({ type: "response", id: msg.id, error: "invalid exit_cli command" }) + return + } + void (async () => { + try { + await session.get(current.value) + const exit = remoteExit.get() + if (!exit) { + options.conn.send({ type: "response", id: msg.id, error: "graceful exit unavailable" }) + return + } + options.conn.send({ type: "response", id: msg.id, result: {} }) + queueMicrotask(() => { + void exit().catch((error) => { + options.log.error("exit CLI failed after ACK", { + id: msg.id, + operation: "exit_cli", + error: errorName(error), + }) + }) + }) + } catch (error) { + options.log.error("exit CLI preflight failed", { id: msg.id, error: errorName(error) }) + options.conn.send({ type: "response", id: msg.id, error: "failed to exit CLI" }) + } + })() + return + } if (msg.command === "create_session") { // kilocode_change start - remote /new creation: root session, attached + heartbeat before response const parsed = CreateSessionRequest.safeParse(msg.data) diff --git a/packages/opencode/test/kilocode/cli/tui/remote-exit-bridge.test.ts b/packages/opencode/test/kilocode/cli/tui/remote-exit-bridge.test.ts new file mode 100644 index 00000000000..3008be8d494 --- /dev/null +++ b/packages/opencode/test/kilocode/cli/tui/remote-exit-bridge.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "bun:test" +import { createExit } from "../../../../src/cli/cmd/tui/context/exit" +import { RemoteExitRpc } from "../../../../src/cli/cmd/tui/remote-exit-rpc" +import { createParentRemoteExitBridge } from "../../../../src/cli/cmd/tui/remote-exit-bridge" +import { Rpc } from "../../../../src/util/rpc" + +describe("parent remote exit RPC bridge", () => { + test("subscribes before readiness and invokes the idempotent local Exit", async () => { + const order: string[] = [] + let handler: (() => void) | undefined + const client = { + on(event: string, next: () => void) { + expect(event).toBe(RemoteExitRpc.Event) + order.push("subscribe") + handler = next + return () => { + order.push("unsubscribe") + handler = undefined + } + }, + async call(method: string) { + order.push(method) + }, + } + let exits = 0 + const exit = createExit(async () => { + exits += 1 + }) + + const bridge = createParentRemoteExitBridge(client, exit) + await bridge.ready() + expect(order).toEqual(["subscribe", "tuiReady"]) + + handler?.() + handler?.() + await Promise.resolve() + expect(exits).toBe(1) + + await bridge.dispose() + expect(order).toEqual(["subscribe", "tuiReady", "tuiGone", "unsubscribe"]) + expect(handler).toBeUndefined() + }) + + test("cleanup unregisters the worker even when readiness fails", async () => { + const calls: string[] = [] + const bridge = createParentRemoteExitBridge( + { + on() { + calls.push("subscribe") + return () => calls.push("unsubscribe") + }, + async call(method: string) { + calls.push(method) + if (method === "tuiReady") throw new Error("worker unavailable") + }, + }, + createExit(async () => {}), + ) + + await expect(bridge.ready()).rejects.toThrow("worker unavailable") + await bridge.dispose() + expect(calls).toEqual(["subscribe", "tuiReady", "tuiGone", "unsubscribe"]) + }) + + test("unsubscribes in finally when tuiGone fails", async () => { + const calls: string[] = [] + let subscribed = true + const bridge = createParentRemoteExitBridge( + { + on() { + calls.push("subscribe") + return () => { + calls.push("unsubscribe") + subscribed = false + } + }, + async call(method: string) { + calls.push(method) + if (method === "tuiGone") throw new Error("worker gone failed") + }, + }, + createExit(async () => {}), + ) + + await bridge.ready() + await expect(bridge.dispose()).rejects.toThrow("worker gone failed") + expect(calls).toEqual(["subscribe", "tuiReady", "tuiGone", "unsubscribe"]) + expect(subscribed).toBe(false) + }) + + test("bounds unresolved tuiGone and still unsubscribes", async () => { + let subscribed = true + const bridge = createParentRemoteExitBridge( + { + on() { + return () => { + subscribed = false + } + }, + async call(method: string) { + if (method === "tuiGone") await new Promise(() => {}) + }, + }, + createExit(async () => {}), + ) + + await bridge.ready() + const disposing = bridge.dispose(5).catch((error: unknown) => error) + await Bun.sleep(20) + + expect(subscribed).toBe(false) + expect(await disposing).toBeInstanceOf(Error) + }) + + test("consumes a serialized worker RPC event without shared backend state", async () => { + let exits = 0 + const target = { + postMessage() {}, + onmessage: null as ((this: Worker, event: MessageEvent) => unknown) | null, + } + const client = Rpc.client<{ tuiReady: () => void; tuiGone: () => void }>(target) + const bridge = createParentRemoteExitBridge( + client, + createExit(async () => void (exits += 1)), + ) + + target.onmessage?.call( + target as unknown as Worker, + new MessageEvent("message", { + data: JSON.stringify({ type: "rpc.event", event: RemoteExitRpc.Event, data: undefined }), + }), + ) + await Promise.resolve() + + expect(exits).toBe(1) + void bridge + }) +}) diff --git a/packages/opencode/test/kilocode/cli/tui/remote-exit-worker.test.ts b/packages/opencode/test/kilocode/cli/tui/remote-exit-worker.test.ts new file mode 100644 index 00000000000..95264694b73 --- /dev/null +++ b/packages/opencode/test/kilocode/cli/tui/remote-exit-worker.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { RemoteExit } from "../../../../src/kilo-sessions/remote-exit" +import { RemoteExitRpc } from "../../../../src/cli/cmd/tui/remote-exit-rpc" +import { createWorkerRemoteExit } from "../../../../src/cli/cmd/tui/remote-exit-worker" + +describe("worker remote exit lifecycle", () => { + test("registers only after tuiReady and emits RPC instead of invoking parent state", async () => { + const events: Array<{ event: string; data: unknown }> = [] + const lifecycle = createWorkerRemoteExit((event, data) => { + events.push({ event, data }) + }) + + expect(RemoteExit.get()).toBeUndefined() + lifecycle.ready() + expect(RemoteExit.get()).toBeDefined() + + await RemoteExit.get()?.() + expect(events).toEqual([{ event: RemoteExitRpc.Event, data: undefined }]) + lifecycle.shutdown() + expect(RemoteExit.get()).toBeUndefined() + }) + + test("tuiGone and shutdown remove only their owned registration", () => { + const lifecycle = createWorkerRemoteExit(() => {}) + lifecycle.ready() + const replacement = async () => {} + const unregisterReplacement = RemoteExit.register(replacement) + + lifecycle.gone() + expect(RemoteExit.get()).toBe(replacement) + + lifecycle.ready() + lifecycle.shutdown() + expect(RemoteExit.get()).toBeUndefined() + unregisterReplacement() + }) +}) diff --git a/packages/opencode/test/kilocode/cli/tui/thread.test.ts b/packages/opencode/test/kilocode/cli/tui/thread.test.ts index a6076a03bd8..fb50262b819 100644 --- a/packages/opencode/test/kilocode/cli/tui/thread.test.ts +++ b/packages/opencode/test/kilocode/cli/tui/thread.test.ts @@ -2,7 +2,12 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" import { tmpdir } from "../../../fixture/fixture" -import { resolveThreadDirectory } from "../../../../src/cli/cmd/tui/thread" +import { + embeddedRemoteExitClient, + resolveThreadDirectory, + runEmbeddedRemoteExitBridge, +} from "../../../../src/cli/cmd/tui/thread" +import { createExit } from "../../../../src/cli/cmd/tui/context/exit" import { KiloTuiThreadDaemon } from "../../../../src/kilocode/cli/cmd/tui/thread" import { DaemonClient } from "../../../../src/kilocode/daemon/client" @@ -35,6 +40,47 @@ describe("kilo tui thread", () => { } }) + test("enables remote exit only for the embedded worker transport", () => { + const client = { marker: "worker" } + + expect(embeddedRemoteExitClient(false, client)).toBe(client) + expect(embeddedRemoteExitClient(true, client)).toBeUndefined() + expect(embeddedRemoteExitClient(false, undefined)).toBeUndefined() + }) + + test("continues TUI startup when remote-exit readiness and cleanup never reply", async () => { + const calls: string[] = [] + let handler: (() => void) | undefined + let tuiContinued = false + const done = Promise.resolve().then(() => { + tuiContinued = true + }) + + await runEmbeddedRemoteExitBridge({ + client: { + on(_event, next) { + calls.push("subscribe") + handler = next + return () => { + calls.push("unsubscribe") + handler = undefined + } + }, + async call(method) { + calls.push(method) + await new Promise(() => {}) + }, + }, + exit: createExit(async () => {}), + done, + timeoutMs: 5, + }) + + expect(tuiContinued).toBe(true) + expect(calls).toEqual(["subscribe", "tuiReady", "tuiGone", "unsubscribe"]) + expect(handler).toBeUndefined() + }) + test("validates imported daemon session over HTTP after importing from cloud", async () => { await using root = await tmpdir() const cloud = "ses_cloud" diff --git a/packages/opencode/test/kilocode/sessions/remote-command.test.ts b/packages/opencode/test/kilocode/sessions/remote-command.test.ts index 8b48958b6c5..8eeeae2c59a 100644 --- a/packages/opencode/test/kilocode/sessions/remote-command.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-command.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { RemoteCommand } from "../../../src/kilo-sessions/remote-command" +import { RemoteExit } from "../../../src/kilo-sessions/remote-exit" import type { Info as SessionInfo } from "../../../src/session/session" import { MessageV2 } from "../../../src/session/message-v2" import { MessageID, SessionID } from "../../../src/session/schema" @@ -11,6 +12,13 @@ describe("RemoteCommand", () => { expect(RemoteCommand.ListRequest.safeParse({ protocolVersion: 1, extra: true }).success).toBe(false) }) + test("validates strict exit CLI requests", () => { + expect(RemoteCommand.ExitRequest.safeParse({ protocolVersion: 1 }).success).toBe(true) + expect(RemoteCommand.ExitRequest.safeParse({}).success).toBe(false) + expect(RemoteCommand.ExitRequest.safeParse({ protocolVersion: 2 }).success).toBe(false) + expect(RemoteCommand.ExitRequest.safeParse({ protocolVersion: 1, extra: true }).success).toBe(false) + }) + test("validates structured command requests without duplicating session identity", () => { const valid = { protocolVersion: 1 as const, @@ -101,6 +109,82 @@ describe("RemoteCommand", () => { expect(JSON.stringify(catalog)).not.toContain("secret-skill") }) + test("advertises only canonical exit while the embedded worker callback is registered", () => { + const base = RemoteCommand.build([ + { name: "beta", source: "command", hints: [], template: "beta" }, + { name: "alpha", source: "command", hints: [], template: "alpha" }, + ]) + expect(base.commands.map((item) => item.name)).toEqual(["alpha", "beta", "compact"]) + + const catalog = RemoteCommand.build( + [ + ...base.commands.map((item) => ({ ...item, template: item.name })), + { name: "exit", source: "command", hints: ["must-not-leak"], template: "must-not-run" }, + ], + true, + ) + + expect(catalog.commands).toEqual([ + { name: "alpha", source: "command", hints: [] }, + { name: "beta", source: "command", hints: [] }, + { + name: "compact", + description: "compact the current session context", + hints: [], + }, + { + name: "exit", + description: "Exit the CLI", + hints: [], + }, + ]) + expect(catalog.commands.some((item) => item.name === "quit" || item.name === "q")).toBe(false) + + expect(RemoteCommand.build([]).commands.map((item) => item.name)).toEqual(["compact"]) + }) + + test("catalog omits exit until the embedded worker TUI is ready", async () => { + const remote = RemoteCommand.create({ + exitAvailable: () => !!RemoteExit.get(), + list: async () => [], + command: async () => {}, + session: { get: async () => null as never, messages: async () => [] }, + agent: { default: async () => "test" }, + provider: { default: async () => ({ providerID: "test", modelID: "test" }) }, + revert: { cleanup: async () => {} }, + compaction: { create: async () => {} }, + prompt: { loop: async () => {} }, + }) + expect((await remote.list()).commands.some((item) => item.name === "exit")).toBe(false) + + const unregister = RemoteExit.register(async () => {}) + try { + expect((await remote.list()).commands.some((item) => item.name === "exit")).toBe(true) + } finally { + unregister() + } + + expect((await remote.list()).commands.some((item) => item.name === "exit")).toBe(false) + }) + + test("keeps compact and exit within command and byte caps", () => { + const commands = Array.from({ length: RemoteCommand.MAX_COMMANDS + 10 }, (_, index) => ({ + name: `command-${String(index).padStart(3, "0")}`, + description: "x".repeat(1_900), + source: "command" as const, + hints: [], + template: "hidden", + })) + const catalog = RemoteCommand.build(commands, true) + + expect(catalog.commands).toHaveLength(RemoteCommand.MAX_COMMANDS) + expect(catalog.commands.some((item) => item.name === "compact")).toBe(true) + expect(catalog.commands.some((item) => item.name === "exit")).toBe(true) + expect(new TextEncoder().encode(JSON.stringify(catalog)).byteLength).toBeLessThanOrEqual( + RemoteCommand.MAX_RESULT_BYTES, + ) + }) + test("truncates catalogs over the command limit", () => { const commands = Array.from({ length: RemoteCommand.MAX_COMMANDS + 10 }, (_, index) => ({ name: `command-${String(index).padStart(3, "0")}`, diff --git a/packages/opencode/test/kilocode/sessions/remote-exit.test.ts b/packages/opencode/test/kilocode/sessions/remote-exit.test.ts new file mode 100644 index 00000000000..16b1843e9b6 --- /dev/null +++ b/packages/opencode/test/kilocode/sessions/remote-exit.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { RemoteExit } from "../../../src/kilo-sessions/remote-exit" + +describe("RemoteExit", () => { + const cleanups: Array<() => void> = [] + + afterEach(() => { + while (cleanups.length) cleanups.pop()?.() + }) + + test("active unregister clears its callback", () => { + const callback = async () => {} + const unregister = RemoteExit.register(callback) + cleanups.push(unregister) + + expect(RemoteExit.get()).toBe(callback) + unregister() + expect(RemoteExit.get()).toBeUndefined() + }) + + test("stale unregister preserves a different callback replacement", () => { + const first = RemoteExit.register(async () => {}) + const replacement = async () => {} + const second = RemoteExit.register(replacement) + cleanups.push(first, second) + + first() + expect(RemoteExit.get()).toBe(replacement) + }) + + test("stale unregister preserves a same-callback replacement", () => { + const callback = async () => {} + const first = RemoteExit.register(callback) + const second = RemoteExit.register(callback) + cleanups.push(first, second) + + first() + expect(RemoteExit.get()).toBe(callback) + second() + expect(RemoteExit.get()).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts index 95072210fc2..18dc0723aae 100644 --- a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test" import { afterEach, mock, spyOn } from "bun:test" import { Effect } from "effect" +import { ProjectV2 } from "@opencode-ai/core/project" +import { RemoteCommand } from "../../../src/kilo-sessions/remote-command" import { RemoteModelCatalog } from "../../../src/kilo-sessions/remote-model-catalog" import { RemoteSender } from "../../../src/kilo-sessions/remote-sender" import type { RemoteWS } from "../../../src/kilo-sessions/remote-ws" @@ -15,6 +17,7 @@ import { ModelV2 } from "@opencode-ai/core/model" import { SessionID } from "../../../src/session/schema" import { Session } from "../../../src/session/session" import { Suggestion } from "../../../src/kilocode/suggestion" // kilocode_change +import { createExit } from "../../../src/cli/cmd/tui/context/exit" function fakeConn() { const sent: any[] = [] @@ -75,6 +78,18 @@ function prompts(calls: SessionPrompt.PromptInput[]) { } } +function info(id: SessionID, directory = "/workspace/project-a") { + return { + id, + slug: id, + projectID: ProjectV2.ID.make("project_test"), + directory, + title: "Test session", + version: "test", + time: { created: 0, updated: 0 }, + } satisfies Session.Info +} + function catalogModel(providerID: string, modelID: string, name: string, reasoning = false) { return { id: ModelV2.ID.make(modelID), @@ -2082,6 +2097,298 @@ describe("RemoteSender slash commands", () => { expect(flattened).not.toContain("secret=") }) + test("send_command rejects process exit before ACK without invoking command or graceful exit", async () => { + const { conn, sent } = fakeConn() + const calls: unknown[] = [] + let callbacks = 0 + const commands = RemoteCommand.create({ + exitAvailable: () => true, + list: async () => [], + command: async (input) => { + calls.push(input) + }, + session: { + get: async () => { + throw new Error("unexpected command session lookup") + }, + messages: async () => { + throw new Error("unexpected command message lookup") + }, + }, + agent: { default: async () => "unexpected-agent" }, + provider: { default: async () => ({ providerID: "unexpected", modelID: "unexpected" }) }, + revert: { cleanup: async () => {} }, + compaction: { create: async () => {} }, + prompt: { loop: async () => {} }, + }) + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + commands, + remoteExit: { + get: () => { + callbacks += 1 + return async () => {} + }, + }, + catalog: { + get: async (id) => info(id), + messages: async () => [], + providers: async () => ({}), + default: async () => undefined, + }, + }) + + const response = expectResponse(conn, sent, "req_send_exit") + sender.handle({ + type: "command", + id: "req_send_exit", + command: "send_command", + sessionId: "ses_current", + data: { protocolVersion: 1, command: "exit", arguments: "" }, + }) + await response.promise + response.restore() + await Promise.resolve() + + expect(sent).toEqual([{ type: "response", id: "req_send_exit", error: "unknown slash command" }]) + expect(calls).toEqual([]) + expect(callbacks).toBe(0) + }) + + test("exit_cli rejects invalid, missing, unresolved, and unavailable sessions before ACK", async () => { + const { conn, sent } = fakeConn() + const lookups: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + session: { + get: async (id) => { + lookups.push(id) + if (id === SessionID.make("ses_missing")) throw new Error("private missing-session detail") + return info(id) + }, + children: async () => [], + }, + remoteExit: { + get: () => undefined, + }, + }) + + sender.handle({ + type: "command", + id: "req_exit_no_session", + command: "exit_cli", + data: { protocolVersion: 1 }, + }) + sender.handle({ + type: "command", + id: "req_exit_invalid_session", + command: "exit_cli", + sessionId: "not-a-session-id", + data: { protocolVersion: 1 }, + }) + sender.handle({ + type: "command", + id: "req_exit_bad_protocol", + command: "exit_cli", + sessionId: "ses_current", + data: { protocolVersion: 2 }, + }) + sender.handle({ + type: "command", + id: "req_exit_extra", + command: "exit_cli", + sessionId: "ses_current", + data: { protocolVersion: 1, extra: true }, + }) + + const missing = expectResponse(conn, sent, "req_exit_missing") + sender.handle({ + type: "command", + id: "req_exit_missing", + command: "exit_cli", + sessionId: "ses_missing", + data: { protocolVersion: 1 }, + }) + await missing.promise + missing.restore() + + const unavailable = expectResponse(conn, sent, "req_exit_unavailable") + sender.handle({ + type: "command", + id: "req_exit_unavailable", + command: "exit_cli", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await unavailable.promise + unavailable.restore() + + expect(sent).toEqual([ + { type: "response", id: "req_exit_no_session", error: "invalid exit_cli command" }, + { type: "response", id: "req_exit_invalid_session", error: "invalid exit_cli command" }, + { type: "response", id: "req_exit_bad_protocol", error: "invalid exit_cli command" }, + { type: "response", id: "req_exit_extra", error: "invalid exit_cli command" }, + { type: "response", id: "req_exit_missing", error: "failed to exit CLI" }, + { type: "response", id: "req_exit_unavailable", error: "graceful exit unavailable" }, + ]) + expect(lookups).toEqual(["ses_missing", "ses_current"]) + }) + + test("exit_cli ACKs before invoking the worker callback in a microtask", async () => { + const { conn, sent } = fakeConn() + const order: string[] = [] + const tasks: VoidFunction[] = [] + const original = globalThis.queueMicrotask + globalThis.queueMicrotask = (task) => { + tasks.push(task) + } + const send = conn.send.bind(conn) + conn.send = (message) => { + order.push("response") + send(message) + } + const invoked = Promise.withResolvers() + const remoteExit = { + get: () => async () => { + order.push("callback") + invoked.resolve() + }, + } + try { + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + session: { + get: async (id) => info(id), + children: async () => [], + }, + remoteExit, + }) + + const ack = expectResponse(conn, sent, "req_exit") + sender.handle({ + type: "command", + id: "req_exit", + command: "exit_cli", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await ack.promise + ack.restore() + + expect(sent).toEqual([{ type: "response", id: "req_exit", result: {} }]) + expect(order).toEqual(["response"]) + expect(tasks).toHaveLength(1) + + tasks[0]?.() + await invoked.promise + expect(order).toEqual(["response", "callback"]) + } finally { + globalThis.queueMicrotask = original + } + }) + + test("concurrent exit_cli requests ACK while idempotent local Exit cleans up once", async () => { + const { conn, sent } = fakeConn() + const completed = Promise.withResolvers() + const calls: string[] = [] + const exit = createExit(async () => { + calls.push("cleanup") + completed.resolve() + }) + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + session: { + get: async (id) => info(id), + children: async () => [], + }, + remoteExit: { + get: () => exit, + }, + }) + + sender.handle({ + type: "command", + id: "req_exit_first", + command: "exit_cli", + sessionId: "ses_first", + data: { protocolVersion: 1 }, + }) + sender.handle({ + type: "command", + id: "req_exit_second", + command: "exit_cli", + sessionId: "ses_second", + data: { protocolVersion: 1 }, + }) + await completed.promise + await Promise.resolve() + + expect(sent).toEqual([ + { type: "response", id: "req_exit_first", result: {} }, + { type: "response", id: "req_exit_second", result: {} }, + ]) + expect(calls).toEqual(["cleanup"]) + }) + + test("exit_cli logs only the error class when graceful exit fails after ACK", async () => { + class CredentialLeakError extends Error { + override name = "CredentialLeakError" + } + const { conn, sent } = fakeConn() + const logs: unknown[][] = [] + const logged = Promise.withResolvers() + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: { + ...nolog, + error: (...args: unknown[]) => { + logs.push(args) + logged.resolve() + }, + }, + subscribe: fakeBus().subscribe, + session: { + get: async (id) => info(id), + children: async () => [], + }, + remoteExit: { + get: () => async () => { + throw new CredentialLeakError("token=must-not-leak") + }, + }, + }) + + sender.handle({ + type: "command", + id: "req_exit_failed", + command: "exit_cli", + sessionId: "ses_current", + data: { protocolVersion: 1 }, + }) + await logged.promise + + expect(sent).toEqual([{ type: "response", id: "req_exit_failed", result: {} }]) + expect(logs).toEqual([ + ["exit CLI failed after ACK", { id: "req_exit_failed", operation: "exit_cli", error: "CredentialLeakError" }], + ]) + expect(JSON.stringify(logs)).not.toContain("must-not-leak") + expect(JSON.stringify(logs)).not.toContain("token=") + }) + test("create_session creates a root session in the current directory and responds in order", async () => { const { conn, sent } = fakeConn() const dirs: string[] = [] From ae8e62e222502e97bb2b52ab5ecf19f6c7d4e4ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 16 Jul 2026 19:15:30 +0200 Subject: [PATCH 6/6] fix(cli): isolate remote exit bridge --- packages/opencode/src/cli/cmd/tui/thread.ts | 2 +- packages/opencode/src/cli/cmd/tui/worker.ts | 9 ++++++--- .../src/{ => kilocode}/cli/cmd/tui/remote-exit-bridge.ts | 2 +- .../src/{ => kilocode}/cli/cmd/tui/remote-exit-rpc.ts | 0 .../src/{ => kilocode}/cli/cmd/tui/remote-exit-worker.ts | 2 +- .../test/kilocode/cli/tui/remote-exit-bridge.test.ts | 4 ++-- .../test/kilocode/cli/tui/remote-exit-worker.test.ts | 4 ++-- 7 files changed, 13 insertions(+), 10 deletions(-) rename packages/opencode/src/{ => kilocode}/cli/cmd/tui/remote-exit-bridge.ts (92%) rename packages/opencode/src/{ => kilocode}/cli/cmd/tui/remote-exit-rpc.ts (100%) rename packages/opencode/src/{ => kilocode}/cli/cmd/tui/remote-exit-worker.ts (86%) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 2759f7d3343..8647a5f5755 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -24,7 +24,7 @@ import { sanitizedProcessEnv, } from "@opencode-ai/core/util/opencode-process" import { validateSession } from "./validate-session" -import { createParentRemoteExitBridge, type RemoteExitBridgeClient } from "./remote-exit-bridge" // kilocode_change +import { createParentRemoteExitBridge, type RemoteExitBridgeClient } from "@/kilocode/cli/cmd/tui/remote-exit-bridge" // kilocode_change import type { Exit } from "./context/exit" // kilocode_change declare global { diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 412e83508f9..ea72f0cdad7 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -13,7 +13,7 @@ import { AppRuntime } from "@/effect/app-runtime" import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" import { Effect } from "effect" import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" -import { createWorkerRemoteExit } from "@/cli/cmd/tui/remote-exit-worker" // kilocode_change +import { createWorkerRemoteExit } from "@/kilocode/cli/cmd/tui/remote-exit-worker" // kilocode_change ensureProcessMetadata("worker") @@ -49,12 +49,14 @@ let server: Awaited> | undefined const remoteExit = createWorkerRemoteExit(Rpc.emit) // kilocode_change export const rpc = { + // kilocode_change start - worker lifecycle hooks for remote exit tuiReady() { - remoteExit.ready() // kilocode_change + remoteExit.ready() }, tuiGone() { - remoteExit.gone() // kilocode_change + remoteExit.gone() }, + // kilocode_change end async fetch(input: { url: string; method: string; headers: Record; body?: string }) { const headers = { ...input.headers } const auth = ServerAuth.header() @@ -97,6 +99,7 @@ export const rpc = { ) }, async shutdown() { + // kilocode_change Log.Default.info("worker shutting down") remoteExit.shutdown() // kilocode_change diff --git a/packages/opencode/src/cli/cmd/tui/remote-exit-bridge.ts b/packages/opencode/src/kilocode/cli/cmd/tui/remote-exit-bridge.ts similarity index 92% rename from packages/opencode/src/cli/cmd/tui/remote-exit-bridge.ts rename to packages/opencode/src/kilocode/cli/cmd/tui/remote-exit-bridge.ts index 04c64c3370c..d34f739b07d 100644 --- a/packages/opencode/src/cli/cmd/tui/remote-exit-bridge.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui/remote-exit-bridge.ts @@ -1,5 +1,5 @@ import type { Exit } from "@/cli/cmd/tui/context/exit" -import { RemoteExitRpc } from "@/cli/cmd/tui/remote-exit-rpc" +import { RemoteExitRpc } from "@/kilocode/cli/cmd/tui/remote-exit-rpc" import { withTimeout } from "@/util/timeout" export type RemoteExitBridgeClient = { diff --git a/packages/opencode/src/cli/cmd/tui/remote-exit-rpc.ts b/packages/opencode/src/kilocode/cli/cmd/tui/remote-exit-rpc.ts similarity index 100% rename from packages/opencode/src/cli/cmd/tui/remote-exit-rpc.ts rename to packages/opencode/src/kilocode/cli/cmd/tui/remote-exit-rpc.ts diff --git a/packages/opencode/src/cli/cmd/tui/remote-exit-worker.ts b/packages/opencode/src/kilocode/cli/cmd/tui/remote-exit-worker.ts similarity index 86% rename from packages/opencode/src/cli/cmd/tui/remote-exit-worker.ts rename to packages/opencode/src/kilocode/cli/cmd/tui/remote-exit-worker.ts index 027eb310593..67e42c10dda 100644 --- a/packages/opencode/src/cli/cmd/tui/remote-exit-worker.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui/remote-exit-worker.ts @@ -1,4 +1,4 @@ -import { RemoteExitRpc } from "@/cli/cmd/tui/remote-exit-rpc" +import { RemoteExitRpc } from "@/kilocode/cli/cmd/tui/remote-exit-rpc" import { RemoteExit } from "@/kilo-sessions/remote-exit" export function createWorkerRemoteExit(emit: (event: string, data: undefined) => void) { diff --git a/packages/opencode/test/kilocode/cli/tui/remote-exit-bridge.test.ts b/packages/opencode/test/kilocode/cli/tui/remote-exit-bridge.test.ts index 3008be8d494..9c351f81dcb 100644 --- a/packages/opencode/test/kilocode/cli/tui/remote-exit-bridge.test.ts +++ b/packages/opencode/test/kilocode/cli/tui/remote-exit-bridge.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { createExit } from "../../../../src/cli/cmd/tui/context/exit" -import { RemoteExitRpc } from "../../../../src/cli/cmd/tui/remote-exit-rpc" -import { createParentRemoteExitBridge } from "../../../../src/cli/cmd/tui/remote-exit-bridge" +import { RemoteExitRpc } from "../../../../src/kilocode/cli/cmd/tui/remote-exit-rpc" +import { createParentRemoteExitBridge } from "../../../../src/kilocode/cli/cmd/tui/remote-exit-bridge" import { Rpc } from "../../../../src/util/rpc" describe("parent remote exit RPC bridge", () => { diff --git a/packages/opencode/test/kilocode/cli/tui/remote-exit-worker.test.ts b/packages/opencode/test/kilocode/cli/tui/remote-exit-worker.test.ts index 95264694b73..463789c731b 100644 --- a/packages/opencode/test/kilocode/cli/tui/remote-exit-worker.test.ts +++ b/packages/opencode/test/kilocode/cli/tui/remote-exit-worker.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { RemoteExit } from "../../../../src/kilo-sessions/remote-exit" -import { RemoteExitRpc } from "../../../../src/cli/cmd/tui/remote-exit-rpc" -import { createWorkerRemoteExit } from "../../../../src/cli/cmd/tui/remote-exit-worker" +import { RemoteExitRpc } from "../../../../src/kilocode/cli/cmd/tui/remote-exit-rpc" +import { createWorkerRemoteExit } from "../../../../src/kilocode/cli/cmd/tui/remote-exit-worker" describe("worker remote exit lifecycle", () => { test("registers only after tuiReady and emits RPC instead of invoking parent state", async () => {