From fda4e1756b3de46da3ac2081d440969a32ae5a59 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 3 Jul 2026 17:39:52 +0200 Subject: [PATCH 1/2] fix(cli): fail headless subagent permission asks instead of hanging --- .changeset/subagent-permission-hang.md | 5 + packages/opencode/src/cli/cmd/run.ts | 20 ++- .../src/kilocode/permission/headless.ts | 45 +++++++ packages/opencode/src/permission/index.ts | 7 + .../session-prompt-permission-refresh.test.ts | 124 +++++++++++++++++- 5 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 .changeset/subagent-permission-hang.md create mode 100644 packages/opencode/src/kilocode/permission/headless.ts diff --git a/.changeset/subagent-permission-hang.md b/.changeset/subagent-permission-hang.md new file mode 100644 index 00000000000..6db1fef325d --- /dev/null +++ b/.changeset/subagent-permission-hang.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fail subagent permission prompts in headless `kilo run` immediately instead of hanging forever, and approve subagent permission prompts under `--dangerously-skip-permissions` diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index e6ffede741e..9b0e1f2ef1a 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -33,6 +33,7 @@ import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime. import { event as normalizeEvent } from "./run/event" import { importCloudSession, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change import { KiloRunAuto } from "@/kilocode/cli/run-auto" // kilocode_change +import { KiloHeadless } from "@/kilocode/permission/headless" // kilocode_change import { KiloRun, KiloRunDaemon } from "@/kilocode/cli/cmd/run" // kilocode_change const runtimeTask = import("./run/runtime") @@ -700,7 +701,10 @@ export const RunCommand = effectCmd({ process.exit(1) } const sessionID = sess.id - const auto = KiloRunAuto.create(sessionID) // kilocode_change + // kilocode_change start - track Task children; plain headless runs deny subagent asks instead of hanging (#11903) + const auto = KiloRunAuto.create(sessionID) + if (!args.attach && !args.auto && !args["dangerously-skip-permissions"]) KiloHeadless.mark(sessionID) + // kilocode_change end function emit(type: string, data: Record) { if (args.format === "json") { @@ -746,8 +750,8 @@ export const RunCommand = effectCmd({ if (event.type === "message.part.updated") { const part = event.properties.part - // kilocode_change start - track Task child sessions for --auto permission replies - if (args.auto) KiloRunAuto.track(auto, part) + // kilocode_change start - track Task child sessions for --auto and --dangerously-skip-permissions replies + if (args.auto || args["dangerously-skip-permissions"]) KiloRunAuto.track(auto, part) // kilocode_change end if (part.sessionID !== sessionID) continue @@ -851,6 +855,16 @@ export const RunCommand = effectCmd({ } // kilocode_change end + // kilocode_change start - approve tracked Task child asks too, so subagents don't hang (#11903) + if (args["dangerously-skip-permissions"] && KiloRunAuto.allowed(auto, permission.sessionID)) { + await client.permission.reply({ + requestID: permission.id, + reply: "once", + }) + continue + } + // kilocode_change end + if (permission.sessionID !== sessionID) continue if (args["dangerously-skip-permissions"]) { diff --git a/packages/opencode/src/kilocode/permission/headless.ts b/packages/opencode/src/kilocode/permission/headless.ts new file mode 100644 index 00000000000..baa931b7734 --- /dev/null +++ b/packages/opencode/src/kilocode/permission/headless.ts @@ -0,0 +1,45 @@ +import { Database, eq } from "@/storage/db" +import { SessionTable } from "@/session/session.sql" +import type { SessionID } from "@/session/schema" + +/** + * Headless roots (#11903). + * + * Root sessions driven by a client that cannot answer subagent permission + * prompts (plain `kilo run`). Permission asks originating from their child + * sessions must fail with DeniedError instead of blocking forever on a reply + * that never comes. Interactive clients (TUI, extension) never mark sessions + * here, so their subagent prompts stay answerable. + */ +export namespace KiloHeadless { + const roots = new Set() + + export function mark(id: string) { + roots.add(id) + } + + export function clear(id: string) { + roots.delete(id) + } + + /** True when `id` is a subagent session whose root run has no attached human. */ + export function denies(id: string): boolean { + if (roots.size === 0) return false + if (roots.has(id)) return false + for (let parent = lookup(id); parent; parent = lookup(parent)) { + if (roots.has(parent)) return true + } + return false + } + + function lookup(id: string) { + const row = Database.use((db) => + db + .select({ parent: SessionTable.parent_id }) + .from(SessionTable) + .where(eq(SessionTable.id, id as SessionID)) + .get(), + ) + return row?.parent ?? undefined + } +} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index d57f9efff89..af14a724ba0 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -18,6 +18,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionID } from "./schema" // kilocode_change start import { ConfigProtection } from "@/kilocode/permission/config-paths" +import { KiloHeadless } from "@/kilocode/permission/headless" import { drainCovered } from "@/kilocode/permission/drain" import { ReadPermission } from "@/kilocode/permission/read" import { ExternalDirectoryPermission } from "@/kilocode/permission/external-directory" @@ -276,6 +277,12 @@ export const layer = Layer.effect( if (!needsAsk) return + // kilocode_change start - headless subagent asks fail instead of queuing for a reply that never comes (#11903) + if (KiloHeadless.denies(request.sessionID)) { + return yield* new DeniedError({ ruleset: subset(request.permission, ruleset) }) + } + // kilocode_change end + const id = request.id ?? PermissionID.ascending() const info: Request = { id, diff --git a/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts b/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts index 3f86f491f27..20d71868c79 100644 --- a/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts @@ -44,8 +44,10 @@ import { SyncEvent } from "../../src/sync" import { Ripgrep } from "../../src/file/ripgrep" import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" +import { KiloHeadless } from "../../src/kilocode/permission/headless" +import { KiloSessionPrompt } from "../../src/kilocode/session/prompt" import { provideTmpdirServer } from "../fixture/fixture" -import { testEffect } from "../lib/effect" +import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect" import { reply, TestLLMServer } from "../lib/llm-server" void Log.init({ print: false }) @@ -290,3 +292,123 @@ it.live("active tool calls use permissions changed after model streaming starts" }, ), ) + +const worker = (mode: "subagent" | "all"): AgentSvc.Info => ({ + name: "worker", + mode, + permission: Permission.fromConfig({ bash: "ask" }), + options: {}, +}) + +const bash = (sessionID: Session.Info["id"]) => ({ + sessionID, + permission: "bash", + patterns: ["echo 1"], + always: ["echo 1"], + metadata: {}, +}) + +// Reproduces #11903: a sync subagent hitting an "ask" rule in a headless run +// used to block forever on a permission prompt no client would ever answer. +it.live("headless run: subagent permission asks fail instead of waiting forever", () => + provideTmpdirServer( + Effect.fnUntraced(function* () { + const permission = yield* Permission.Service + const sessions = yield* Session.Service + const root = yield* sessions.create({ title: "Root" }) + const child = yield* sessions.create({ parentID: root.id, title: "Subagent" }) + KiloHeadless.mark(root.id) + + // mode "all" agents are valid subagents too; the deny must not key off agent mode + const agent = worker("all") + const err = yield* awaitWithTimeout( + KiloSessionPrompt.askPermission({ + permission, + agents: { get: () => Effect.succeed(agent) }, + sessions, + agent, + session: child, + request: bash(child.id), + }).pipe(Effect.flip), + "subagent permission ask queued waiting for a human reply instead of failing", + ) + + expect(err).toBeInstanceOf(Permission.DeniedError) + expect(yield* permission.list()).toEqual([]) + expect(KiloHeadless.denies(child.id)).toBe(true) + expect(KiloHeadless.denies(root.id)).toBe(false) + + KiloHeadless.clear(root.id) + }), + { git: true }, + ), +) + +it.live("interactive run: subagent permission asks still queue for a human reply", () => + provideTmpdirServer( + Effect.fnUntraced(function* () { + const permission = yield* Permission.Service + const sessions = yield* Session.Service + const root = yield* sessions.create({ title: "Root" }) + const child = yield* sessions.create({ parentID: root.id, title: "Subagent" }) + + const agent = worker("subagent") + const fiber = yield* KiloSessionPrompt.askPermission({ + permission, + agents: { get: () => Effect.succeed(agent) }, + sessions, + agent, + session: child, + request: bash(child.id), + }).pipe(Effect.forkScoped) + + const pending = yield* pollWithTimeout( + Effect.gen(function* () { + const list = yield* permission.list() + return list.find((item) => item.sessionID === child.id) + }), + "subagent permission ask was never surfaced", + ) + yield* permission.reply({ requestID: pending.id, reply: "reject" }) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + }), + { git: true }, + ), +) + +it.live("headless run: root session permission asks still queue (only subagents fail)", () => + provideTmpdirServer( + Effect.fnUntraced(function* () { + const permission = yield* Permission.Service + const sessions = yield* Session.Service + const root = yield* sessions.create({ title: "Root" }) + KiloHeadless.mark(root.id) + + const agent = { ...worker("subagent"), mode: "primary" as const } + const fiber = yield* KiloSessionPrompt.askPermission({ + permission, + agents: { get: () => Effect.succeed(agent) }, + sessions, + agent, + session: root, + request: bash(root.id), + }).pipe(Effect.forkScoped) + + const pending = yield* pollWithTimeout( + Effect.gen(function* () { + const list = yield* permission.list() + return list.find((item) => item.sessionID === root.id) + }), + "root permission ask was never surfaced", + ) + yield* permission.reply({ requestID: pending.id, reply: "reject" }) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + KiloHeadless.clear(root.id) + }), + { git: true }, + ), +) From b05cbcaca2e07c86d92b6a4bf0195728370e015c Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 3 Jul 2026 18:04:55 +0200 Subject: [PATCH 2/2] fix(cli): reject subagent asks over the wire for daemon and attach runs --- packages/opencode/src/cli/cmd/run.ts | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 9b0e1f2ef1a..32d99030d5c 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -750,8 +750,8 @@ export const RunCommand = effectCmd({ if (event.type === "message.part.updated") { const part = event.properties.part - // kilocode_change start - track Task child sessions for --auto and --dangerously-skip-permissions replies - if (args.auto || args["dangerously-skip-permissions"]) KiloRunAuto.track(auto, part) + // kilocode_change start - track Task child sessions so permission replies can target them + KiloRunAuto.track(auto, part) // kilocode_change end if (part.sessionID !== sessionID) continue @@ -855,11 +855,26 @@ export const RunCommand = effectCmd({ } // kilocode_change end - // kilocode_change start - approve tracked Task child asks too, so subagents don't hang (#11903) - if (args["dangerously-skip-permissions"] && KiloRunAuto.allowed(auto, permission.sessionID)) { + // kilocode_change start - answer tracked Task child asks too, so subagents don't hang (#11903) + // Covers daemon/attach modes where the server evaluates permissions in another + // process and the in-process KiloHeadless deny cannot apply. + if (permission.sessionID !== sessionID) { + if (!KiloRunAuto.allowed(auto, permission.sessionID)) continue + if (args["dangerously-skip-permissions"]) { + await client.permission.reply({ + requestID: permission.id, + reply: "once", + }) + continue + } + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL + + `subagent permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, + ) await client.permission.reply({ requestID: permission.id, - reply: "once", + reply: "reject", }) continue }