From 209e43a4a2fef6dc92a8f9f89a2393e1a1ac4c23 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 27 Apr 2026 14:20:25 +0300 Subject: [PATCH 01/18] fix(cli): avoid provider plugin test flake --- packages/opencode/src/plugin/index.ts | 3 ++- packages/opencode/test/provider/provider.test.ts | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 85c2fb3d4a2..04af4815080 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -165,12 +165,13 @@ export const layer = Layer.effect( if (Flag.KILO_PURE && cfg.plugin_origins?.length) { log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length }) } - if (plugins.length) yield* config.waitForDependencies() + const wait = () => bridge.promise(config.waitForDependencies()) // kilocode_change const loaded = yield* Effect.promise(() => PluginLoader.loadExternal({ items: plugins, kind: "server", + wait, report: { start(candidate) { log.info("loading plugin", { path: candidate.plan.spec }) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index d19ec611f64..158772d7a25 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -2530,7 +2530,10 @@ test("plugin config providers persist after instance dispose", async () => { expect(first[ProviderID.make("demo")]).toBeDefined() expect(first[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined() - await Instance.disposeAll() + await Instance.provide({ + directory: tmp.path, + fn: () => Instance.dispose(), + }) const second = await Instance.provide({ directory: tmp.path, From 0c84a0f5e784dbff87dcaa92b6ff3735b794f1de Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 27 Apr 2026 14:27:24 +0300 Subject: [PATCH 02/18] fix(cli): annotate provider plugin changes --- packages/opencode/src/plugin/index.ts | 2 +- packages/opencode/test/provider/provider.test.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 04af4815080..f063b5eb456 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -171,7 +171,7 @@ export const layer = Layer.effect( PluginLoader.loadExternal({ items: plugins, kind: "server", - wait, + wait, // kilocode_change report: { start(candidate) { log.info("loading plugin", { path: candidate.plan.spec }) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 158772d7a25..060943f9bf3 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -2530,10 +2530,12 @@ test("plugin config providers persist after instance dispose", async () => { expect(first[ProviderID.make("demo")]).toBeDefined() expect(first[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined() + // kilocode_change start await Instance.provide({ directory: tmp.path, fn: () => Instance.dispose(), }) + // kilocode_change end const second = await Instance.provide({ directory: tmp.path, From 3642cf9525a125ffc799ff9cfafbdaa3ced4dd68 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 27 Apr 2026 14:40:39 +0300 Subject: [PATCH 03/18] fix(cli): stabilize cleanup cancellation tests --- packages/opencode/src/session/prompt.ts | 4 +++- packages/opencode/src/worktree/index.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7ce63047299..2db44d26139 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -918,9 +918,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the Effect.exit, ) - if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) { + // kilocode_change start - cancelled shells can exit by signal during forced cleanup + if (Exit.isFailure(exit) && !aborted && !Cause.hasInterruptsOnly(exit.cause)) { return yield* Effect.failCause(exit.cause) } + // kilocode_change end return { info: msg, parts: [part] } }) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index bbebeaa4965..f426eef8ad2 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -354,9 +354,10 @@ export const layer: Layer.Layer< } function cleanDirectory(target: string) { + const retries = process.platform === "win32" ? 30 : 5 // kilocode_change return Effect.promise(() => import("fs/promises") - .then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })) + .then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: retries, retryDelay: 100 })) .catch((error) => { const message = errorMessage(error) throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" }) From aebddf5afc83ff5a70b9a24c4315f8e121b5bd47 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 27 Apr 2026 15:00:31 +0300 Subject: [PATCH 04/18] fix(cli): harden shell cancellation cleanup --- packages/opencode/src/effect/runner.ts | 20 ++++++++++++++++---- packages/opencode/src/session/prompt.ts | 4 +++- packages/opencode/src/worktree/index.ts | 4 +++- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 925c268f8e0..a4e563983a0 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -19,6 +19,9 @@ interface RunHandle { interface ShellHandle { id: number fiber: Fiber.Fiber + // kilocode_change start - shell cancellation can finish with non-interrupt process errors + cancelled: boolean + // kilocode_change end } interface PendingHandle { @@ -98,7 +101,12 @@ export const make = ( }), ).pipe(Effect.flatten) - const stopShell = (shell: ShellHandle) => Fiber.interrupt(shell.fiber) + // kilocode_change start - wait for shell finalizers so aborted output is persisted before callers resolve + const stopShell = (shell: ShellHandle) => + Effect.sync(() => { + shell.cancelled = true + }).pipe(Effect.andThen(Fiber.interrupt(shell.fiber)), Effect.andThen(Fiber.await(shell.fiber)), Effect.asVoid) + // kilocode_change end const ensureRunning = (work: Effect.Effect) => SynchronizedRef.modifyEffect( @@ -146,12 +154,14 @@ export const make = ( yield* busy const id = next() const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) - const shell = { id, fiber } satisfies ShellHandle + const shell = { id, fiber, cancelled: false } satisfies ShellHandle return [ Effect.gen(function* () { const exit = yield* Fiber.await(fiber) if (Exit.isSuccess(exit)) return exit.value - if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt + // kilocode_change start - cancelled shells may fail with process-signal errors after cleanup + if ((shell.cancelled || Cause.hasInterruptsOnly(exit.cause)) && onInterrupt) return yield* onInterrupt + // kilocode_change end return yield* Effect.failCause(exit.cause) }), { _tag: "Shell", shell }, @@ -183,8 +193,10 @@ export const make = ( case "ShellThenRun": return [ Effect.gen(function* () { - yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) + // kilocode_change start - let shell cleanup persist before queued loop resolves via onInterrupt yield* stopShell(st.shell) + yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) + // kilocode_change end yield* idleIfCurrent() }), { _tag: "Idle" } as const, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 2db44d26139..c55f071a70f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -919,7 +919,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the ) // kilocode_change start - cancelled shells can exit by signal during forced cleanup - if (Exit.isFailure(exit) && !aborted && !Cause.hasInterruptsOnly(exit.cause)) { + const err = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined + const signal = err instanceof Error && err.message.includes("Process interrupted due to receipt of signal") + if (Exit.isFailure(exit) && !aborted && !signal && !Cause.hasInterruptsOnly(exit.cause)) { return yield* Effect.failCause(exit.cause) } // kilocode_change end diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index f426eef8ad2..131c6c41d12 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -354,7 +354,8 @@ export const layer: Layer.Layer< } function cleanDirectory(target: string) { - const retries = process.platform === "win32" ? 30 : 5 // kilocode_change + // kilocode_change start - Windows CI can hold worktree handles briefly after git cleanup + const retries = process.platform === "win32" ? 30 : 5 return Effect.promise(() => import("fs/promises") .then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: retries, retryDelay: 100 })) @@ -363,6 +364,7 @@ export const layer: Layer.Layer< throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" }) }), ) + // kilocode_change end } const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) { From d80e23a8dd8e73251d2915d0ecd102c882915ef8 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 27 Apr 2026 15:01:59 +0300 Subject: [PATCH 05/18] fix(cli): annotate shell cancellation handle --- packages/opencode/src/effect/runner.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index a4e563983a0..44e4116427d 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -154,7 +154,7 @@ export const make = ( yield* busy const id = next() const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) - const shell = { id, fiber, cancelled: false } satisfies ShellHandle + const shell = { id, fiber, cancelled: false } satisfies ShellHandle // kilocode_change return [ Effect.gen(function* () { const exit = yield* Fiber.await(fiber) From d61e4d4b431d7f63bfb9005a18935941a00afd77 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 27 Apr 2026 16:08:12 +0300 Subject: [PATCH 06/18] fix(cli): remove unrelated ci hardening --- packages/opencode/src/effect/runner.ts | 20 ++++---------------- packages/opencode/src/session/prompt.ts | 6 +----- packages/opencode/src/worktree/index.ts | 5 +---- 3 files changed, 6 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 44e4116427d..925c268f8e0 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -19,9 +19,6 @@ interface RunHandle { interface ShellHandle { id: number fiber: Fiber.Fiber - // kilocode_change start - shell cancellation can finish with non-interrupt process errors - cancelled: boolean - // kilocode_change end } interface PendingHandle { @@ -101,12 +98,7 @@ export const make = ( }), ).pipe(Effect.flatten) - // kilocode_change start - wait for shell finalizers so aborted output is persisted before callers resolve - const stopShell = (shell: ShellHandle) => - Effect.sync(() => { - shell.cancelled = true - }).pipe(Effect.andThen(Fiber.interrupt(shell.fiber)), Effect.andThen(Fiber.await(shell.fiber)), Effect.asVoid) - // kilocode_change end + const stopShell = (shell: ShellHandle) => Fiber.interrupt(shell.fiber) const ensureRunning = (work: Effect.Effect) => SynchronizedRef.modifyEffect( @@ -154,14 +146,12 @@ export const make = ( yield* busy const id = next() const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) - const shell = { id, fiber, cancelled: false } satisfies ShellHandle // kilocode_change + const shell = { id, fiber } satisfies ShellHandle return [ Effect.gen(function* () { const exit = yield* Fiber.await(fiber) if (Exit.isSuccess(exit)) return exit.value - // kilocode_change start - cancelled shells may fail with process-signal errors after cleanup - if ((shell.cancelled || Cause.hasInterruptsOnly(exit.cause)) && onInterrupt) return yield* onInterrupt - // kilocode_change end + if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt return yield* Effect.failCause(exit.cause) }), { _tag: "Shell", shell }, @@ -193,10 +183,8 @@ export const make = ( case "ShellThenRun": return [ Effect.gen(function* () { - // kilocode_change start - let shell cleanup persist before queued loop resolves via onInterrupt - yield* stopShell(st.shell) yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) - // kilocode_change end + yield* stopShell(st.shell) yield* idleIfCurrent() }), { _tag: "Idle" } as const, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c55f071a70f..7ce63047299 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -918,13 +918,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the Effect.exit, ) - // kilocode_change start - cancelled shells can exit by signal during forced cleanup - const err = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined - const signal = err instanceof Error && err.message.includes("Process interrupted due to receipt of signal") - if (Exit.isFailure(exit) && !aborted && !signal && !Cause.hasInterruptsOnly(exit.cause)) { + if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) { return yield* Effect.failCause(exit.cause) } - // kilocode_change end return { info: msg, parts: [part] } }) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 131c6c41d12..bbebeaa4965 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -354,17 +354,14 @@ export const layer: Layer.Layer< } function cleanDirectory(target: string) { - // kilocode_change start - Windows CI can hold worktree handles briefly after git cleanup - const retries = process.platform === "win32" ? 30 : 5 return Effect.promise(() => import("fs/promises") - .then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: retries, retryDelay: 100 })) + .then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })) .catch((error) => { const message = errorMessage(error) throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" }) }), ) - // kilocode_change end } const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) { From 1825b83fd3055b8b2b603a140bc844639048afb9 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 27 Apr 2026 16:45:52 +0300 Subject: [PATCH 07/18] fix(cli): make shell cancellation race-safe --- packages/opencode/src/effect/runner.ts | 20 +- packages/opencode/src/session/prompt.ts | 336 +++++++++++++----------- 2 files changed, 195 insertions(+), 161 deletions(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 925c268f8e0..44e4116427d 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -19,6 +19,9 @@ interface RunHandle { interface ShellHandle { id: number fiber: Fiber.Fiber + // kilocode_change start - shell cancellation can finish with non-interrupt process errors + cancelled: boolean + // kilocode_change end } interface PendingHandle { @@ -98,7 +101,12 @@ export const make = ( }), ).pipe(Effect.flatten) - const stopShell = (shell: ShellHandle) => Fiber.interrupt(shell.fiber) + // kilocode_change start - wait for shell finalizers so aborted output is persisted before callers resolve + const stopShell = (shell: ShellHandle) => + Effect.sync(() => { + shell.cancelled = true + }).pipe(Effect.andThen(Fiber.interrupt(shell.fiber)), Effect.andThen(Fiber.await(shell.fiber)), Effect.asVoid) + // kilocode_change end const ensureRunning = (work: Effect.Effect) => SynchronizedRef.modifyEffect( @@ -146,12 +154,14 @@ export const make = ( yield* busy const id = next() const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) - const shell = { id, fiber } satisfies ShellHandle + const shell = { id, fiber, cancelled: false } satisfies ShellHandle // kilocode_change return [ Effect.gen(function* () { const exit = yield* Fiber.await(fiber) if (Exit.isSuccess(exit)) return exit.value - if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt + // kilocode_change start - cancelled shells may fail with process-signal errors after cleanup + if ((shell.cancelled || Cause.hasInterruptsOnly(exit.cause)) && onInterrupt) return yield* onInterrupt + // kilocode_change end return yield* Effect.failCause(exit.cause) }), { _tag: "Shell", shell }, @@ -183,8 +193,10 @@ export const make = ( case "ShellThenRun": return [ Effect.gen(function* () { - yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) + // kilocode_change start - let shell cleanup persist before queued loop resolves via onInterrupt yield* stopShell(st.shell) + yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) + // kilocode_change end yield* idleIfCurrent() }), { _tag: "Idle" } as const, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e43cac7a26b..42bbe999697 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -742,184 +742,206 @@ NOTE: At any point in time through this workflow you should feel free to ask the } satisfies MessageV2.TextPart) }) + // kilocode_change start - complete shell setup before honoring cancellation const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput) { - const ctx = yield* InstanceState.context - const run = yield* runner() - const session = yield* sessions.get(input.sessionID) - if (session.revert) { - yield* revert.cleanup(session) - } - const agent = yield* agents.get(input.agent) - if (!agent) { - const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) - const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" - const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` }) - yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) - throw error - } - const model = input.model ?? agent.model ?? (yield* lastModel(input.sessionID)) - const userMsg: MessageV2.User = { - id: input.messageID ?? MessageID.ascending(), - sessionID: input.sessionID, - time: { created: Date.now() }, - role: "user", - agent: input.agent, - model: { providerID: model.providerID, modelID: model.modelID }, - } - yield* sessions.updateMessage(userMsg) - const userPart: MessageV2.Part = { - type: "text", - id: PartID.ascending(), - messageID: userMsg.id, - sessionID: input.sessionID, - text: "The following tool was executed by the user", - synthetic: true, - } - yield* sessions.updatePart(userPart) + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const ctx = yield* InstanceState.context + const run = yield* runner() + const session = yield* sessions.get(input.sessionID) + if (session.revert) { + yield* revert.cleanup(session) + } + const agent = yield* agents.get(input.agent) + if (!agent) { + const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` }) + yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + throw error + } + const model = input.model ?? agent.model ?? (yield* lastModel(input.sessionID)) + const userMsg: MessageV2.User = { + id: input.messageID ?? MessageID.ascending(), + sessionID: input.sessionID, + time: { created: Date.now() }, + role: "user", + agent: input.agent, + model: { providerID: model.providerID, modelID: model.modelID }, + } + yield* sessions.updateMessage(userMsg) + const userPart: MessageV2.Part = { + type: "text", + id: PartID.ascending(), + messageID: userMsg.id, + sessionID: input.sessionID, + text: "The following tool was executed by the user", + synthetic: true, + } + yield* sessions.updatePart(userPart) - const msg: MessageV2.Assistant = { - id: MessageID.ascending(), - sessionID: input.sessionID, - parentID: userMsg.id, - mode: input.agent, - agent: input.agent, - cost: 0, - path: { cwd: ctx.directory, root: ctx.worktree }, - time: { created: Date.now() }, - role: "assistant", - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - modelID: model.modelID, - providerID: model.providerID, - } - yield* sessions.updateMessage(msg) - const part: MessageV2.ToolPart = { - type: "tool", - id: PartID.ascending(), - messageID: msg.id, - sessionID: input.sessionID, - tool: "bash", - callID: ulid(), - state: { - status: "running", - time: { start: Date.now() }, - input: { command: input.command }, - }, - } - yield* sessions.updatePart(part) - - const sh = Shell.preferred() - const shellName = ( - process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh) - ).toLowerCase() - const cwd = ctx.directory - const invocations: Record = { - nu: { args: ["-c", input.command] }, - fish: { args: ["-c", input.command] }, - zsh: { - args: [ - "-l", - "-c", - ` + const msg: MessageV2.Assistant = { + id: MessageID.ascending(), + sessionID: input.sessionID, + parentID: userMsg.id, + mode: input.agent, + agent: input.agent, + cost: 0, + path: { cwd: ctx.directory, root: ctx.worktree }, + time: { created: Date.now() }, + role: "assistant", + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: model.modelID, + providerID: model.providerID, + } + yield* sessions.updateMessage(msg) + const part: MessageV2.ToolPart = { + type: "tool", + id: PartID.ascending(), + messageID: msg.id, + sessionID: input.sessionID, + tool: "bash", + callID: ulid(), + state: { + status: "running", + time: { start: Date.now() }, + input: { command: input.command }, + }, + } + yield* sessions.updatePart(part) + + const sh = Shell.preferred() + const shellName = ( + process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh) + ).toLowerCase() + const cwd = ctx.directory + const invocations: Record = { + nu: { args: ["-c", input.command] }, + fish: { args: ["-c", input.command] }, + zsh: { + args: [ + "-l", + "-c", + ` [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true cd -- "$1" eval ${JSON.stringify(input.command)} `, - "opencode", - cwd, - ], - }, - bash: { - args: [ - "-l", - "-c", - ` + "opencode", + cwd, + ], + }, + bash: { + args: [ + "-l", + "-c", + ` shopt -s expand_aliases [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true cd -- "$1" eval ${JSON.stringify(input.command)} `, - "opencode", + "opencode", + cwd, + ], + }, + cmd: { args: ["/c", input.command] }, + powershell: { args: ["-NoProfile", "-Command", input.command] }, + pwsh: { args: ["-NoProfile", "-Command", input.command] }, + "": { args: ["-c", input.command] }, + } + + const args = (invocations[shellName] ?? invocations[""]).args + const shellEnv = yield* plugin.trigger( + "shell.env", + { cwd, sessionID: input.sessionID, callID: part.callID }, + { env: {} }, + ) + + const cmd = ChildProcess.make(sh, args, { cwd, - ], - }, - cmd: { args: ["/c", input.command] }, - powershell: { args: ["-NoProfile", "-Command", input.command] }, - pwsh: { args: ["-NoProfile", "-Command", input.command] }, - "": { args: ["-c", input.command] }, - } + extendEnv: true, + env: { ...shellEnv.env, TERM: "dumb" }, + stdin: "ignore", + forceKillAfter: "3 seconds", + }) - const args = (invocations[shellName] ?? invocations[""]).args - const shellEnv = yield* plugin.trigger( - "shell.env", - { cwd, sessionID: input.sessionID, callID: part.callID }, - { env: {} }, - ) + let output = "" + let aborted = false - const cmd = ChildProcess.make(sh, args, { - cwd, - extendEnv: true, - env: { ...shellEnv.env, TERM: "dumb" }, - stdin: "ignore", - forceKillAfter: "3 seconds", - }) + const finish = Effect.uninterruptible( + Effect.gen(function* () { + if (aborted) { + output += "\n\n" + ["", "User aborted the command", ""].join("\n") + } + if (!msg.time.completed) { + msg.time.completed = Date.now() + yield* sessions.updateMessage(msg) + } + if (part.state.status === "running") { + part.state = { + status: "completed", + time: { ...part.state.time, end: Date.now() }, + input: part.state.input, + title: "", + metadata: { output, description: "" }, + output, + } + yield* sessions.updatePart(part) + } + }), + ) - let output = "" - let aborted = false + const exit = yield* Effect.acquireUseRelease( + Effect.void, + () => + restore( + Effect.gen(function* () { + const handle = yield* spawner.spawn(cmd) + yield* Stream.runForEach(Stream.decodeText(handle.all), (chunk) => + Effect.sync(() => { + output += chunk + if (part.state.status === "running") { + part.state.metadata = { output, description: "" } + void run.fork(sessions.updatePart(part)) + } + }), + ) + yield* handle.exitCode + }).pipe( + Effect.scoped, + Effect.onInterrupt(() => + Effect.sync(() => { + aborted = true + }), + ), + Effect.orDie, + Effect.exit, + ), + ), + (_resource, exit) => + Effect.gen(function* () { + if (!aborted && Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) { + aborted = true + } + yield* finish + }), + ) - const finish = Effect.uninterruptible( - Effect.gen(function* () { - if (aborted) { - output += "\n\n" + ["", "User aborted the command", ""].join("\n") + // kilocode_change start - cancelled shells can exit by signal during forced cleanup + const err = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined + const signal = err instanceof Error && err.message.includes("Process interrupted due to receipt of signal") + if (Exit.isFailure(exit) && !aborted && !signal && !Cause.hasInterruptsOnly(exit.cause)) { + return yield* Effect.failCause(exit.cause) } - if (!msg.time.completed) { - msg.time.completed = Date.now() - yield* sessions.updateMessage(msg) - } - if (part.state.status === "running") { - part.state = { - status: "completed", - time: { ...part.state.time, end: Date.now() }, - input: part.state.input, - title: "", - metadata: { output, description: "" }, - output, - } - yield* sessions.updatePart(part) - } - }), - ) + // kilocode_change end - const exit = yield* Effect.gen(function* () { - const handle = yield* spawner.spawn(cmd) - yield* Stream.runForEach(Stream.decodeText(handle.all), (chunk) => - Effect.sync(() => { - output += chunk - if (part.state.status === "running") { - part.state.metadata = { output, description: "" } - void run.fork(sessions.updatePart(part)) - } - }), - ) - yield* handle.exitCode - }).pipe( - Effect.scoped, - Effect.onInterrupt(() => - Effect.sync(() => { - aborted = true - }), - ), - Effect.orDie, - Effect.ensuring(finish), - Effect.exit, + return { info: msg, parts: [part] } + }), ) - - if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) { - return yield* Effect.failCause(exit.cause) - } - - return { info: msg, parts: [part] } }) + // kilocode_change end const getModel = Effect.fn("SessionPrompt.getModel")(function* ( providerID: ProviderID, From a286835ccf8efc1717e26140f6a998445dfb471a Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 27 Apr 2026 16:57:42 +0300 Subject: [PATCH 08/18] fix(cli): avoid nested shell annotations --- packages/opencode/src/session/prompt.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 42bbe999697..ec8dcbb5486 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -929,13 +929,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the }), ) - // kilocode_change start - cancelled shells can exit by signal during forced cleanup const err = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined const signal = err instanceof Error && err.message.includes("Process interrupted due to receipt of signal") if (Exit.isFailure(exit) && !aborted && !signal && !Cause.hasInterruptsOnly(exit.cause)) { return yield* Effect.failCause(exit.cause) } - // kilocode_change end return { info: msg, parts: [part] } }), From 2469380773e30ee54bb2a59a614d3ea2c14bad97 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 27 Apr 2026 17:10:19 +0300 Subject: [PATCH 09/18] fix(cli): keep preflight cancellable --- packages/opencode/src/session/prompt.ts | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ec8dcbb5486..a8e6da34fe3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -742,25 +742,25 @@ NOTE: At any point in time through this workflow you should feel free to ask the } satisfies MessageV2.TextPart) }) - // kilocode_change start - complete shell setup before honoring cancellation + // kilocode_change start - persist shell messages before honoring cancellation const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput) { return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { - const ctx = yield* InstanceState.context - const run = yield* runner() - const session = yield* sessions.get(input.sessionID) + const ctx = yield* restore(InstanceState.context) + const run = yield* restore(runner()) + const session = yield* restore(sessions.get(input.sessionID)) if (session.revert) { - yield* revert.cleanup(session) + yield* restore(revert.cleanup(session)) } - const agent = yield* agents.get(input.agent) + const agent = yield* restore(agents.get(input.agent)) if (!agent) { - const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) + const available = (yield* restore(agents.list())).filter((a) => !a.hidden).map((a) => a.name) const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` }) - yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + yield* restore(bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })) throw error } - const model = input.model ?? agent.model ?? (yield* lastModel(input.sessionID)) + const model = input.model ?? agent.model ?? (yield* restore(lastModel(input.sessionID))) const userMsg: MessageV2.User = { id: input.messageID ?? MessageID.ascending(), sessionID: input.sessionID, @@ -853,10 +853,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the } const args = (invocations[shellName] ?? invocations[""]).args - const shellEnv = yield* plugin.trigger( - "shell.env", - { cwd, sessionID: input.sessionID, callID: part.callID }, - { env: {} }, + const shellEnv = yield* restore( + plugin.trigger("shell.env", { cwd, sessionID: input.sessionID, callID: part.callID }, { env: {} }), ) const cmd = ChildProcess.make(sh, args, { From 0f8d493cc7949a3110be84566983d590d419521c Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 27 Apr 2026 17:10:48 +0300 Subject: [PATCH 10/18] fix(cli): gate signal failures --- packages/opencode/src/session/prompt.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a8e6da34fe3..90f05a3ff82 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -927,10 +927,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the }), ) - const err = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined - const signal = err instanceof Error && err.message.includes("Process interrupted due to receipt of signal") - if (Exit.isFailure(exit) && !aborted && !signal && !Cause.hasInterruptsOnly(exit.cause)) { - return yield* Effect.failCause(exit.cause) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + const signal = err instanceof Error && err.message.includes("Process interrupted due to receipt of signal") + const ok = aborted && (signal || Cause.hasInterruptsOnly(exit.cause)) + if (!ok) return yield* Effect.failCause(exit.cause) } return { info: msg, parts: [part] } From a7293e2fae1bedabe497c36ea26c7a247afc71a0 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 27 Apr 2026 17:11:08 +0300 Subject: [PATCH 11/18] fix(cli): surface cleanup errors --- packages/opencode/src/effect/runner.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 44e4116427d..184c17f2c5e 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -160,7 +160,11 @@ export const make = ( const exit = yield* Fiber.await(fiber) if (Exit.isSuccess(exit)) return exit.value // kilocode_change start - cancelled shells may fail with process-signal errors after cleanup - if ((shell.cancelled || Cause.hasInterruptsOnly(exit.cause)) && onInterrupt) return yield* onInterrupt + const err = Cause.squash(exit.cause) + const signal = err instanceof Error && err.message.includes("Process interrupted due to receipt of signal") + if ((Cause.hasInterruptsOnly(exit.cause) || (shell.cancelled && signal)) && onInterrupt) { + return yield* onInterrupt + } // kilocode_change end return yield* Effect.failCause(exit.cause) }), From 43fe29fadcf25fcd6c8d01c5970eca8ba334812f Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 27 Apr 2026 17:30:24 +0300 Subject: [PATCH 12/18] fix(cli): handle early shell cancel --- packages/opencode/src/effect/runner.ts | 43 ++++++++---- packages/opencode/src/session/prompt.ts | 90 +++++++++++++++++++++++-- 2 files changed, 117 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 184c17f2c5e..7c4f3b61472 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -10,6 +10,17 @@ export interface Runner { export class Cancelled extends Schema.TaggedErrorClass()("RunnerCancelled", {}) {} +// kilocode_change start - identify process signal failures nested in platform errors +const signal = "Process interrupted due to receipt of signal" +const killed = (value: unknown): boolean => { + if (value instanceof Error && value.message.includes(signal)) return true + if (typeof value !== "object" || value === null) return false + const data = value as { cause?: unknown; message?: unknown } + if (typeof data.message === "string" && data.message.includes(signal)) return true + return killed(data.cause) +} +// kilocode_change end + interface RunHandle { id: number done: Deferred.Deferred @@ -156,18 +167,26 @@ export const make = ( const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) const shell = { id, fiber, cancelled: false } satisfies ShellHandle // kilocode_change return [ - Effect.gen(function* () { - const exit = yield* Fiber.await(fiber) - if (Exit.isSuccess(exit)) return exit.value - // kilocode_change start - cancelled shells may fail with process-signal errors after cleanup - const err = Cause.squash(exit.cause) - const signal = err instanceof Error && err.message.includes("Process interrupted due to receipt of signal") - if ((Cause.hasInterruptsOnly(exit.cause) || (shell.cancelled && signal)) && onInterrupt) { - return yield* onInterrupt - } - // kilocode_change end - return yield* Effect.failCause(exit.cause) - }), + Effect.uninterruptible( + Effect.gen(function* () { + const exit = yield* Fiber.await(fiber) + if (Exit.isSuccess(exit)) return exit.value + // kilocode_change start - cancelled shells may fail with process-signal errors after cleanup + const ok = + exit.cause.reasons.length > 0 && + exit.cause.reasons.every((reason) => { + if (Cause.isInterruptReason(reason)) return true + if (Cause.isFailReason(reason)) return killed(reason.error) + if (Cause.isDieReason(reason)) return killed(reason.defect) + return false + }) + if ((Cause.hasInterruptsOnly(exit.cause) || (shell.cancelled && ok)) && onInterrupt) { + return yield* Effect.uninterruptible(onInterrupt) + } + // kilocode_change end + return yield* Effect.failCause(exit.cause) + }), + ), { _tag: "Shell", shell }, ] as const }), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 90f05a3ff82..a549e70f738 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -76,6 +76,17 @@ export const shouldAskPlanFollowup = KiloSessionPrompt.shouldAskPlanFollowup const log = Log.create({ service: "session.prompt" }) const elog = EffectLogger.create({ service: "session.prompt" }) +// kilocode_change start - identify process signal failures nested in platform errors +const signal = "Process interrupted due to receipt of signal" +const killed = (value: unknown): boolean => { + if (value instanceof Error && value.message.includes(signal)) return true + if (typeof value !== "object" || value === null) return false + const data = value as { cause?: unknown; message?: unknown } + if (typeof data.message === "string" && data.message.includes(signal)) return true + return killed(data.cause) +} +// kilocode_change end + export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect @@ -928,9 +939,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the ) if (Exit.isFailure(exit)) { - const err = Cause.squash(exit.cause) - const signal = err instanceof Error && err.message.includes("Process interrupted due to receipt of signal") - const ok = aborted && (signal || Cause.hasInterruptsOnly(exit.cause)) + const ok = + aborted && + exit.cause.reasons.length > 0 && + exit.cause.reasons.every((reason) => { + if (Cause.isInterruptReason(reason)) return true + if (Cause.isFailReason(reason)) return killed(reason.error) + if (Cause.isDieReason(reason)) return killed(reason.defect) + return false + }) if (!ok) return yield* Effect.failCause(exit.cause) } @@ -1374,6 +1391,71 @@ NOTE: At any point in time through this workflow you should feel free to ask the throw new Error("Impossible") }) + // kilocode_change start - create an aborted shell record if cancellation beats setup + const shellInterrupt = Effect.fn("SessionPrompt.shellInterrupt")(function* (input: ShellInput) { + const existing = yield* lastAssistant(input.sessionID).pipe(Effect.exit) + if (Exit.isSuccess(existing)) return existing.value + const err = Cause.squash(existing.cause) + if (!(err instanceof Error) || err.message !== "Impossible") return yield* Effect.failCause(existing.cause) + + const ctx = yield* InstanceState.context + const fallback = { providerID: ProviderID.make("unknown"), modelID: ModelID.make("unknown") } + const model = input.model ?? (yield* provider.defaultModel().pipe(Effect.catchCause(() => Effect.succeed(fallback)))) + const userMsg: MessageV2.User = { + id: input.messageID ?? MessageID.ascending(), + sessionID: input.sessionID, + time: { created: Date.now() }, + role: "user", + agent: input.agent, + model: { providerID: model.providerID, modelID: model.modelID }, + } + yield* sessions.updateMessage(userMsg) + yield* sessions.updatePart({ + type: "text", + id: PartID.ascending(), + messageID: userMsg.id, + sessionID: input.sessionID, + text: "The following tool was executed by the user", + synthetic: true, + } satisfies MessageV2.Part) + + const msg: MessageV2.Assistant = { + id: MessageID.ascending(), + sessionID: input.sessionID, + parentID: userMsg.id, + mode: input.agent, + agent: input.agent, + cost: 0, + path: { cwd: ctx.directory, root: ctx.worktree }, + time: { created: Date.now(), completed: Date.now() }, + role: "assistant", + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: model.modelID, + providerID: model.providerID, + } + yield* sessions.updateMessage(msg) + const output = ["", "User aborted the command", ""].join("\n") + const part: MessageV2.ToolPart = { + type: "tool", + id: PartID.ascending(), + messageID: msg.id, + sessionID: input.sessionID, + tool: "bash", + callID: ulid(), + state: { + status: "completed", + time: { start: Date.now(), end: Date.now() }, + input: { command: input.command }, + title: "", + metadata: { output, description: "" }, + output, + }, + } + yield* sessions.updatePart(part) + return { info: msg, parts: [part] } + }) + // kilocode_change end + // kilocode_change — mutable close-reason per session, set by runLoop and read by loop const closeReasons = new Map() @@ -1695,7 +1777,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const shell: (input: ShellInput) => Effect.Effect = Effect.fn("SessionPrompt.shell")( function* (input: ShellInput) { - return yield* state.startShell(input.sessionID, lastAssistant(input.sessionID), shellImpl(input)) + return yield* state.startShell(input.sessionID, shellInterrupt(input), shellImpl(input)) }, ) From a7e71f498d1c1ebb6724a3d0573b96699544dc56 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 27 Apr 2026 17:30:32 +0300 Subject: [PATCH 13/18] fix(cli): wait before plugin init --- packages/opencode/src/plugin/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index f063b5eb456..45cfef577b5 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -209,6 +209,8 @@ export const layer = Layer.effect( }, }), ) + // kilocode_change - wait before plugin initialization can import local dependencies + if (loaded.length > 0) yield* Effect.promise(wait) for (const load of loaded) { if (!load) continue From 73c42f8b3b2b12aeb0bb845e7c799421ba552d07 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 27 Apr 2026 17:32:01 +0300 Subject: [PATCH 14/18] chore(cli): mark review changes --- packages/opencode/src/effect/runner.ts | 4 ++-- packages/opencode/src/plugin/index.ts | 3 ++- packages/opencode/src/session/prompt.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 7c4f3b61472..2c4eede03bf 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -167,11 +167,11 @@ export const make = ( const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) const shell = { id, fiber, cancelled: false } satisfies ShellHandle // kilocode_change return [ + // kilocode_change start - cancelled shells may fail with process-signal errors after cleanup Effect.uninterruptible( Effect.gen(function* () { const exit = yield* Fiber.await(fiber) if (Exit.isSuccess(exit)) return exit.value - // kilocode_change start - cancelled shells may fail with process-signal errors after cleanup const ok = exit.cause.reasons.length > 0 && exit.cause.reasons.every((reason) => { @@ -183,10 +183,10 @@ export const make = ( if ((Cause.hasInterruptsOnly(exit.cause) || (shell.cancelled && ok)) && onInterrupt) { return yield* Effect.uninterruptible(onInterrupt) } - // kilocode_change end return yield* Effect.failCause(exit.cause) }), ), + // kilocode_change end { _tag: "Shell", shell }, ] as const }), diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 45cfef577b5..afac41b98bf 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -209,8 +209,9 @@ export const layer = Layer.effect( }, }), ) - // kilocode_change - wait before plugin initialization can import local dependencies + // kilocode_change start - wait before plugin initialization can import local dependencies if (loaded.length > 0) yield* Effect.promise(wait) + // kilocode_change end for (const load of loaded) { if (!load) continue diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a549e70f738..6fbdbbb711e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1777,7 +1777,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const shell: (input: ShellInput) => Effect.Effect = Effect.fn("SessionPrompt.shell")( function* (input: ShellInput) { - return yield* state.startShell(input.sessionID, shellInterrupt(input), shellImpl(input)) + return yield* state.startShell(input.sessionID, shellInterrupt(input), shellImpl(input)) // kilocode_change }, ) From 439ab0a5fc344ebb1c927f3034e1ec816f44233e Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 27 Apr 2026 17:38:25 +0300 Subject: [PATCH 15/18] fix(cli): complete canceled shell --- packages/opencode/src/session/prompt.ts | 51 ++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 6fbdbbb711e..6550051f6dc 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1393,16 +1393,52 @@ NOTE: At any point in time through this workflow you should feel free to ask the // kilocode_change start - create an aborted shell record if cancellation beats setup const shellInterrupt = Effect.fn("SessionPrompt.shellInterrupt")(function* (input: ShellInput) { - const existing = yield* lastAssistant(input.sessionID).pipe(Effect.exit) - if (Exit.isSuccess(existing)) return existing.value - const err = Cause.squash(existing.cause) - if (!(err instanceof Error) || err.message !== "Impossible") return yield* Effect.failCause(existing.cause) + const id = input.messageID ?? MessageID.ascending() + const current = yield* sessions.findMessage( + input.sessionID, + (msg) => + msg.info.role === "assistant" && + msg.info.parentID === id && + msg.parts.some( + (part) => part.type === "tool" && part.tool === "bash" && part.state.input.command === input.command, + ), + ) + if (Option.isSome(current)) { + const msg = current.value.info + const part = current.value.parts.find( + (part) => part.type === "tool" && part.tool === "bash" && part.state.input.command === input.command, + ) + if (msg.role === "assistant" && !msg.time.completed) { + msg.time.completed = Date.now() + yield* sessions.updateMessage(msg) + } + if (part?.type === "tool" && (part.state.status === "pending" || part.state.status === "running")) { + const meta = ["", "User aborted the command", ""].join("\n") + const prior = + part.state.status === "running" && typeof part.state.metadata?.output === "string" + ? part.state.metadata.output + : "" + const output = prior ? `${prior}\n\n${meta}` : meta + const start = part.state.status === "running" ? part.state.time.start : Date.now() + const input = part.state.input + part.state = { + status: "completed", + time: { start, end: Date.now() }, + input, + title: "", + metadata: { output, description: "" }, + output, + } + yield* sessions.updatePart(part) + } + return current.value + } const ctx = yield* InstanceState.context const fallback = { providerID: ProviderID.make("unknown"), modelID: ModelID.make("unknown") } const model = input.model ?? (yield* provider.defaultModel().pipe(Effect.catchCause(() => Effect.succeed(fallback)))) const userMsg: MessageV2.User = { - id: input.messageID ?? MessageID.ascending(), + id, sessionID: input.sessionID, time: { created: Date.now() }, role: "user", @@ -1777,7 +1813,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the const shell: (input: ShellInput) => Effect.Effect = Effect.fn("SessionPrompt.shell")( function* (input: ShellInput) { - return yield* state.startShell(input.sessionID, shellInterrupt(input), shellImpl(input)) // kilocode_change + // kilocode_change start - share shell message id with cancellation fallback + const next = { ...input, messageID: input.messageID ?? MessageID.ascending() } + return yield* state.startShell(next.sessionID, shellInterrupt(next), shellImpl(next)) + // kilocode_change end }, ) From 07923610effda2d7a7e2274eb40768ab62a1470d Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 27 Apr 2026 18:07:51 +0300 Subject: [PATCH 16/18] fix(cli): avoid blocking provider plugin init --- packages/opencode/src/plugin/index.ts | 5 ++--- packages/shared/src/util/flock.ts | 20 +++++++++++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index afac41b98bf..0030a31c588 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -209,9 +209,7 @@ export const layer = Layer.effect( }, }), ) - // kilocode_change start - wait before plugin initialization can import local dependencies - if (loaded.length > 0) yield* Effect.promise(wait) - // kilocode_change end + // kilocode_change start - avoid blocking successful local plugins on background dependency installs for (const load of loaded) { if (!load) continue @@ -236,6 +234,7 @@ export const layer = Layer.effect( }), ) } + // kilocode_change end // Notify plugins of current config for (const hook of hooks) { diff --git a/packages/shared/src/util/flock.ts b/packages/shared/src/util/flock.ts index 958bd9fd1da..5732c1b7fd6 100644 --- a/packages/shared/src/util/flock.ts +++ b/packages/shared/src/util/flock.ts @@ -106,6 +106,24 @@ export namespace Flock { return Math.max(0, ms + d) } + // kilocode_change start - tolerate transient Windows directory removal failures + function transient(err: unknown) { + const name = code(err) + return name === "EBUSY" || name === "EPERM" || name === "ENOTEMPTY" + } + + async function remove(dir: string, n = 0): Promise { + try { + await rm(dir, { recursive: true, force: true }) + return + } catch (err) { + if (!transient(err) || n >= 5) throw err + await sleep(25 * (n + 1)) + return remove(dir, n + 1) + } + } + // kilocode_change end + function mono() { return performance.now() } @@ -261,7 +279,7 @@ export namespace Flock { throw new Error("Refusing to release: lock token mismatch (not the owner).") } - await rm(lockDir, { recursive: true, force: true }) + await remove(lockDir) // kilocode_change } return { From b9a75b02abe8ff2324cabb596c940c1884bbdc72 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 28 Apr 2026 10:01:37 +0300 Subject: [PATCH 17/18] fix(cli): retry local plugin init after install --- packages/opencode/src/plugin/index.ts | 36 ++++++++++++++++++--------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 0030a31c588..3a03a5028dd 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -92,18 +92,21 @@ function getLegacyPlugins(mod: Record) { return result } -async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) { +// kilocode_change start - return hooks so local plugin initialization can be retried safely +async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput) { const plugin = readV1Plugin(load.mod, load.spec, "server", "detect") if (plugin) { await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg) - hooks.push(await (plugin as PluginModule).server(input, load.options)) - return + return [await (plugin as PluginModule).server(input, load.options)] } + const result: Hooks[] = [] for (const server of getLegacyPlugins(load.mod)) { - hooks.push(await server(input, load.options)) + result.push(await server(input, load.options)) } + return result } +// kilocode_change end export const layer = Layer.effect( Service, @@ -161,17 +164,18 @@ export const layer = Layer.effect( if (init._tag === "Some") hooks.push(init.value) } + // kilocode_change start const plugins = Flag.KILO_PURE ? [] : (cfg.plugin_origins ?? []) if (Flag.KILO_PURE && cfg.plugin_origins?.length) { log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length }) } - const wait = () => bridge.promise(config.waitForDependencies()) // kilocode_change + const wait = () => bridge.promise(config.waitForDependencies()) const loaded = yield* Effect.promise(() => PluginLoader.loadExternal({ items: plugins, kind: "server", - wait, // kilocode_change + wait, report: { start(candidate) { log.info("loading plugin", { path: candidate.plan.spec }) @@ -209,14 +213,21 @@ export const layer = Layer.effect( }, }), ) - // kilocode_change start - avoid blocking successful local plugins on background dependency installs + // kilocode_change end + // kilocode_change start - retry local plugin initialization after dependency setup for (const load of loaded) { if (!load) continue - // Keep plugin execution sequential so hook registration and execution - // order remains deterministic across plugin runs. - yield* Effect.tryPromise({ - try: () => applyPlugin(load, input, hooks), + const init = yield* Effect.tryPromise({ + try: async () => { + try { + return await applyPlugin(load, input) + } catch (err) { + if (load.source !== "file") throw err + await wait() + return applyPlugin(load, input) + } + }, catch: (err) => { const message = errorMessage(err) log.error("failed to load plugin", { path: load.spec, error: message }) @@ -230,9 +241,10 @@ export const layer = Layer.effect( // message: `Failed to load plugin ${load.spec}: ${message}`, // }).toObject(), // }) - return Effect.void + return Effect.succeed(undefined) }), ) + if (init) hooks.push(...init) } // kilocode_change end From 19acf7680e4b8424fc413860ebb153ddd6a2e93b Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 28 Apr 2026 10:34:03 +0300 Subject: [PATCH 18/18] test(cli): mirror upstream provider plugin fixture --- packages/opencode/src/plugin/index.ts | 35 ++++++------------- .../opencode/test/provider/provider.test.ts | 24 +++++++++---- 2 files changed, 27 insertions(+), 32 deletions(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 3a03a5028dd..85c2fb3d4a2 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -92,21 +92,18 @@ function getLegacyPlugins(mod: Record) { return result } -// kilocode_change start - return hooks so local plugin initialization can be retried safely -async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput) { +async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) { const plugin = readV1Plugin(load.mod, load.spec, "server", "detect") if (plugin) { await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg) - return [await (plugin as PluginModule).server(input, load.options)] + hooks.push(await (plugin as PluginModule).server(input, load.options)) + return } - const result: Hooks[] = [] for (const server of getLegacyPlugins(load.mod)) { - result.push(await server(input, load.options)) + hooks.push(await server(input, load.options)) } - return result } -// kilocode_change end export const layer = Layer.effect( Service, @@ -164,18 +161,16 @@ export const layer = Layer.effect( if (init._tag === "Some") hooks.push(init.value) } - // kilocode_change start const plugins = Flag.KILO_PURE ? [] : (cfg.plugin_origins ?? []) if (Flag.KILO_PURE && cfg.plugin_origins?.length) { log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length }) } - const wait = () => bridge.promise(config.waitForDependencies()) + if (plugins.length) yield* config.waitForDependencies() const loaded = yield* Effect.promise(() => PluginLoader.loadExternal({ items: plugins, kind: "server", - wait, report: { start(candidate) { log.info("loading plugin", { path: candidate.plan.spec }) @@ -213,21 +208,13 @@ export const layer = Layer.effect( }, }), ) - // kilocode_change end - // kilocode_change start - retry local plugin initialization after dependency setup for (const load of loaded) { if (!load) continue - const init = yield* Effect.tryPromise({ - try: async () => { - try { - return await applyPlugin(load, input) - } catch (err) { - if (load.source !== "file") throw err - await wait() - return applyPlugin(load, input) - } - }, + // Keep plugin execution sequential so hook registration and execution + // order remains deterministic across plugin runs. + yield* Effect.tryPromise({ + try: () => applyPlugin(load, input, hooks), catch: (err) => { const message = errorMessage(err) log.error("failed to load plugin", { path: load.spec, error: message }) @@ -241,12 +228,10 @@ export const layer = Layer.effect( // message: `Failed to load plugin ${load.spec}: ${message}`, // }).toObject(), // }) - return Effect.succeed(undefined) + return Effect.void }), ) - if (init) hooks.push(...init) } - // kilocode_change end // Notify plugins of current config for (const hook of hooks) { diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 060943f9bf3..19e1e05b3cf 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -56,6 +56,16 @@ async function defaultModel() { return run((provider) => provider.defaultModel()) } +// kilocode_change start - mirror upstream #24416 for @kilocode/plugin dependency readiness +async function markPluginDependenciesReady(dir: string) { + await mkdir(path.join(dir, "node_modules"), { recursive: true }) + await Bun.write( + path.join(dir, "package-lock.json"), + JSON.stringify({ packages: { "": { dependencies: { "@kilocode/plugin": "0.0.0" } } } }), + ) +} +// kilocode_change end + function paid(providers: Awaited>) { const item = providers[ProviderID.make("opencode")] if (!item) return 0 // kilocode_change - Kilo drops opencode provider without apiKey/auth @@ -2484,8 +2494,13 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => { test("plugin config providers persist after instance dispose", async () => { await using tmp = await tmpdir({ init: async (dir) => { - const root = path.join(dir, ".opencode", "plugin") + // kilocode_change start - mirror upstream #24416 to avoid real plugin dependency installs + const cfg = path.join(dir, ".opencode") + const root = path.join(cfg, "plugin") await mkdir(root, { recursive: true }) + await markPluginDependenciesReady(cfg) + await markPluginDependenciesReady(Global.Path.config) + // kilocode_change end await Bun.write( path.join(root, "demo-provider.ts"), [ @@ -2530,12 +2545,7 @@ test("plugin config providers persist after instance dispose", async () => { expect(first[ProviderID.make("demo")]).toBeDefined() expect(first[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined() - // kilocode_change start - await Instance.provide({ - directory: tmp.path, - fn: () => Instance.dispose(), - }) - // kilocode_change end + await Instance.disposeAll() const second = await Instance.provide({ directory: tmp.path,