Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/coding-agent/.changes/herdr-reporter-rearm.md
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions packages/coding-agent/examples/sdk/12-full-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Available: ipython. Be concise.`,
getAppendSystemPrompt: () => [],
extendResources: () => {},
reload: async () => {},
emitExtensionEvent: () => {},
};

const { session } = await createAgentSession({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, string>;
}

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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)}`,
Expand Down Expand Up @@ -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<HerdrRebindEventData> | 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);
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions packages/coding-agent/src/core/resource-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ export interface ResourceLoader {
getAppendSystemPrompt(): string[];
extendResources(paths: ResourceExtensionPaths): void;
reload(): Promise<void>;
/**
* 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 {
Expand Down Expand Up @@ -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 };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
}
Expand Down
56 changes: 45 additions & 11 deletions packages/coding-agent/src/modes/daemon/daemon-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
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";
Expand Down Expand Up @@ -974,6 +975,36 @@
}
}

/**
* 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<string, string>): 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 });

Check failure on line 1005 in packages/coding-agent/src/modes/daemon/daemon-mode.ts

View workflow job for this annotation

GitHub Actions / Test (coding-agent 3/3)

test/suite/regressions/4257-update-restart-resume.test.ts > issue #4257 update restart resume > adopts attach env after a fenced update preparation rolls back

TypeError: Cannot read properties of undefined (reading 'resourceLoader') ❯ AgentDaemon.rebindClientEnv src/modes/daemon/daemon-mode.ts:1005:26 ❯ AgentDaemon.cancelPreparedUpdateRestart src/modes/daemon/daemon-mode.ts:6632:10 ❯ test/suite/regressions/4257-update-restart-resume.test.ts:1384:13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partial env clobbers pane identity

Medium Severity

rebindClientEnv replaces state.clientEnv and emits herdr:rebind for any non-empty filtered attach env. The reporter only arms or moves when HERDR_ENV is 1 with both socket and pane set. A partial or stray allowlisted key therefore wipes exec and reload env while the live reporter stays on the old pane, and a later /reload can disarm reporting entirely.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit edeb993. Configure here.

}

/** Root sessions dir that keys this daemon's spawn ledger. */
private rlmLedgerSessionsDir(): string {
return this.options.defaultSessionConfig.sessionDir ?? getSessionsDir(this.agentDir);
Expand Down Expand Up @@ -1898,9 +1929,9 @@
// 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);
}
Expand Down Expand Up @@ -3404,9 +3435,7 @@
...(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) } : {}),
};
}

Expand Down Expand Up @@ -3482,7 +3511,11 @@
...(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,
),
}
: {}),
};
Expand Down Expand Up @@ -4183,11 +4216,12 @@
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed attach still rebinds pane

Medium Severity

rebindClientEnv runs before createAttachResult succeeds and before the client joins state.clients. A thrown attach still overwrites clientEnv and emits herdr:rebind, so the session reports to a pane that never became a live viewer and disappears from the previous pane's herdr sidebar.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit edeb993. Configure here.

const snapshotSignal = streamsSnapshot
? markClientSnapshotStreaming(client, state.activeSessionId)
: undefined;
Expand Down Expand Up @@ -6595,7 +6629,7 @@
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;
Expand Down
14 changes: 9 additions & 5 deletions packages/coding-agent/src/modes/daemon/daemon-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
Expand Down Expand Up @@ -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";
Expand Down
7 changes: 5 additions & 2 deletions packages/coding-agent/src/modes/daemon/daemon-supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading