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
77 changes: 77 additions & 0 deletions packages/core/src/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1499,4 +1499,81 @@ describe('runAgent', () => {
expect(resultText(result.history)).toBe('fast enough');
});
});

describe('persistent shells', () => {
it('closes every shell it opened, even when the loop throws', async () => {
// A crashed run leaving live shell processes on the machine is the
// objection this capability has to answer, so the guarantee cannot rest
// on the loop reaching its normal exit.
let seen: import('./shell/registry.js').ShellRegistry | undefined;
const grab: ToolHandler = {
name: 'Grab',
definition: {
name: 'Grab',
description: 'captures the registry then explodes the run',
inputSchema: { type: 'object', properties: {} },
},
async execute(_input, toolCtx) {
seen = toolCtx.shells;
await toolCtx.shells?.open({ cwd });
return { content: 'grabbed' };
},
};

const exploding: Provider = {
name: 'exploding',
runTurn: (() => {
let first = true;
return () => {
if (first) {
first = false;
return Promise.resolve(
toolUse('grabbing', {
type: 'tool_use',
id: 'c1',
name: 'Grab',
input: {},
}),
);
}
// Not an AbortError, so the loop does not treat it as cancellation.
throw Object.assign(new Error('provider exploded'), { fatal: true });
};
})(),
};

await runAgent({
provider: exploding,
tools: new ToolRegistry([grab]),
systemPrompt: '',
userMessage: 'go',
model: 'deepseek-chat',
cwd,
});

expect(seen).toBeDefined();
expect(seen?.list()).toEqual([]);
});

it('leaves a host-owned registry alone', async () => {
// The host closes what the host owns; shells must survive between runs
// for a REPL session to be worth anything.
const { ShellRegistry } = await import('./shell/registry.js');
const shells = new ShellRegistry();
await shells.open({ cwd });

await runAgent({
provider: new MockProvider([endTurn('done')]),
tools: new ToolRegistry(),
systemPrompt: '',
userMessage: 'go',
model: 'deepseek-chat',
cwd,
shells,
});

expect(shells.list()).toHaveLength(1);
await shells.closeAll();
});
});
});
31 changes: 31 additions & 0 deletions packages/core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { Mode } from './types.js';
import type { Provider } from './providers/types.js';
import { resolveRuntimePolicy } from './runtime/policy.js';
import { applySpillPolicy, type SpillStore } from './spill/index.js';
import { ShellRegistry } from './shell/registry.js';
// NOTE: reminders + sessions are lazy-loaded inside the loop so a browser
// build (Tauri renderer) that doesn't use them avoids pulling node:fs at
// module-load time. See `loadRemindersIfEnabled` and `appendSessionIfSet`.
Expand Down Expand Up @@ -141,6 +142,13 @@ export interface RunAgentOptions {
/** Installed-plugin directories — so the Task tool can resolve plugin-bundled
* sub-agents (`<dir>/agents/*.md`) in addition to user/project ones. */
pluginDirs?: string[];
/**
* Host-owned registry for shells that outlive one tool call. When set, shells
* survive across runs and the host is responsible for closing them. When
* absent, this run owns one and closes every shell before it returns, so no
* shell can outlive the run that opened it.
*/
shells?: ShellRegistry;
/** Optional host-owned background-task manager (e.g. the REPL's session-scoped
* one). When set, this run attaches its sub-agent runner to it and exposes it
* on the tool context, so background tasks persist across runAgent calls and
Expand Down Expand Up @@ -241,7 +249,28 @@ const READ_ONLY_TOOLS = new Set([
* Runs the agent loop until the model produces an end_turn (no tool calls),
* or `maxTurns` is reached, or the abort signal fires.
*/
/**
* Run the agent loop.
*
* When the caller supplies no {@link RunAgentOptions.shells}, this run owns a
* registry and closes every shell it opened before returning — including when
* the loop throws. Leaving live shell processes behind after a crashed run is
* the failure this wrapper exists to make impossible.
*
* @param opts Everything the loop needs.
* @returns The final history, usage, and stop reason.
*/
export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
if (opts.shells) return runAgentInner(opts);
const shells = new ShellRegistry();
try {
return await runAgentInner({ ...opts, shells });
} finally {
await shells.closeAll();
}
}

async function runAgentInner(opts: RunAgentOptions): Promise<RunAgentResult> {
const maxTurns = opts.maxTurns ?? DEFAULT_MAX_TURNS;
const runtimePolicy = resolveRuntimePolicy(opts);
const allowedToolNames = opts.allowedTools ? new Set(opts.allowedTools) : undefined;
Expand Down Expand Up @@ -544,6 +573,8 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
}
}

toolCtx.shells = opts.shells;

