diff --git a/packages/coding-agent/.changes/herdr-reporter-rearm.md b/packages/coding-agent/.changes/herdr-reporter-rearm.md new file mode 100644 index 0000000000..a863c93c64 --- /dev/null +++ b/packages/coding-agent/.changes/herdr-reporter-rearm.md @@ -0,0 +1 @@ +- Fixed the herdr agent indicator to follow the pane that attaches a daemon session, so a session opened from a pane appears in herdr without a manual `/reload`. diff --git a/packages/coding-agent/examples/sdk/12-full-control.ts b/packages/coding-agent/examples/sdk/12-full-control.ts index 9cb55623e7..501831ce2b 100644 --- a/packages/coding-agent/examples/sdk/12-full-control.ts +++ b/packages/coding-agent/examples/sdk/12-full-control.ts @@ -48,6 +48,7 @@ Available: ipython. Be concise.`, getAppendSystemPrompt: () => [], extendResources: () => {}, reload: async () => {}, + emitExtensionEvent: () => {}, }; const { session } = await createAgentSession({ diff --git a/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts b/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts index fd8afe7038..e998612c54 100644 --- a/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts +++ b/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts @@ -12,8 +12,10 @@ * loader invokes per session load — inside the daemon's client-env window — * so each daemon session captures its own pane identity. * - * The factory is a complete no-op when `HERDR_ENV` is not `"1"` (i.e. when - * not running inside a Herdr pane), so it is safe to always load. + * The factory only opens sockets when armed with a full Herdr pane identity, + * so it is safe to always load. Outside Herdr it stays unarmed but keeps + * tracking session state, so the daemon can arm it later when a pane client + * attaches (see `HERDR_REBIND_EVENT`). */ import { createConnection } from "node:net"; @@ -131,16 +133,36 @@ export const herdrAgentStateExtension: ExtensionFactory = (pi: ExtensionAPI) => herdrAgentStateExtensionImpl(pi, () => []); }; +/** + * Shared-bus event the daemon emits when a client carrying the allowlisted + * HERDR_* env attaches to a session. The reporter re-captures its pane + * identity from `env` and claims the new pane with the live session state, so + * a daemon-resident session attached from a pane appears in Herdr without a + * manual /reload. + */ +export const HERDR_REBIND_EVENT = "herdr:rebind"; + +export interface HerdrRebindEventData { + /** The attaching client's allowlisted HERDR_* env. */ + env: Record; +} + function herdrAgentStateExtensionImpl(pi: ExtensionAPI, getLoadedExtensionPaths: () => string[]): void { + // The file-based integration owns the pane; the built-in must stay silent + // entirely, including on rebind events. + if (hasFileBasedHerdrIntegration(getLoadedExtensionPaths())) { + return; + } + // Captured per factory invocation: the resource loader runs this during // session load, inside the daemon's client-env window, so these reflect the // session's own Herdr pane rather than the daemon's startup environment. - const socketPath = process.env.HERDR_SOCKET_PATH; - const paneId = process.env.HERDR_PANE_ID; - const enabled = process.env.HERDR_ENV === "1" && !!socketPath && !!paneId; - if (!enabled || hasFileBasedHerdrIntegration(getLoadedExtensionPaths())) { - return; - } + // Sessions created outside Herdr start unarmed; the listener set below + // still tracks state, and the daemon arms it by emitting HERDR_REBIND_EVENT + // on the first attach that carries a pane's env. + let socketPath = process.env.HERDR_SOCKET_PATH; + let paneId = process.env.HERDR_PANE_ID; + let enabled = process.env.HERDR_ENV === "1" && !!socketPath && !!paneId; const source = "herdr:pi"; const agentLabel = "prime-agent"; @@ -245,6 +267,11 @@ function herdrAgentStateExtensionImpl(pi: ExtensionAPI, getLoadedExtensionPaths: // leave Herdr showing an agent that already exited. return; } + if (!enabled) { + // Unarmed sessions never touch the wire; a later rebind publishes + // the tracked state to the pane it arms with. + return; + } queuedState = { state, message, seq: nextReportSeq() }; if (!sendInFlight) { activeDrain = drainStateQueue(); @@ -277,6 +304,10 @@ function herdrAgentStateExtensionImpl(pi: ExtensionAPI, getLoadedExtensionPaths: // a report landing after the release would reclaim the pane. released = true; queuedState = undefined; + if (!enabled) { + // Never armed: no pane to release. + return; + } await activeDrain.catch(() => undefined); return sendRequest({ id: `${source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`, @@ -381,6 +412,37 @@ function herdrAgentStateExtensionImpl(pi: ExtensionAPI, getLoadedExtensionPaths: publishState(true); }); + // The daemon emits this on the session's shared bus when a client carrying + // HERDR_* env attaches; the loaded factory cannot re-run under the new env, + // so the identity is re-captured here instead. + const unsubscribeRebind = pi.events.on(HERDR_REBIND_EVENT, (data: unknown) => { + const env = (data as Partial | undefined)?.env; + const nextSocketPath = env?.HERDR_SOCKET_PATH; + const nextPaneId = env?.HERDR_PANE_ID; + // Rebind only arms or moves the identity; an event without a complete + // pane identity (headless client) leaves the current one alone. + if (env?.HERDR_ENV !== "1" || !nextSocketPath || !nextPaneId) { + return; + } + if (enabled && socketPath === nextSocketPath && paneId === nextPaneId) { + return; + } + // Switch panes the way a reload replaces this instance: drop transient + // timers and failure holds, then claim the new pane with the live state + // (agentActive and blocked counters keep tracking while unarmed). A + // report still in flight may land on the old pane; Herdr drops reports + // for panes it does not know. + clearPendingTimers(); + clearFailureState(); + queuedState = undefined; + socketPath = nextSocketPath; + paneId = nextPaneId; + enabled = true; + lastState = undefined; + lastMessage = undefined; + publishState(true); + }); + const unsubscribeBlocked = pi.events.on("herdr:blocked", (data: any) => { if (!data?.active) { blockedCount = Math.max(0, blockedCount - 1); @@ -453,8 +515,10 @@ function herdrAgentStateExtensionImpl(pi: ExtensionAPI, getLoadedExtensionPaths: clearPendingTimers(); // The event bus is shared across reloads and session replacements, so a // listener left behind would keep this stale instance reporting with a - // captured (possibly wrong) pane identity forever. + // captured (possibly wrong) pane identity forever, or let a rebind + // event re-arm an instance that already handed the pane over. unsubscribeBlocked(); + unsubscribeRebind(); // On session replacement (new/resume/fork) or reload, a successor // instance in this same pane re-reports immediately. Releasing here // races that report: two independent socket writes with no ordering, diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index 5cb03a4bf5..f37523b7d5 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -36,6 +36,12 @@ export interface ResourceLoader { getAppendSystemPrompt(): string[]; extendResources(paths: ResourceExtensionPaths): void; reload(): Promise; + /** + * Emit an event on the bus shared with the loaded extensions (`pi.events`). + * Lets a host (e.g. the daemon) notify extension instances about events + * that happen after load (such as a client attach carrying pane identity). + */ + emitExtensionEvent(channel: string, data: unknown): void; } function resolvePromptInput(input: string | undefined, description: string): string | undefined { @@ -269,6 +275,10 @@ export class DefaultResourceLoader implements ResourceLoader { return this.loadedExtensionPaths; } + emitExtensionEvent(channel: string, data: unknown): void { + this.eventBus.emit(channel, data); + } + getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] } { return { skills: this.skills, diagnostics: this.skillDiagnostics }; } diff --git a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts index 722c2895be..2f4c5fbd67 100644 --- a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts @@ -175,9 +175,9 @@ export interface DaemonAgentConnectionOptions { snapshotTimeoutMs?: number; /** * Send this client's allowlisted env (herdr pane identity) with attach so - * an env-less session (e.g. cron-created) adopts it. Set only by the - * primary interactive connection — the daemon adopts-if-absent, never - * rebinds, so watchers must not send env at all. + * the session's pane identity follows the pane the client runs in. Set + * only by the primary interactive connection — the daemon rebinds to the + * attaching client, so watchers must not send env at all. */ sendClientEnv?: boolean; /** Advertise support for interactive extension dialogs. */ diff --git a/packages/coding-agent/src/modes/daemon/active-session-state.ts b/packages/coding-agent/src/modes/daemon/active-session-state.ts index f71711831a..ebb1eb200e 100644 --- a/packages/coding-agent/src/modes/daemon/active-session-state.ts +++ b/packages/coding-agent/src/modes/daemon/active-session-state.ts @@ -50,10 +50,12 @@ export interface ActiveSessionState { summaryState?: AgentStatus; /** * Client env (e.g. herdr pane identity), merged over process.env for this - * session's pi.exec() subprocesses. Bound when the runtime is created (or + * session's pi.exec() subprocesses. Bound when the runtime is created, * adopted from the first env-carrying create that reuses an env-less - * session); never overwritten after that — watchers also attach, and - * extensions capture identity at load. Subagents inherit the parent's. + * session, or rebound to the pane of the latest env-carrying attach + * (the loaded herdr reporter is notified via the session's extension + * bus). Env-less clients — watchers and headless clients — never move + * it. Subagents inherit the parent's. */ clientEnv?: Record; } diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 7ba94b8366..97a92a1942 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -87,6 +87,7 @@ import { resolveHeartbeatStreamingBehavior, shouldDeferHeartbeatCronJob, } from "../../core/cron-jobs.js"; +import { HERDR_REBIND_EVENT } from "../../core/extensions/builtin/herdr-agent-state.js"; import { ORPHAN_PROCESS_JOURNAL_ENV } from "../../core/orphan-process-journal.js"; import { PromptAdmissionCancelledError, waitForPromptAdmission } from "../../core/prompt-admission.js"; import { providerRetryPolicy } from "../../core/provider-retry.js"; @@ -974,6 +975,36 @@ export class AgentDaemon { } } + /** + * Rebind the session's pane identity to an attaching client that carries + * the allowlisted HERDR_* env. Every pane client attaches right after it + * creates or opens a session, so the last pane to attach owns the + * identity: pi.exec reads state.clientEnv live and every runtime rebuild + * re-loads extensions under it, so subprocess env and load-time captures + * move together instead of pinning the creator's (possibly closed) pane. + * The already-loaded herdr reporter cannot re-run its factory, so the + * rebind is also emitted on the session's shared extension bus and the + * reporter switches panes immediately. Env-less clients (headless CLI, + * watchers such as the agents view and subagent viewers) send no env and + * never move the identity. + */ + private rebindClientEnv(state: ActiveSessionState, env?: Record): void { + if (!env) { + return; + } + state.clientEnv = env; + for (const child of this.sessions.values()) { + const metadata = child.runtime.metadata; + if (metadata.kind === "subagent" && metadata.parentActiveSessionId === state.activeSessionId) { + this.rebindClientEnv(child, env); + } + } + if (state.runtime.metadata.kind === "subagent") { + return; + } + state.runtime.services.resourceLoader.emitExtensionEvent(HERDR_REBIND_EVENT, { env }); + } + /** Root sessions dir that keys this daemon's spawn ledger. */ private rlmLedgerSessionsDir(): string { return this.options.defaultSessionConfig.sessionDir ?? getSessionsDir(this.agentDir); @@ -1898,9 +1929,9 @@ export class AgentDaemon { // A live runtime already owns this session file; reuse it instead of // starting a second runtime that would interleave writes to one file. // clientEnv adopts the first offered identity (e.g. a pane opening a - // cron-created session) but never overwrites one: extensions captured - // the creator's identity at load, and swapping it would only make - // pi.exec disagree with those captures. + // cron-created session) but never overwrites one here: pane clients + // rebind the session's pane identity on the attach that follows + // (rebindClientEnv), which keeps captures and pi.exec consistent. if (command.name) { await this.setStateSessionName(existing, command.name); } @@ -3404,9 +3435,7 @@ export class AgentDaemon { ...(entry.repliedSinceTask !== undefined ? { repliedSinceTask: entry.repliedSinceTask } : {}), ...(entry.parentSessionId ? { parentSessionId: entry.parentSessionId } : {}), ...(entry.rlmChildId ? { rlmChildId: entry.rlmChildId } : {}), - ...(entry.firstMessage - ? { firstMessage: entry.firstMessage.slice(0, AGENT_OBSERVE_PREVIEW_MAX_CHARS) } - : {}), + ...(entry.firstMessage ? { firstMessage: entry.firstMessage.slice(0, AGENT_OBSERVE_PREVIEW_MAX_CHARS) } : {}), }; } @@ -3482,7 +3511,11 @@ export class AgentDaemon { ...(summary.firstMessage ? { firstMessage: summary.firstMessage } : {}), ...(latest ? { - latestMessage: createAgentObserveMessagePreview(latest, messages.length - 1, AGENT_OBSERVE_PREVIEW_MAX_CHARS), + latestMessage: createAgentObserveMessagePreview( + latest, + messages.length - 1, + AGENT_OBSERVE_PREVIEW_MAX_CHARS, + ), } : {}), }; @@ -4183,11 +4216,12 @@ export class AgentDaemon { client.transport === "private-framed" && daemonClientCapabilitiesForSession(client, state.activeSessionId).has("chunked_snapshot"); // Attach is admitted during update-restart preparation as a read. Env - // adoption remains safe while mutations are only draining; after fencing, - // defer it until rollback so the checkpoint never omits a live identity. + // rebinding remains safe while mutations are only draining; after + // fencing, defer it until rollback so the checkpoint never omits a + // live identity. const clientEnv = filterClientEnv(command.env); const deferClientEnv = this.updateRestart && this.updateRestart.phase !== "preparing"; - if (!deferClientEnv) this.adoptClientEnv(state, clientEnv); + if (!deferClientEnv) this.rebindClientEnv(state, clientEnv); const snapshotSignal = streamsSnapshot ? markClientSnapshotStreaming(client, state.activeSessionId) : undefined; @@ -6595,7 +6629,7 @@ export class AgentDaemon { deferred.state.clients.has(deferred.client) && deferred.client.attachedActiveSessionIds.has(deferred.state.activeSessionId) ) { - this.adoptClientEnv(deferred.state, deferred.env); + this.rebindClientEnv(deferred.state, deferred.env); } } transaction.deferredClientEnv.length = 0; diff --git a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts index b864623536..b3f1be8f4b 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts @@ -208,8 +208,10 @@ export interface DaemonAttachClientMetadata { * (e.g. HERDR_PANE_ID/HERDR_SOCKET_PATH that herdr sets per pane). The daemon * scopes these to the created session and merges them over process.env for * that session's pi.exec() subprocesses — it does not mutate the daemon's own - * env. Carried on create only: attach must not rebind a session's identity, - * since watchers (agents view, subagent viewers) also attach. + * env. Create binds the creator's env; an attach carrying env rebinds the + * session's pane identity to the attaching client (last pane wins), while + * watchers (agents view, subagent viewers) attach without env and never + * move it. */ export interface DaemonClientEnv { env?: Record; @@ -415,9 +417,11 @@ export type DaemonCommand = lifecycle?: DaemonSessionLifecycle; } & DaemonClientEnv & DaemonLaunchEnv) - // Attach env is adopt-if-absent only: it fills identity for env-less - // sessions (e.g. cron-created) but never rebinds one, since watchers - // (agents view, subagent viewers) also attach. + // Attach env fills identity for env-less sessions (e.g. cron-created) and + // rebinds it to the attaching client afterwards (last pane wins), so a + // daemon-resident session attached from a pane reports to that pane. + // Env-less clients — watchers (agents view, subagent viewers) and + // headless clients — never move it. | ({ id?: string; type: "attach"; diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 5a2782c777..c9ccea7e48 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -78,7 +78,6 @@ import { CompactAssistantStreamReconstructor, isCompactAssistantDelta } from "./ import { DAEMON_CATALOG_ROLE_ENV, DaemonCatalogClient } from "./daemon-catalog-process.js"; import { DaemonSessionRecoveringError, deserializeDaemonError, serializeDaemonError } from "./daemon-errors.js"; import { - collectDaemonClientEnv, createDaemonEventMeta, DAEMON_COMMAND_COMPATIBILITY, DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION, @@ -5111,7 +5110,11 @@ export class DaemonSupervisor { ? ["attach_snapshot", "event_sequence", "slim_attach", "chunked_snapshot"] : ["attach_snapshot", "event_sequence", "slim_attach"], supportsExtensionUi: false, - env: command.env ?? collectDaemonClientEnv(), + // Forward only the client's env. Fabricating the + // supervisor's here would rebind env-less sessions + // (and their herdr reporter) to the pane that started + // the daemon whenever a watcher attaches. + env: command.env, }); const loaded = attachResultFromResponse(response); if (match.worker.snapshotLoads.get(snapshotLoadKey) !== loading) { diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index eed9af4939..26412d0854 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -4063,6 +4063,177 @@ describe("daemon mode helpers", () => { expect(write).not.toHaveBeenCalled(); }); + it("rebinds the herdr pane identity to the client of an env-carrying attach", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const emittedEvents: Array<{ channel: string; data: unknown }> = []; + const state = makeState("active"); + state.clientEnv = { HERDR_PANE_ID: "w1:p1", HERDR_ENV: "1" }; + state.runtime = { + ...state.runtime, + metadata: { kind: "top-level", createdAt: 1 }, + services: { + resourceLoader: { + emitExtensionEvent: (channel: string, data: unknown) => { + emittedEvents.push({ channel, data }); + }, + }, + }, + } as never; + const client = makeClient("client-1", state.activeSessionId); + client.attachedActiveSessionIds.clear(); + const internals = daemon as unknown as { + sessions: Map; + createAttachResult: ReturnType; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + }; + internals.sessions.set(state.activeSessionId, state); + internals.createAttachResult = vi.fn(async () => ({ + activeSessionId: state.activeSessionId, + snapshot: { summary: {}, state: {}, messages: [] }, + lastEventSequence: 0, + })); + + await internals.handleCommand(client, { + type: "attach", + activeSessionId: state.activeSessionId, + env: { HERDR_ENV: "1", HERDR_PANE_ID: "w2:p2", HERDR_SOCKET_PATH: "/tmp/herdr.sock", PATH: "/evil" }, + }); + + // Last pane wins: the session's env identity follows the attaching + // client, filtered to the allowlist, and the loaded herdr reporter is + // notified on the session's extension bus. + expect(state.clientEnv).toEqual({ + HERDR_ENV: "1", + HERDR_PANE_ID: "w2:p2", + HERDR_SOCKET_PATH: "/tmp/herdr.sock", + }); + expect(emittedEvents).toEqual([ + { + channel: "herdr:rebind", + data: { + env: { + HERDR_ENV: "1", + HERDR_PANE_ID: "w2:p2", + HERDR_SOCKET_PATH: "/tmp/herdr.sock", + }, + }, + }, + ]); + + // A second pane re-adopts: the identity switches again. + await internals.handleCommand(makeClient("client-2", state.activeSessionId), { + type: "attach", + activeSessionId: state.activeSessionId, + env: { HERDR_ENV: "1", HERDR_PANE_ID: "w3:p3" }, + }); + expect(state.clientEnv).toEqual({ HERDR_ENV: "1", HERDR_PANE_ID: "w3:p3" }); + expect(emittedEvents).toHaveLength(2); + expect(emittedEvents[1]?.data).toEqual({ env: { HERDR_ENV: "1", HERDR_PANE_ID: "w3:p3" } }); + }); + + it("leaves the herdr pane identity alone on an env-less attach", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const emittedEvents: Array<{ channel: string; data: unknown }> = []; + const state = makeState("active"); + state.clientEnv = { HERDR_PANE_ID: "w1:p1", HERDR_ENV: "1" }; + state.runtime = { + ...state.runtime, + metadata: { kind: "top-level", createdAt: 1 }, + services: { + resourceLoader: { + emitExtensionEvent: (channel: string, data: unknown) => { + emittedEvents.push({ channel, data }); + }, + }, + }, + } as never; + const client = makeClient("watcher", state.activeSessionId); + client.attachedActiveSessionIds.clear(); + const internals = daemon as unknown as { + sessions: Map; + createAttachResult: ReturnType; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + }; + internals.sessions.set(state.activeSessionId, state); + internals.createAttachResult = vi.fn(async () => ({ + activeSessionId: state.activeSessionId, + snapshot: { summary: {}, state: {}, messages: [] }, + lastEventSequence: 0, + })); + + // Watchers (agents view, subagent viewers) and headless clients attach + // without env: no rebind, no bus event. + await internals.handleCommand(client, { type: "attach", activeSessionId: state.activeSessionId }); + + expect(state.clientEnv).toEqual({ HERDR_PANE_ID: "w1:p1", HERDR_ENV: "1" }); + expect(emittedEvents).toEqual([]); + }); + + it("propagates an attach rebind to subagents spawned before it", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const emittedEvents: Array<{ channel: string; data: unknown }> = []; + const parent = makeState("active-parent"); + const child = makeState("active-child", parent.activeSessionId); + const childLoader = { emitExtensionEvent: vi.fn() }; + for (const state of [parent, child]) { + state.clientEnv = { HERDR_PANE_ID: "w1:p1", HERDR_ENV: "1" }; + state.runtime = { + ...state.runtime, + metadata: + state === parent + ? { kind: "top-level", createdAt: 1 } + : { kind: "subagent", createdAt: 1, parentActiveSessionId: parent.activeSessionId }, + services: { + resourceLoader: + state === parent + ? { + emitExtensionEvent: (channel: string, data: unknown) => { + emittedEvents.push({ channel, data }); + }, + } + : childLoader, + }, + } as never; + } + const client = makeClient("client-1", parent.activeSessionId); + client.attachedActiveSessionIds.clear(); + const internals = daemon as unknown as { + sessions: Map; + createAttachResult: ReturnType; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + }; + internals.sessions.set(parent.activeSessionId, parent); + internals.sessions.set(child.activeSessionId, child); + internals.createAttachResult = vi.fn(async () => ({ + activeSessionId: parent.activeSessionId, + snapshot: { summary: {}, state: {}, messages: [] }, + lastEventSequence: 0, + })); + + await internals.handleCommand(client, { + type: "attach", + activeSessionId: parent.activeSessionId, + env: { HERDR_ENV: "1", HERDR_PANE_ID: "w2:p2" }, + }); + + // The subagent's exec env reads state.clientEnv live, so it follows the + // parent's new pane; only the top-level session notifies its reporter. + expect(parent.clientEnv).toEqual({ HERDR_ENV: "1", HERDR_PANE_ID: "w2:p2" }); + expect(child.clientEnv).toEqual({ HERDR_ENV: "1", HERDR_PANE_ID: "w2:p2" }); + expect(emittedEvents).toHaveLength(1); + expect(emittedEvents[0]?.data).toEqual({ env: { HERDR_ENV: "1", HERDR_PANE_ID: "w2:p2" } }); + expect(childLoader.emitExtensionEvent).not.toHaveBeenCalled(); + }); + it("marks a chunked attach as snapshotting before deferred streaming", async () => { const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-snapshot-order-")); try { diff --git a/packages/coding-agent/test/herdr-agent-state.test.ts b/packages/coding-agent/test/herdr-agent-state.test.ts index 006bbaf2aa..c81c0b4115 100644 --- a/packages/coding-agent/test/herdr-agent-state.test.ts +++ b/packages/coding-agent/test/herdr-agent-state.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { createHerdrAgentStateExtension, + HERDR_REBIND_EVENT, hasFileBasedHerdrIntegration, herdrAgentStateExtension, herdrSocketTarget, @@ -152,7 +153,42 @@ describe("herdrAgentStateExtension", () => { expect(requests[0]?.params.state).toBe("idle"); }); - it("registers no handlers when HERDR_ENV is not set", () => { + it("stays off the wire when HERDR_ENV is not set", async () => { + const tempDir = join(tmpdir(), `hrd-${Math.random().toString(36).slice(2, 8)}`); + mkdirSync(tempDir, { recursive: true }); + cleanupPaths.push(tempDir); + const socketPath = join(tempDir, "h.sock"); + + const { server, requests } = await startFakeHerdrServer(socketPath); + cleanupServers.push(server); + + delete process.env.HERDR_ENV; + process.env.HERDR_SOCKET_PATH = socketPath; + delete process.env.HERDR_PANE_ID; + + const { pi, handlers } = createMockPi(); + herdrAgentStateExtension(pi); + // Handlers stay registered so state tracking continues, but nothing + // reaches the wire until a rebind arms a full pane identity. + expect(handlers.size).toBeGreaterThan(0); + + const ctx = { sessionManager: { getSessionFile: () => undefined, getSessionId: () => "s" } }; + handlers.get("session_start")?.[0]?.({ type: "session_start", reason: "startup" }, ctx); + handlers.get("agent_start")?.[0]?.({ type: "agent_start" }, ctx); + await handlers.get("session_shutdown")?.[0]?.({ type: "session_shutdown", reason: "quit" }, ctx); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(requests).toHaveLength(0); + }); + + it("arms from a herdr:rebind event and reports the live state to the new pane", async () => { + const tempDir = join(tmpdir(), `hrd-${Math.random().toString(36).slice(2, 8)}`); + mkdirSync(tempDir, { recursive: true }); + cleanupPaths.push(tempDir); + const socketPath = join(tempDir, "h.sock"); + + const { server, requests, waitForRequests } = await startFakeHerdrServer(socketPath); + cleanupServers.push(server); + delete process.env.HERDR_ENV; delete process.env.HERDR_SOCKET_PATH; delete process.env.HERDR_PANE_ID; @@ -160,8 +196,128 @@ describe("herdrAgentStateExtension", () => { const { pi, handlers, busHandlers } = createMockPi(); herdrAgentStateExtension(pi); - expect(handlers.size).toBe(0); - expect(busHandlers.size).toBe(0); + const ctx = { sessionManager: { getSessionFile: () => undefined, getSessionId: () => "s" } }; + // The session started headless and is mid-turn when a pane client + // attaches; the daemon emits the rebind on the session's shared bus. + handlers.get("session_start")?.[0]?.({ type: "session_start", reason: "startup" }, ctx); + handlers.get("agent_start")?.[0]?.({ type: "agent_start" }, ctx); + expect(requests).toHaveLength(0); + + busHandlers.get(HERDR_REBIND_EVENT)?.[0]?.({ + env: { HERDR_ENV: "1", HERDR_SOCKET_PATH: socketPath, HERDR_PANE_ID: "w2:p2" }, + }); + + await waitForRequests(1); + expect(requests[0]?.method).toBe("pane.report_agent"); + expect(requests[0]?.params.pane_id).toBe("w2:p2"); + expect(requests[0]?.params.state).toBe("working"); + + handlers.get("agent_end")?.[0]?.({ type: "agent_end", messages: [] }, ctx); + await waitForRequests(2); + expect(requests[1]?.params.pane_id).toBe("w2:p2"); + expect(requests[1]?.params.state).toBe("idle"); + }); + + it("switches panes on a later rebind; the last attach wins", async () => { + const tempDir = join(tmpdir(), `hrd-${Math.random().toString(36).slice(2, 8)}`); + mkdirSync(tempDir, { recursive: true }); + cleanupPaths.push(tempDir); + const socketPath = join(tempDir, "h.sock"); + + const { server, requests, waitForRequests } = await startFakeHerdrServer(socketPath); + cleanupServers.push(server); + + process.env.HERDR_ENV = "1"; + process.env.HERDR_SOCKET_PATH = socketPath; + process.env.HERDR_PANE_ID = "w1:p1"; + + const { pi, handlers, busHandlers } = createMockPi(); + herdrAgentStateExtension(pi); + + const ctx = { sessionManager: { getSessionFile: () => undefined, getSessionId: () => "s" } }; + handlers.get("session_start")?.[0]?.({ type: "session_start", reason: "startup" }, ctx); + await waitForRequests(1); + expect(requests[0]?.params.pane_id).toBe("w1:p1"); + + // The session was created in a pane that has since closed; a different + // pane re-adopts it, and the reporter follows the last attacher. + busHandlers.get(HERDR_REBIND_EVENT)?.[0]?.({ + env: { HERDR_ENV: "1", HERDR_SOCKET_PATH: socketPath, HERDR_PANE_ID: "w2:p2" }, + }); + await waitForRequests(2); + expect(requests[1]?.params.pane_id).toBe("w2:p2"); + + handlers.get("agent_start")?.[0]?.({ type: "agent_start" }, ctx); + handlers.get("agent_end")?.[0]?.({ type: "agent_end", messages: [] }, ctx); + await waitForRequests(3); + expect(requests[2]?.params.pane_id).toBe("w2:p2"); + + // A re-attach from a third pane switches again, even mid-flow. + busHandlers.get(HERDR_REBIND_EVENT)?.[0]?.({ + env: { HERDR_ENV: "1", HERDR_SOCKET_PATH: socketPath, HERDR_PANE_ID: "w3:p3" }, + }); + await waitForRequests(4); + expect(requests[3]?.params.pane_id).toBe("w3:p3"); + expect(requests[3]?.params.state).toBe("idle"); + + await handlers.get("session_shutdown")?.[0]?.({ type: "session_shutdown", reason: "quit" }, ctx); + await waitForRequests(5); + expect(requests.at(-1)?.method).toBe("pane.release_agent"); + expect(requests.at(-1)?.params.pane_id).toBe("w3:p3"); + }); + + it("ignores a rebind without a complete pane identity", async () => { + const tempDir = join(tmpdir(), `hrd-${Math.random().toString(36).slice(2, 8)}`); + mkdirSync(tempDir, { recursive: true }); + cleanupPaths.push(tempDir); + const socketPath = join(tempDir, "h.sock"); + + const { server, requests, waitForRequests } = await startFakeHerdrServer(socketPath); + cleanupServers.push(server); + + process.env.HERDR_ENV = "1"; + process.env.HERDR_SOCKET_PATH = socketPath; + process.env.HERDR_PANE_ID = "w1:p1"; + + const { pi, handlers, busHandlers } = createMockPi(); + herdrAgentStateExtension(pi); + + const ctx = { sessionManager: { getSessionFile: () => undefined, getSessionId: () => "s" } }; + handlers.get("session_start")?.[0]?.({ type: "session_start", reason: "startup" }, ctx); + await waitForRequests(1); + + // A headless client attaches: the rebind carries no pane identity and + // must neither move nor disarm the current one. + busHandlers.get(HERDR_REBIND_EVENT)?.[0]?.({ env: { HERDR_ENV: "1" } }); + busHandlers.get(HERDR_REBIND_EVENT)?.[0]?.({ + env: { HERDR_ENV: "0", HERDR_PANE_ID: "w9:p9", HERDR_SOCKET_PATH: socketPath }, + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(requests).toHaveLength(1); + expect(requests[0]?.params.pane_id).toBe("w1:p1"); + }); + + it("unsubscribes the shared-bus herdr:rebind listener on shutdown", async () => { + const tempDir = join(tmpdir(), `hrd-${Math.random().toString(36).slice(2, 8)}`); + mkdirSync(tempDir, { recursive: true }); + cleanupPaths.push(tempDir); + const socketPath = join(tempDir, "h.sock"); + + const { server } = await startFakeHerdrServer(socketPath); + cleanupServers.push(server); + + process.env.HERDR_ENV = "1"; + process.env.HERDR_SOCKET_PATH = socketPath; + process.env.HERDR_PANE_ID = "w1:p1"; + + const { pi, handlers, busHandlers } = createMockPi(); + herdrAgentStateExtension(pi); + expect(busHandlers.get(HERDR_REBIND_EVENT)).toHaveLength(1); + + const ctx = { sessionManager: { getSessionFile: () => undefined, getSessionId: () => "s" } }; + await handlers.get("session_shutdown")?.[0]?.({ type: "session_shutdown", reason: "new" }, ctx); + expect(busHandlers.get(HERDR_REBIND_EVENT)).toHaveLength(0); }); it("detects the file-based herdr integration among loaded extension paths", () => { diff --git a/packages/coding-agent/test/sdk-codex-cache-probe-tool-loop.ts b/packages/coding-agent/test/sdk-codex-cache-probe-tool-loop.ts index 3c71c5f254..03fbfe4435 100644 --- a/packages/coding-agent/test/sdk-codex-cache-probe-tool-loop.ts +++ b/packages/coding-agent/test/sdk-codex-cache-probe-tool-loop.ts @@ -181,6 +181,7 @@ function createMinimalResourceLoader(systemPrompt: string): ResourceLoader { getAppendSystemPrompt: () => [], extendResources: () => {}, reload: async () => {}, + emitExtensionEvent: () => {}, }; } diff --git a/packages/coding-agent/test/sdk-skills.test.ts b/packages/coding-agent/test/sdk-skills.test.ts index eb9b9ba6c2..d629b10d2e 100644 --- a/packages/coding-agent/test/sdk-skills.test.ts +++ b/packages/coding-agent/test/sdk-skills.test.ts @@ -59,6 +59,7 @@ This is a test skill. getAppendSystemPrompt: () => [], extendResources: () => {}, reload: async () => {}, + emitExtensionEvent: () => {}, }; const { session } = await createAgentSession({ @@ -93,6 +94,7 @@ This is a test skill. getAppendSystemPrompt: () => [], extendResources: () => {}, reload: async () => {}, + emitExtensionEvent: () => {}, }; const { session } = await createAgentSession({ diff --git a/packages/coding-agent/test/utilities.ts b/packages/coding-agent/test/utilities.ts index f4a0bfc532..780fc875bf 100644 --- a/packages/coding-agent/test/utilities.ts +++ b/packages/coding-agent/test/utilities.ts @@ -226,6 +226,7 @@ export function createTestResourceLoader(options: CreateTestResourceLoaderOption getAppendSystemPrompt: () => [], extendResources: () => {}, reload: async () => {}, + emitExtensionEvent: () => {}, }; }