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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/nikcli/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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()
}

Expand Down
5 changes: 5 additions & 0 deletions packages/nikcli/src/plugin/herdr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,12 @@ export async function HerdrPlugin(_input: PluginInput): Promise<Hooks> {
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
Expand Down
43 changes: 43 additions & 0 deletions packages/nikcli/test/plugin/herdr/release-on-exit.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
34 changes: 34 additions & 0 deletions packages/util/src/herdr-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -982,6 +1015,7 @@ export const HerdrBridge = {
handleEvent,
handleChatMessage,
releasePane,
releasePaneSync,
isInHerdrPane,
nextReportSeq,
normalizeSnapshot,
Expand Down
Loading