const totalUsage = { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0 };
let turnsUsed = 0;

Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export {
TodoWriteTool,
SessionSearchTool,
SessionReadTool,
SHELL_TOOLS,
WebFetchTool,
WebSearchTool,
AskUserQuestionTool,
Expand Down Expand Up @@ -252,6 +253,15 @@ export {
type RepeatReminderKind,
type ToolDeadlineConfig,
} from './guard/index.js';
// Persistent shells
export {
PersistentShell,
ShellRegistry,
type ShellInfo,
type ShellRegistryOptions,
type ShellRunResult,
type ShellSessionOptions,
} from './shell/index.js';

// Agent loop's approval callback type (M3b)
export type { ApprovalCallback, ApprovalDecision } from './agent.js';
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/shell/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Shells that survive between tool calls.
// Spec: docs/DSH_ADOPTION_PLAN.md §1.4

export { PersistentShell, type ShellRunResult, type ShellSessionOptions } from './session.js';
export { ShellRegistry, type ShellInfo, type ShellRegistryOptions } from './registry.js';
155 changes: 155 additions & 0 deletions packages/core/src/shell/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Owner of every open shell, and the thing that guarantees none outlive their run.
// Spec: docs/DSH_ADOPTION_PLAN.md §1.4
//
// A long-lived process is a leak waiting to happen. Two rules keep it bounded:
// the registry closes everything when its owner ends, and an idle shell closes
// itself. Neither is optional — a crashed session leaving orphaned shells on the
// machine is exactly the objection this capability has to answer.

import { PersistentShell, type ShellSessionOptions } from './session.js';

/** An open shell and what a caller is allowed to know about it. */
export interface ShellInfo {
id: string;
/** Directory it started in. */
cwd: string;
/** ISO timestamp of the last command run in it. */
lastUsedAt: string;
busy: boolean;
}

export interface ShellRegistryOptions {
/** Close a shell after this long without a command. Defaults to 30 minutes. */
idleTimeoutMs?: number;
/** Maximum shells open at once. Defaults to 8. */
maxShells?: number;
}

const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60_000;
const DEFAULT_MAX_SHELLS = 8;

interface Entry {
shell: PersistentShell;
cwd: string;
lastUsedAt: number;
timer: ReturnType<typeof setTimeout>;
}

/** Every shell open for one agent session. */
export class ShellRegistry {
readonly #entries = new Map<string, Entry>();
readonly #idleTimeoutMs: number;
readonly #maxShells: number;
#seq = 0;

/**
* @param opts Idle timeout and concurrency cap.
*/
constructor(opts: ShellRegistryOptions = {}) {
this.#idleTimeoutMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
this.#maxShells = opts.maxShells ?? DEFAULT_MAX_SHELLS;
}

/**
* Open a shell.
*
* @param opts Where to start it and under what confinement.
* @returns The new shell's id.
* @throws When the concurrency cap is already reached.
*/
async open(opts: ShellSessionOptions): Promise<string> {
this.#reap();
if (this.#entries.size >= this.#maxShells) {
throw new Error(
`Too many shells open (${this.#entries.size}/${this.#maxShells}). Close one with ShellClose first.`,
);
}
const id = `shell-${++this.#seq}`;
const shell = await PersistentShell.open(opts);
this.#entries.set(id, {
shell,
cwd: opts.cwd,
lastUsedAt: Date.now(),
timer: this.#armIdle(id),
});
return id;
}

/**
* Look up a live shell.
*
* @param id Shell id.
* @returns The shell, or undefined when it is unknown or already gone.
*/
get(id: string): PersistentShell | undefined {
const entry = this.#entries.get(id);
if (!entry) return undefined;
if (entry.shell.closed) {
this.#forget(id);
return undefined;
}
return entry.shell;
}

/** Record activity on a shell and restart its idle countdown. */
touch(id: string): void {
const entry = this.#entries.get(id);
if (!entry) return;
entry.lastUsedAt = Date.now();
clearTimeout(entry.timer);
entry.timer = this.#armIdle(id);
}

/** Every open shell, oldest first. */
list(): ShellInfo[] {
this.#reap();
return [...this.#entries.entries()].map(([id, e]) => ({
id,
cwd: e.cwd,
lastUsedAt: new Date(e.lastUsedAt).toISOString(),
busy: e.shell.busy,
}));
}

/**
* Close one shell.
*
* @param id Shell id.
* @returns True when a shell was closed, false when the id was unknown.
*/
async close(id: string): Promise<boolean> {
const entry = this.#entries.get(id);
if (!entry) return false;
this.#forget(id);
await entry.shell.close();
return true;
}

/** Close everything. Called when the owning run or session ends. */
async closeAll(): Promise<void> {
const entries = [...this.#entries.keys()];
await Promise.all(entries.map((id) => this.close(id)));
}

#armIdle(id: string): ReturnType<typeof setTimeout> {
const timer = setTimeout(() => {
void this.close(id);
}, this.#idleTimeoutMs);
// An idle shell must not be the reason the process stays alive.
timer.unref?.();
return timer;
}

#forget(id: string): void {
const entry = this.#entries.get(id);
if (entry) clearTimeout(entry.timer);
this.#entries.delete(id);
}

/** Drop entries whose shell exited on its own. */
#reap(): void {
for (const [id, entry] of this.#entries) {
if (entry.shell.closed) this.#forget(id);
}
}
}
Loading
Loading