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
14 changes: 13 additions & 1 deletion packages/coding-agent/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,8 @@ user sends another prompt ◄─────────────────
/name or pi.setSessionName()
└─► session_info_changed

/compact or auto-compaction
/compact (manual)
├─► session_detach_foreground (best-effort; hand foreground work to a lane)
├─► session_before_compact (can cancel or customize)
├─► session_compact (success)
└─► session_compact_failed (failure or abort)
Expand Down Expand Up @@ -449,6 +450,17 @@ pi.on("session_before_fork", async (event, ctx) => {
After a successful fork or clone, step emits `session_shutdown` for the old extension instance, reloads and rebinds extensions for the new session, then emits `session_start` with `reason: "fork"` and `previousSessionFile`.
Do cleanup work in `session_shutdown`, then reestablish any in-memory state in `session_start`.

#### session_detach_foreground

Fired only for manual compaction, before it waits for the active run to settle. A host whose foreground work can continue in the background (for example a blocking `subagent` call) hands that work to a lane here and returns at once, so compaction does not wait for the work to finish. Detach is best-effort: when no handler claims the work, or the host cannot own a lane, compaction keeps its old behaviour and waits for the run. The return value is ignored.

```typescript
pi.on("session_detach_foreground", async (event, ctx) => {
// event.reason - "manual"
// Move long-running foreground work into a background lane here.
});
```

#### session_before_compact / session_compact / session_compact_failed

Fired on compaction. See [compaction.md](compaction.md) for details.
Expand Down
59 changes: 49 additions & 10 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,8 @@ export class AgentSession {

// Compaction state
private _compactionAbortController: AbortController | undefined = undefined;
/** True while a manual compaction is waiting for the current run to settle. */
private _manualCompactionPending = false;
private _autoCompactionAbortController: AbortController | undefined = undefined;
private _overflowRecoveryAttempted = false;
private _liveContext?: LiveContextManager;
Expand Down Expand Up @@ -820,6 +822,7 @@ export class AgentSession {
!settings.enabled ||
!this.settingsManager.getCompactionEnabled() ||
this._manualClm ||
this._manualCompactionPending ||
this._autoClmAbortController ||
this._blocksNativeThreshold() ||
signal?.aborted ||
Expand Down Expand Up @@ -2626,20 +2629,53 @@ export class AgentSession {
/**
* Manually compact the session context.
*
* This is the manual entry point used by `/compact`, RPC, and extensions. It is
* separate from automatic threshold/overflow compaction, which enters through
* `_checkCompaction()` and `_runAutoCompaction()`. After preparation and the
* `session_before_compact` hook, both paths call the lower-level `compact()`
* function imported from `./compaction/index.ts`, unless the hook cancels or
* supplies a custom result.
*
* Aborts the current agent operation first. Manual compaction never retries or
* continues the interrupted agent turn.
* This is the manual entry point used by `/compact`, RPC, and extensions. It
* shares the non-aborting lifecycle with automatic threshold/overflow
* compaction: it never cancels the current run. Before waiting for the run to
* settle it offers foreground work to extensions through
* `session_detach_foreground`; a host that hands its work to a background lane
* lets compaction proceed while that work keeps running. If no host claims the
* work, the request waits as before, which keeps a foreground subagent alive
* until its result can be compacted with the session.
*
* @param customInstructions Optional instructions for the compaction summary
*/
async compact(customInstructions?: string): Promise<CompactionResult> {
await this.abort();
this._manualCompactionPending = true;
try {
await this._detachForegroundWorkForCompaction();
await this.waitForIdle();
return await this._compactNow(customInstructions);
} finally {
this._manualCompactionPending = false;
}
}

/**
* Ask extensions to hand foreground work to a background lane before manual
* compaction waits for the run. Best-effort by design: a failed or absent
* handler leaves the work in the foreground, and `waitForIdle()` keeps the old
* behaviour of waiting for it to finish.
*/
private async _detachForegroundWorkForCompaction(): Promise<void> {
if (!this._extensionRunner.hasHandlers("session_detach_foreground")) return;
try {
await this._extensionRunner.emit({ type: "session_detach_foreground", reason: "manual" });
} catch {
// Detaching is an optimisation, not a precondition for compaction.
}
}

/**
* Run manual compaction once the session is idle. Automatic compaction enters
* through `_checkCompaction()` and `_runAutoCompaction()`; after preparation
* and the `session_before_compact` hook, both paths call the lower-level
* `compact()` function imported from `./compaction/index.ts`, unless the hook
* cancels or supplies a custom result.
*
* @param customInstructions Optional instructions for the compaction summary
*/
private async _compactNow(customInstructions?: string): Promise<CompactionResult> {
this._compactionAbortController = new AbortController();
this._emit({ type: "compaction_start", reason: "manual" });
let fromExtension = false;
Expand Down Expand Up @@ -2975,6 +3011,9 @@ export class AgentSession {
private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise<boolean> {
const runSignal = this._agentRunAbortController?.signal;
if (runSignal?.aborted) return false;
// A manual compaction request owns the next rewrite. Auto-compaction must not
// win the race and leave the manual request with "Already compacted".
if (this._manualCompactionPending) return false;
if (reason === "threshold" && this._blocksNativeThreshold()) return false;
const settings = this.settingsManager.getCompactionSettings();
let started = false;
Expand Down
16 changes: 15 additions & 1 deletion packages/coding-agent/src/core/extensions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,18 @@ export interface SessionTreeEvent {
fromExtension?: boolean;
}

/**
* Fired before manual compaction waits for the session to settle, so a host can
* hand its foreground work to a background lane instead of blocking compaction
* until that work finishes. Detach is best-effort: compaction still waits when
* no handler claims the work.
*/
export interface SessionDetachForegroundEvent {
type: "session_detach_foreground";
/** What triggered the detach request. Only manual compaction uses it today. */
reason: "manual";
}

export type SessionEvent =
| SessionStartEvent
| SessionInfoChangedEvent
Expand All @@ -747,7 +759,8 @@ export type SessionEvent =
| SessionCompactFailedEvent
| SessionShutdownEvent
| SessionBeforeTreeEvent
| SessionTreeEvent;
| SessionTreeEvent
| SessionDetachForegroundEvent;

// ============================================================================
// Agent Events
Expand Down Expand Up @@ -1349,6 +1362,7 @@ export interface ExtensionAPI {
on(event: "session_shutdown", handler: ExtensionHandler<SessionShutdownEvent>): void;
on(event: "session_before_tree", handler: ExtensionHandler<SessionBeforeTreeEvent, SessionBeforeTreeResult>): void;
on(event: "session_tree", handler: ExtensionHandler<SessionTreeEvent>): void;
on(event: "session_detach_foreground", handler: ExtensionHandler<SessionDetachForegroundEvent>): void;
on(event: "context", handler: ExtensionHandler<ContextEvent, ContextEventResult>): void;
on(
event: "before_provider_request",
Expand Down
146 changes: 143 additions & 3 deletions packages/coding-agent/src/features/step-subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
createLaneLifecycle,
getLiveSubagentSession,
MAX_RETAINED_FINISHED_LANES,
type StepSubagentLaneRuntime,
} from "./subagent/lane-lifecycle.ts";
import {
renderAgentNotification,
Expand Down Expand Up @@ -807,6 +808,147 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption
clearLaneWidget,
});

// Foreground calls that manual compaction may hand to a background lane.
// Keyed by tool call id so several concurrent foreground calls can each be
// detached independently; the entry is removed as soon as the call returns.
interface ForegroundSubagentRun {
toolCallId: string;
params: SubagentParams;
ctx: ExtensionContext;
controller: AbortController;
/** Child session id chosen before the run starts, so an early detach still knows it. */
sessionId: string;
details?: StepSubagentDetails;
onLaneUpdate?: (result: AgentToolResult<StepSubagentDetails>) => void;
detach?: () => BackgroundAgentLane | string | undefined;
}
const foregroundRuns = new Map<string, ForegroundSubagentRun>();
const subagentListWidgetKey = (toolCallId: string): string => `step-subagent-list:${toolCallId}`;
const clearSubagentListWidget = (ctx: ExtensionContext, toolCallId: string): void => {
if (!ctx.hasUI) return;
try {
ctx.ui.setWidget(subagentListWidgetKey(toolCallId), undefined);
} catch {
// The host may already have torn its UI down; widget cleanup is best-effort.
}
};
const foregroundDetailsFallback = (params: SubagentParams, ctx: ExtensionContext): StepSubagentDetails => {
const mode: StepSubagentDetails["mode"] = params.tasks ? "parallel" : params.chain ? "chain" : "single";
return {
mode,
agentScope: params.agentScope ?? "user",
userAgentsDir: path.join(resolved.agentDir, "agents"),
projectAgentsDir: path.join(ctx.cwd, resolved.configDirName, "agents"),
results: [],
};
};

/**
* Run a foreground subagent call so it can be detached mid-flight. The tool
* result races the real run against a detach request: when compaction asks
* for the foreground work, the running promise is adopted by a background
* lane and this call returns at once with the lane id. The child process is
* never respawned or aborted by the detach itself.
*/
const runDetachableForeground = async (
toolCallId: string,
params: SubagentParams,
signal: AbortSignal | undefined,
onUpdate: AgentToolUpdateCallback<StepSubagentDetails> | undefined,
ctx: ExtensionContext,
): Promise<AgentToolResult<StepSubagentDetails>> => {
const controller = new AbortController();
const abortFromTool = (): void => controller.abort();
if (signal) {
if (signal.aborted) controller.abort();
else signal.addEventListener("abort", abortFromTool, { once: true });
}
// Choose the child session id before the run starts. A detach can arrive
// before the first streamed update, and the lane must still reuse this
// child's transcript for later replies.
const sessionId = `${SUBAGENT_SESSION_ID_PREFIX}${randomUUID()}`;
const run: ForegroundSubagentRun = { toolCallId, params, ctx, controller, sessionId };
foregroundRuns.set(toolCallId, run);
let detached = false;
let settled = false;
let resolveDetached!: (result: AgentToolResult<StepSubagentDetails>) => void;
const detachedResult = new Promise<AgentToolResult<StepSubagentDetails>>((resolve) => {
resolveDetached = resolve;
});
const captureUpdate = (result: AgentToolResult<StepSubagentDetails>): void => {
if (!detached) onUpdate?.(result);
if (result.details) run.details = result.details;
run.onLaneUpdate?.(result);
};
const laneRuntime: StepSubagentLaneRuntime = { sessionId, execution: "blocking" };
const rawRunPromise = withSubagentListWidget(toolCallId, ctx, captureUpdate, (update) =>
executeSubagent(params, controller.signal, update, ctx, resolved, laneRuntime),
);
// Mark the call settled before the raced promise resolves, so a detach
// request that arrives after the child finished cannot adopt a dead run.
const runPromise = rawRunPromise.then(
(result) => {
settled = true;
return result;
},
(error) => {
settled = true;
throw error;
},
);
run.detach = () => {
if (detached || settled) return undefined;
// Respect the same host/config gates as `run_in_background`: a host that
// cannot own lanes keeps the foreground call and compaction waits for it.
if (backgroundUnavailableReason(ctx, options.backgroundSettings?.())) return undefined;
const lane = lifecycle.adoptForegroundRun({
params,
ctx,
controller,
details: run.details ?? foregroundDetailsFallback(params, ctx),
promise: runPromise,
sessionId: run.sessionId,
turnTimeoutMs: resolveBackgroundTimeoutMs(undefined, options.backgroundSettings?.()?.timeoutSeconds),
setUpdate: (update) => {
run.onLaneUpdate = update;
},
});
if (typeof lane === "string") return lane;
detached = true;
clearSubagentListWidget(ctx, toolCallId);
resolveDetached(
makeToolResult(
lane.details,
[
`Subagent detached to background lane ${lane.id} because manual compaction was requested.`,
"It keeps running; you will get one <agent-notification> when it finishes.",
"Do not wait for it with sleep or polling.",
].join(" "),
),
);
return lane;
};
try {
return await Promise.race([runPromise, detachedResult]);
} finally {
foregroundRuns.delete(toolCallId);
if (signal) signal.removeEventListener("abort", abortFromTool);
}
};

// Manual compaction asks for foreground work before it waits for the run.
// Detach is best-effort: a host that cannot own lanes, or a session already
// at its lane limit, leaves the call foreground and compaction waits for it.
pi.on("session_detach_foreground", async () => {
for (const run of [...foregroundRuns.values()]) {
try {
run.detach?.();
} catch {
// One failed detach must not stop the others or break compaction.
}
}
});

// Lanes belong to this session runtime. Quit, signals, /new, /resume,
// /fork, and reload all tear the runtime down through session_shutdown;
// lanes are stopped rather than carried into the next session.
Expand Down Expand Up @@ -837,9 +979,7 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption
execute: async (toolCallId, params, signal, onUpdate, ctx) => {
const callParams = params as SubagentParams;
const foreground = (runParams: SubagentParams) =>
withSubagentListWidget(toolCallId, ctx, onUpdate, (update) =>
executeSubagent(runParams, signal, update, ctx, resolved),
);
runDetachableForeground(toolCallId, runParams, signal, onUpdate, ctx);
if (!callParams.run_in_background) return foreground(callParams);

const settings = options.backgroundSettings?.();
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/src/features/subagent/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ export async function runPreparedSubagent(
const chain = mode === "chain" ? tasks : undefined;

const records = tasks.map((task) => makeEmptyResult(task.agent, task.task));
const execution = laneRuntime ? "background" : "blocking";
const execution = laneRuntime?.execution ?? (laneRuntime ? "background" : "blocking");
const createdAt = tasks.map(() => Date.now());
const createdTasks = new Set<number>();
const reportCreated = (agent: StepAgentConfig, index: number): void => {
Expand Down
Loading
Loading