diff --git a/packages/nikcli/src/cli/cmd/tui/thread.ts b/packages/nikcli/src/cli/cmd/tui/thread.ts index a390b0f6f..5fd802157 100644 --- a/packages/nikcli/src/cli/cmd/tui/thread.ts +++ b/packages/nikcli/src/cli/cmd/tui/thread.ts @@ -12,6 +12,7 @@ import { createNikcliClient, type Event } from "@nikcli-ai/sdk/httpapi" import type { EventSource } from "@nikcli-ai/tui/context/sdk" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "@nikcli-ai/util/win32" import { errorMessage } from "@nikcli-ai/util/error-format" +import { HerdrBridge } from "@nikcli-ai/util/herdr-bridge" import { Process } from "@nikcli-ai/util/process" import { SessionPrimitives } from "@nikcli-ai/util/session-primitives" @@ -265,6 +266,12 @@ export const TuiThreadCommand = cmd({ error: errorMessage(error), }) }) + // The worker owns the herdr plugin, but on Windows its shutdown is + // fire-and-forget (see shutdownWorker), so the plugin's dispose is cut + // off before it can hand the pane back. This is the process that is + // actually about to exit, so release from here. Synchronous and + // idempotent; a no-op outside a herdr pane. + HerdrBridge.releasePaneSync() simulation?.backend.stop() } diff --git a/packages/nikcli/src/plugin/herdr/index.ts b/packages/nikcli/src/plugin/herdr/index.ts index c4239e75a..30f528d0a 100644 --- a/packages/nikcli/src/plugin/herdr/index.ts +++ b/packages/nikcli/src/plugin/herdr/index.ts @@ -319,7 +319,12 @@ export async function HerdrPlugin(_input: PluginInput): Promise { return { async dispose() { log.info("disposing herdr plugin") + // stop() resets runtime.released, so the CLI release has to run after + // it. Otherwise a late report could reclaim the pane we just handed + // back. The TUI process also calls releasePaneSync() because Windows + // never awaits this dispose. HerdrBridge.stop() + HerdrBridge.releasePaneSync() }, async event(input) { // Session lifecycle is already covered by the bridge's GlobalBus diff --git a/packages/nikcli/test/plugin/herdr/release-on-exit.test.ts b/packages/nikcli/test/plugin/herdr/release-on-exit.test.ts new file mode 100644 index 000000000..580e0de31 --- /dev/null +++ b/packages/nikcli/test/plugin/herdr/release-on-exit.test.ts @@ -0,0 +1,43 @@ +/** + * Shutdown contract for the herdr integration. + * + * Herdr only clears agents it recognizes by process. nikcli is reported, + * not detected, so quitting has to hand the pane back explicitly or the + * agent panel keeps a zombie row until the pane's shell exits. + */ +import { afterEach, describe, expect, it } from "bun:test" +import * as bridge from "@nikcli-ai/util/herdr-bridge" + +const originalEnv = { ...process.env } + +afterEach(() => { + process.env = { ...originalEnv } + bridge.setReleased(false) +}) + +describe("herdr shutdown", () => { + it("hands the pane back under the source herdr granted authority to", () => { + expect(bridge.releaseAgentArgv("w1:p1", 42)).toEqual([ + "pane", + "release-agent", + "w1:p1", + "--source", + "herdr:nikcli", + "--agent", + "nikcli", + "--seq", + "42", + ]) + }) + + it("does nothing when there is no pane to hand back", () => { + delete process.env.HERDR_PANE_ID + expect(() => bridge.releasePaneSync()).not.toThrow() + }) + + it("can still hand the pane back after stop() resets local runtime state", () => { + delete process.env.HERDR_PANE_ID + bridge.stop() + expect(() => bridge.releasePaneSync()).not.toThrow() + }) +}) diff --git a/packages/util/src/herdr-bridge.ts b/packages/util/src/herdr-bridge.ts index 1cb7743a6..81d8dee03 100644 --- a/packages/util/src/herdr-bridge.ts +++ b/packages/util/src/herdr-bridge.ts @@ -34,6 +34,7 @@ * command) — never on import. This protects the chat session stream * from being hooked while the user has no pane registered. */ +import { spawnSync } from "node:child_process" import { createConnection, type NetConnectOpts, type Socket } from "node:net" import { platform } from "node:os" import fs from "fs/promises" @@ -556,6 +557,38 @@ export async function releasePane(input?: { paneId?: string; socketPath?: string }) } +/** + * Release the pane without an event loop. + * + * Herdr keeps a reported agent until someone releases it — it only clears + * agents it recognizes by process, and nikcli is not one of those, so a + * quit that skips the release leaves a zombie row in herdr's agent panel + * until the pane's shell itself exits. `process.on("exit")` cannot await a + * socket write, so the shutdown path goes through the herdr CLI instead, + * which is synchronous. + */ +export function releaseAgentArgv(paneId: string, seq: number): string[] { + return ["pane", "release-agent", paneId, "--source", HERDR_SOURCE, "--agent", HERDR_AGENT, "--seq", String(seq)] +} + +export function releasePaneSync(): void { + if (runtime.released) return + const paneId = process.env["HERDR_PANE_ID"] + const bin = resolveHerdrBin() + if (!paneId || !bin) return + runtime.released = true + try { + spawnSync(bin, releaseAgentArgv(paneId, nextReportSeq()), { + stdio: "ignore", + timeout: 2000, + windowsHide: true, + }) + } catch (error) { + log.debug("herdr release_agent (cli) failed", { error: errorMessage(error) }) + } +} + + /** * Report a nikcli session as a herdr agent. No-op when the bridge is not * enabled or the socket is unreachable. Failures are logged, never thrown, @@ -982,6 +1015,7 @@ export const HerdrBridge = { handleEvent, handleChatMessage, releasePane, + releasePaneSync, isInHerdrPane, nextReportSeq, normalizeSnapshot,