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
174 changes: 101 additions & 73 deletions packages/coding-agent/src/core/agent-session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ function extractUserMessageText(content: string | Array<{ type: string; text?: s
*/
export class AgentSessionRuntime implements SubagentRuntimeHost {
private rebindSession?: (session: AgentSession) => Promise<void>;
private runtimeEnvScope?: <T>(fn: () => Promise<T>) => Promise<T>;
private beforeSessionInvalidate?: () => void;
private subagentRuntimeHost?: SubagentRuntimeHost;
private subagentRuntimes = new Map<string, AgentSessionRuntime>();
Expand Down Expand Up @@ -142,6 +143,19 @@ export class AgentSessionRuntime implements SubagentRuntimeHost {
this.rebindSession = rebindSession;
}

/**
* Host-installed scope wrapping every runtime rebuild (new/switch/fork/
* import and subagent creation), during which extensions re-load. The
* daemon uses it to apply the session's client env for load-time captures.
*/
setRuntimeEnvScope(scope?: <T>(fn: () => Promise<T>) => Promise<T>): void {
this.runtimeEnvScope = scope;
}

private scopedBuild<T>(fn: () => Promise<T>): Promise<T> {
return this.runtimeEnvScope ? this.runtimeEnvScope(fn) : fn();
}

setSubagentRuntimeHost(host?: SubagentRuntimeHost): void {
this.subagentRuntimeHost = host;
this.bindRuntimeHost();
Expand Down Expand Up @@ -260,37 +274,39 @@ export class AgentSessionRuntime implements SubagentRuntimeHost {
if (options.parentSession.sessionFile) {
sessionManager.newSession({ parentSession: options.parentSession.sessionFile });
}
const runtime = await createAgentSessionRuntime(this.createRuntime, {
cwd: sessionManager.getCwd(),
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "startup" },
sessionConfig: this.sessionConfig,
sessionOptions: {
model: options.model,
thinkingLevel: options.thinkingLevel,
scopedModels: options.scopedModels,
initialActiveToolNames: options.activeToolNames,
allowedToolNames: options.allowedToolNames,
customTools: options.customTools,
includeGoals: options.includeGoals,
rlmDepth: options.rlmDepth,
rlmMaxDepth: options.rlmMaxDepth,
rlmSessionDir: options.sessionDir,
rlmParentNodeId: options.rlmParentNodeId,
},
runtimeMetadata: {
kind: "subagent",
createdAt: Date.now(),
parentSessionId: options.parentSession.sessionId,
parentSessionFile: options.parentSession.sessionFile,
rlmChildId: options.id,
rlmParentNodeId: options.rlmParentNodeId,
prompt: options.prompt,
spawnCode: options.spawnCode,
sessionDir: options.sessionDir,
},
});
const runtime = await this.scopedBuild(() =>
createAgentSessionRuntime(this.createRuntime, {
cwd: sessionManager.getCwd(),
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "startup" },
sessionConfig: this.sessionConfig,
sessionOptions: {
model: options.model,
thinkingLevel: options.thinkingLevel,
scopedModels: options.scopedModels,
initialActiveToolNames: options.activeToolNames,
allowedToolNames: options.allowedToolNames,
customTools: options.customTools,
includeGoals: options.includeGoals,
rlmDepth: options.rlmDepth,
rlmMaxDepth: options.rlmMaxDepth,
rlmSessionDir: options.sessionDir,
rlmParentNodeId: options.rlmParentNodeId,
},
runtimeMetadata: {
kind: "subagent",
createdAt: Date.now(),
parentSessionId: options.parentSession.sessionId,
parentSessionFile: options.parentSession.sessionFile,
rlmChildId: options.id,
rlmParentNodeId: options.rlmParentNodeId,
prompt: options.prompt,
spawnCode: options.spawnCode,
sessionDir: options.sessionDir,
},
}),
);
this.subagentRuntimes.set(options.id, runtime);
try {
await runtime.session.bindExtensions({});
Expand Down Expand Up @@ -352,13 +368,15 @@ export class AgentSessionRuntime implements SubagentRuntimeHost {
assertSessionCwdExists(sessionManager, this.cwd);
await this.teardownCurrent("resume", sessionManager.getSessionFile());
this.apply(
await this.createRuntime({
cwd: sessionManager.getCwd(),
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
sessionConfig: this.sessionConfig,
}),
await this.scopedBuild(() =>
this.createRuntime({
cwd: sessionManager.getCwd(),
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
sessionConfig: this.sessionConfig,
}),
),
);
await this.finishSessionReplacement(options?.withSession);
return { cancelled: false };
Expand All @@ -383,13 +401,15 @@ export class AgentSessionRuntime implements SubagentRuntimeHost {

await this.teardownCurrent("new", sessionManager.getSessionFile());
this.apply(
await this.createRuntime({
cwd: this.cwd,
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "new", previousSessionFile },
sessionConfig: this.sessionConfig,
}),
await this.scopedBuild(() =>
this.createRuntime({
cwd: this.cwd,
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "new", previousSessionFile },
sessionConfig: this.sessionConfig,
}),
),
);
if (options?.setup) {
await options.setup(this.session.sessionManager);
Expand Down Expand Up @@ -438,13 +458,15 @@ export class AgentSessionRuntime implements SubagentRuntimeHost {
sessionManager.newSession({ parentSession: currentSessionFile });
await this.teardownCurrent("fork", sessionManager.getSessionFile());
this.apply(
await this.createRuntime({
cwd: this.cwd,
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
sessionConfig: this.sessionConfig,
}),
await this.scopedBuild(() =>
this.createRuntime({
cwd: this.cwd,
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
sessionConfig: this.sessionConfig,
}),
),
);
await this.finishSessionReplacement(options?.withSession);
return { cancelled: false, selectedText };
Expand All @@ -458,13 +480,15 @@ export class AgentSessionRuntime implements SubagentRuntimeHost {
const sessionManager = SessionManager.open(forkedSessionPath, sessionDir);
await this.teardownCurrent("fork", sessionManager.getSessionFile());
this.apply(
await this.createRuntime({
cwd: sessionManager.getCwd(),
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
sessionConfig: this.sessionConfig,
}),
await this.scopedBuild(() =>
this.createRuntime({
cwd: sessionManager.getCwd(),
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
sessionConfig: this.sessionConfig,
}),
),
);
await this.finishSessionReplacement(options?.withSession);
return { cancelled: false, selectedText };
Expand All @@ -478,13 +502,15 @@ export class AgentSessionRuntime implements SubagentRuntimeHost {
}
await this.teardownCurrent("fork", sessionManager.getSessionFile());
this.apply(
await this.createRuntime({
cwd: this.cwd,
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
sessionConfig: this.sessionConfig,
}),
await this.scopedBuild(() =>
this.createRuntime({
cwd: this.cwd,
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
sessionConfig: this.sessionConfig,
}),
),
);
await this.finishSessionReplacement(options?.withSession);
return { cancelled: false, selectedText };
Expand Down Expand Up @@ -523,13 +549,15 @@ export class AgentSessionRuntime implements SubagentRuntimeHost {
assertSessionCwdExists(sessionManager, this.cwd);
await this.teardownCurrent("resume", sessionManager.getSessionFile());
this.apply(
await this.createRuntime({
cwd: sessionManager.getCwd(),
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
sessionConfig: this.sessionConfig,
}),
await this.scopedBuild(() =>
this.createRuntime({
cwd: sessionManager.getCwd(),
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
sessionConfig: this.sessionConfig,
}),
),
);
await this.finishSessionReplacement();
return { cancelled: false };
Expand Down
18 changes: 18 additions & 0 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ export class AgentSession {

// Extension system
private _extensionRunner!: ExtensionRunner;
private _execEnvProvider?: () => Record<string, string | undefined> | undefined;
private _turnIndex = 0;

private _resourceLoader: ResourceLoader;
Expand Down Expand Up @@ -4213,6 +4214,17 @@ export class AgentSession {
return this.settingsManager.getCompactionEnabled();
}

/**
* Set the provider for extra env vars merged over process.env in extension
* pi.exec() subprocesses. The function is read at exec time, so a host (e.g.
* the daemon) can update the underlying value per attach without rebinding.
*/
setExecEnvProvider(provider: (() => Record<string, string | undefined> | undefined) | undefined): void {
this._execEnvProvider = provider;
const extensions = this._resourceLoader.getExtensions();
extensions.runtime.getExecEnv = provider;
}

async bindExtensions(bindings: ExtensionBindings): Promise<void> {
if (bindings.uiContext !== undefined) {
this._extensionUIContext = bindings.uiContext;
Expand Down Expand Up @@ -4561,6 +4573,12 @@ export class AgentSession {
extensionsResult.runtime.flagValues.set(name, value);
}
}
// Re-apply on (re)build so the provider survives /reload. Guarded: the
// runtime object can be shared across sessions from one ResourceLoader
// (RLM children), so a provider-less session must not wipe the owner's.
if (this._execEnvProvider) {
extensionsResult.runtime.getExecEnv = this._execEnvProvider;
}

this._extensionRunner = new ExtensionRunner(
extensionsResult.extensions,
Expand Down
23 changes: 23 additions & 0 deletions packages/coding-agent/src/core/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export interface ExecOptions {
timeout?: number;
/** Working directory */
cwd?: string;
/**
* Extra env vars merged over the parent process env for this command.
* A key with an undefined value is unset in the child.
*/
env?: Record<string, string | undefined>;
}

/**
Expand All @@ -27,6 +32,21 @@ export interface ExecResult {
killed: boolean;
}

function mergeExecEnv(env?: Record<string, string | undefined>): NodeJS.ProcessEnv | undefined {
if (!env) {
return undefined;
}
const merged: NodeJS.ProcessEnv = { ...process.env };
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
delete merged[key];
} else {
merged[key] = value;
}
}
return merged;
}

/**
* Execute a shell command and return stdout/stderr/code.
* Supports timeout and abort signal.
Expand All @@ -42,6 +62,9 @@ export async function execCommand(
cwd,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
// Merge per-call env over the parent env so callers can scope vars
// (e.g. herdr pane identity) without mutating the shared process.env.
env: mergeExecEnv(options?.env),
});

let stdout = "";
Expand Down
6 changes: 5 additions & 1 deletion packages/coding-agent/src/core/extensions/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,11 @@ function createExtensionAPI(

exec(command: string, args: string[], options?: ExecOptions) {
runtime.assertActive();
return execCommand(command, args, options?.cwd ?? cwd, options);
// Read the host-supplied env at call time so per-session vars (e.g.
// herdr pane identity) are current, then let an explicit options.env win.
const sessionEnv = runtime.getExecEnv?.();
const env = sessionEnv || options?.env ? { ...sessionEnv, ...options?.env } : undefined;
return execCommand(command, args, options?.cwd ?? cwd, { ...options, env });
},

getActiveTools(): string[] {
Expand Down
8 changes: 8 additions & 0 deletions packages/coding-agent/src/core/extensions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1376,6 +1376,14 @@ export type SetLabelHandler = (entryId: string, label: string | undefined) => vo
*/
export interface ExtensionRuntimeState {
flagValues: Map<string, boolean | string>;
/**
* Extra env vars merged over process.env for pi.exec() subprocesses (an
* undefined value unsets the key in the child). Read at call time (not load
* time) so a host can scope per-session vars — e.g. the daemon supplying
* each session's herdr pane identity — without mutating the shared
* process.env. Returns undefined to use the parent env unchanged.
*/
getExecEnv?: () => Record<string, string | undefined> | undefined;
/** Provider registrations queued during extension loading, processed when runner binds */
pendingProviderRegistrations: Array<{ name: string; config: ProviderConfig; extensionPath: string }>;
/** Throws when this extension instance is stale after runtime replacement. */
Expand Down
3 changes: 3 additions & 0 deletions packages/coding-agent/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { SessionManager } from "./core/session-manager.js";
import { SettingsManager } from "./core/settings-manager.js";
import { printTimings, resetTimings, time } from "./core/timings.js";
import { runMigrations, showDeprecationWarnings } from "./migrations.js";
import { collectDaemonClientEnv } from "./modes/daemon/daemon-protocol.js";
import {
type AgentConnection,
createInteractiveModeLocalSessionHost,
Expand Down Expand Up @@ -950,6 +951,7 @@ async function createDaemonInteractiveConnection(options: {
const attach = async (summary: SessionSummary) => {
const connection = await DaemonAgentConnection.attach(client, getDaemonSummaryActiveSessionId(summary), {
closeClientOnDispose: true,
sendClientEnv: true,
});
return { connection, summary };
};
Expand All @@ -974,6 +976,7 @@ async function createDaemonInteractiveConnection(options: {
config: options.config,
sessionPath: options.sessionPath,
continueRecent: options.continueRecent,
env: collectDaemonClientEnv(),
Comment thread
cursor[bot] marked this conversation as resolved.
});
if (!response.success) {
throw new Error(response.error);
Expand Down
Loading