Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

1297 lines
42 KiB
TypeScript

import type {
Options as ClaudeQueryOptions,
PermissionResult,
SDKMessage,
SDKUserMessage
} from "@anthropic-ai/claude-agent-sdk";
import {
numberValue,
recordValue,
sdkContentFromPromptBlocks
} from "./normalizer.ts";
import {
claudeQueryOptionOverrides,
type SidecarClaudeOptions
} from "./options.ts";
import {
QueryGeneration,
type ClaudeQueryRuntime,
type QueryShutdownOptions
} from "./queryGeneration.ts";
import { queryGenerationHooks } from "./queryHooks.ts";
import { errorMessage, errorPayload } from "./errors.ts";
import { AssistantStreamProjector } from "./assistantStream.ts";
import {
InteractiveCoordinator,
type ToolPermissionOptions
} from "./interactive.ts";
import { resolveInteractiveTurnId } from "./interactiveTurnResolver.ts";
import {
GoalExecQueue,
type GoalCommandDispatch,
type GoalExecInput
} from "./goalExecQueue.ts";
import { TurnLifecycle, type RuntimeTurn } from "./turnLifecycle.ts";
import { normalizeTitle, stringValue } from "./runtimeValues.ts";
import {
abortError,
isAbortError,
isCompactCommandPrompt,
normalizeResumeCursor
} from "./sdkMessages.ts";
import { ToolActivityProjector } from "./toolActivity.ts";
import { SidecarTestDriver } from "./testDriver.ts";
import { SessionConfiguration } from "./sessionConfiguration.ts";
import { resolveClaudeCodeExecutablePath } from "./executablePath.ts";
import { claudeSettingsEnv } from "./settingsEnv.ts";
import { CompactionTracker } from "./compaction.ts";
import { MessageProjection } from "./messageProjection.ts";
import { SDKMessageRouter } from "./messageRouter.ts";
import { claudeGoalTranscriptPath } from "./goalTranscript.ts";
import { emit } from "./eventSink.ts";
import {
resolveClaudeTurnBindingByRecoveryToken,
type ClaudeTurnBindingResolver
} from "./sessionFork.ts";
import {
ProviderTurnAcceptanceCoordinator,
type ProviderTurnPhase
} from "./providerTurnAcceptance.ts";
import { ClaudeSessionDiagnostics } from "./sessionDiagnostics.ts";
import {
canBypassPermissions,
effectivePermissionMode,
modelOptionValue,
querySettingsFromSessionSettings,
type SidecarSessionSettings
} from "./sessionSettings.ts";
type ClaudeQueryFactory = (input: {
prompt: AsyncIterable<SDKUserMessage>;
options: ClaudeQueryOptions;
}) => ClaudeQueryRuntime;
export type SessionCancelDisposition =
| "pre_accept"
| "provider_active"
| "provider_state_lost"
| "absent"
| "mismatch";
export type SessionCancelResult = {
canceled: boolean;
disposition: SessionCancelDisposition;
turnId: string;
providerTurnId: string;
dispatchPhase: ProviderTurnPhase | "pending_goal" | "unknown";
};
function cancelResult(
canceled: boolean,
disposition: SessionCancelDisposition,
turnId: string,
providerTurnId = "",
dispatchPhase: SessionCancelResult["dispatchPhase"] = "unknown"
): SessionCancelResult {
return {
canceled,
disposition,
turnId: turnId.trim(),
providerTurnId: providerTurnId.trim(),
dispatchPhase
};
}
export class SessionRuntime {
providerSessionId: string;
get activeTurnId(): string {
return this.turns.activeId;
}
private readonly cwd: string;
private readonly env: Record<string, string | undefined>;
private readonly restore: boolean;
private readonly turns: TurnLifecycle;
private readonly assistantStream: AssistantStreamProjector;
private readonly activities: ToolActivityProjector;
private readonly compaction: CompactionTracker;
private readonly projection: MessageProjection;
private initialized = false;
private sessionClosed = false;
private executionEpoch = 0;
private nextQueryGenerationId = 0;
private queryGeneration: QueryGeneration | undefined;
private resumeQueries: boolean;
private canceledQueryTailPending = false;
private lastTitle = "";
private lastAssistantUuid = "";
private resumeCursor: Record<string, unknown> | undefined;
private readonly configuration: SessionConfiguration;
/**
* Serializes live apply_settings with turn-dispatch applyPendingFlags so a
* settings write and the next send cannot interleave on the same Claude
* query (half-applied flags + retired idle generation → submitted silence).
*/
private configurationGate: Promise<void> = Promise.resolve();
private readonly claudeOptions: SidecarClaudeOptions;
private readonly queryFactory?: ClaudeQueryFactory;
private readonly interactions: InteractiveCoordinator;
private readonly router: SDKMessageRouter;
/** Set by guide() before interrupt; consumed by the next error result. */
private pendingGuidanceInterrupt = false;
private readonly driver?: SidecarTestDriver;
private readonly goalExecQueue: GoalExecQueue;
private readonly providerTurnAcceptance: ProviderTurnAcceptanceCoordinator;
private readonly diagnostics: ClaudeSessionDiagnostics;
private readonly emittedProviderCheckpoints = new Set<string>();
private readonly acceptedProviderTurnIds = new Set<string>();
get query(): ClaudeQueryRuntime | undefined {
return this.queryGeneration?.query;
}
constructor(
providerSessionId: string,
cwd: string,
env: Record<string, string | undefined>,
restore: boolean,
testDriver: boolean,
settings: SidecarSessionSettings,
claudeOptions: SidecarClaudeOptions,
resumeCursor?: Record<string, unknown>,
queryFactory?: ClaudeQueryFactory,
continuationStartTimeoutMs = 30_000,
resolveProviderTurnIdentity: ClaudeTurnBindingResolver = resolveClaudeTurnBindingByRecoveryToken,
providerTurnIdentityResolutionTimeoutMs = 5_000
) {
const resumeSessionId = stringValue(resumeCursor?.resume);
this.providerSessionId = resumeSessionId || providerSessionId;
this.cwd = cwd;
this.env = env;
this.restore = restore || resumeSessionId !== "";
this.resumeQueries = this.restore;
this.turns = new TurnLifecycle({
emit,
onActivate: () => this.resetTurnScratch(),
onSyntheticActivate: (turnId) =>
this.queryGeneration?.registerTurn(turnId),
onSettled: (turnId) => {
this.acceptedProviderTurnIds.delete(turnId.trim());
this.providerTurnAcceptance.terminal(turnId);
this.emitSessionState();
},
onProviderTurnIdentityBound: (turnId) => {
const normalizedTurnId = turnId.trim();
if (!normalizedTurnId) {
return;
}
this.acceptedProviderTurnIds.add(normalizedTurnId);
while (this.acceptedProviderTurnIds.size > 64) {
const oldest = this.acceptedProviderTurnIds.values().next().value;
if (typeof oldest !== "string") {
break;
}
this.acceptedProviderTurnIds.delete(oldest);
}
},
continuationStartTimeoutMs,
onContinuationStartTimeout: () => {
this.activities.clearBackgroundContinuation();
void this.query?.interrupt?.().catch((error) => {
emit({
type: "error",
payload: {
error: `Claude SDK continuation interrupt failed: ${errorMessage(error)}`
}
});
});
}
});
this.providerTurnAcceptance = new ProviderTurnAcceptanceCoordinator({
cwd: this.cwd,
getProviderSessionId: () => this.providerSessionId,
resolveTarget: () => {
const turn =
this.turns.activeTurn ??
this.turns.queue.find(
(candidate) =>
!candidate.settled &&
!candidate.synthetic &&
candidate.awaitingProviderTurnIdentity === true
);
if (!turn || turn.synthetic) {
return undefined;
}
return {
turnId: turn.turnId,
promptCorrelationId: turn.promptUuid,
providerTurnId: turn.providerTurnId?.trim() ?? ""
};
},
resolveBinding: resolveProviderTurnIdentity,
bindIdentity: (turnId, providerTurnId) =>
this.turns.bindProviderTurnIdentity(turnId, providerTurnId),
emitCheckpoint: (turnId, binding) =>
this.emitProviderCheckpoint(
turnId,
binding.providerTurnId,
binding.providerCheckpointMessageId
),
resolutionTimeoutMs: providerTurnIdentityResolutionTimeoutMs
});
this.diagnostics = new ClaudeSessionDiagnostics({
cwd: this.cwd,
providerSessionId: () => this.providerSessionId,
turns: () => this.turns.queue,
phaseForTurn: (turnId) => this.providerTurnAcceptance.phase(turnId)
});
this.assistantStream = new AssistantStreamProjector(
() => this.turns.activeId,
emit
);
this.activities = new ToolActivityProjector(
() => this.turns.activeId,
emit,
() => this.turns.expectSyntheticContinuation(),
() => this.turns.lastTurnId
);
this.compaction = new CompactionTracker({
activeTurnId: () => this.turns.activeId,
ensureActive: (messageType) => {
this.turns.ensureActive(messageType);
},
clearPendingOrphans: () => this.turns.clearPendingOrphans(),
getQuery: () => this.query,
getModel: () => settings.model,
emit
});
this.projection = new MessageProjection({
providerSessionId: () => this.providerSessionId,
turns: this.turns,
assistant: this.assistantStream,
activities: this.activities,
compaction: this.compaction,
emit
});
this.configuration = new SessionConfiguration({
settings,
getQuery: () => this.query,
testDriver,
isInitialized: () => this.initialized,
markInitialized: () => {
this.initialized = true;
},
emitFastModeState: (state) => this.projection.emitFastModeState(state)
});
this.interactions = new InteractiveCoordinator({
settings,
resolveTurnId: (options) =>
resolveInteractiveTurnId(options, this.turns, this.activities),
activateSyntheticTurn: () => this.turns.activateSynthetic().turnId,
ensureProviderTurnAcceptance: (phase, signal) =>
this.ensureProviderTurnAcceptance(phase, signal),
emit
});
this.driver = testDriver
? new SidecarTestDriver(this.turns, this.interactions)
: undefined;
this.goalExecQueue = new GoalExecQueue((input) =>
this.dispatchExec(
input.turnId,
input.promptCorrelationId,
input.prompt,
input.content,
input.turnOrigin,
input.goal,
input.hostContext
)
);
this.router = new SDKMessageRouter({
getProviderSessionId: () => this.providerSessionId,
setProviderSessionId: (value) => {
this.providerSessionId = value;
},
onAssistantUuid: (value) => {
this.lastAssistantUuid = value;
},
onRuntimeModel: (value) => {
if (this.configuration.applyRuntimeModel(value)) {
this.emitSessionState();
}
},
onSessionState: () => this.emitSessionState(),
onMaybeTitle: (shouldEmit) =>
this.maybeEmitSessionTitleUpdated(shouldEmit),
onTerminalConnectionError: () =>
QueryGeneration.retire(this.queryGeneration, () => {
this.executionEpoch += 1;
this.turns.failQueuedTurns("Claude SDK connection lost");
this.queryGeneration = undefined;
}),
turns: this.turns,
assistant: this.assistantStream,
activities: this.activities,
projection: this.projection,
compaction: this.compaction,
emit,
emitProviderCheckpoint: (
turnId,
providerTurnId,
providerCheckpointMessageId
) =>
this.emitProviderCheckpoint(
turnId,
providerTurnId,
providerCheckpointMessageId
),
ensureProviderTurnAcceptance: (phase) =>
this.ensureProviderTurnAcceptance(phase),
peekGuidanceInterrupt: () => this.pendingGuidanceInterrupt,
clearGuidanceInterrupt: () => {
this.pendingGuidanceInterrupt = false;
},
resolveGoalTranscriptPath: (sessionId, providerCwd) =>
claudeGoalTranscriptPath({
sessionId,
cwd: providerCwd.trim() || this.cwd || process.cwd(),
env: {
...process.env,
...claudeSettingsEnv(this.cwd || process.cwd()),
...this.env
}
})
});
this.claudeOptions = claudeOptions;
this.queryFactory = queryFactory;
this.resumeCursor = normalizeResumeCursor(
resumeCursor,
this.providerSessionId
);
this.lastAssistantUuid = stringValue(this.resumeCursor?.resumeSessionAt);
this.turns.restoreTurnCount(numberValue(this.resumeCursor?.turnCount));
}
async start(): Promise<void> {
this.diagnostics.logAuthRefresh("session_start.begin", {
restore: this.restore,
initialized: this.initialized,
queryClosed: this.sessionClosed
});
if (this.restore) {
await this.router.observeRootSession(this.providerSessionId, this.cwd);
}
await this.ensureQuery({ initialize: true });
await this.runConfigurationTask(() =>
this.configuration.applyPendingFlags()
);
emit({
type: "session_started",
payload: {
providerSessionId: this.providerSessionId,
...this.sessionStatePayload(),
resumeCursor: this.currentResumeCursor()
}
});
// Restore must not await getContextUsage before session_started: the SDK
// call can hang for minutes with no timeout, and Host Resume/Send blocks
// on this start handshake. Refresh usage in the background like turn
// completion already does.
if (this.restore) {
void this.compaction.emitContextUsageSnapshot("");
}
const generation = this.queryGeneration;
if (generation && this.isQueryGenerationActive(generation)) {
// The SDK publishes its per-user resolved model in system/init before it
// needs the first prompt. Keep the iterator alive from session start so
// Default never has to be inferred from the advertised catalog label.
this.consume(generation);
}
this.diagnostics.logAuthRefresh("session_start.succeeded", {
restore: this.restore,
initialized: this.initialized,
queryClosed: this.sessionClosed
});
}
exec(
turnId: string,
prompt: string,
content?: unknown,
turnOrigin?: string,
goal?: GoalCommandDispatch,
hostContext = "",
promptCorrelationId = ""
): void {
if (this.driver) {
this.driver.exec(turnId, prompt, promptCorrelationId);
return;
}
if (this.sessionClosed) {
emit({
type: "turn_failed",
payload: {
turnId,
error: "Claude SDK query is closed"
}
});
return;
}
if (goal?.operationId && goal.revision > 0) {
this.goalExecQueue.accept({
turnId,
promptCorrelationId,
prompt,
content,
turnOrigin,
hostContext,
goal
});
return;
}
this.dispatchExec(
turnId,
promptCorrelationId,
prompt,
content,
turnOrigin,
undefined,
hostContext
);
}
private dispatchExec(
turnId: string,
promptCorrelationId: string | undefined,
prompt: string,
content?: unknown,
turnOrigin?: string,
goal?: GoalCommandDispatch,
hostContext = ""
): void {
this.turns.closeSyntheticBeforeUserTurn();
const turn: RuntimeTurn = {
turnId,
promptUuid: promptCorrelationId?.trim() || crypto.randomUUID(),
...(turnOrigin ? { origin: turnOrigin } : {}),
...(goal
? {
goalOperationId: goal.operationId,
goalRevision: goal.revision,
goalRepairEpoch: goal.repairEpoch ?? 0,
goalAction: goal.action
}
: {}),
settled: false
};
this.providerTurnAcceptance.markQueued(turnId);
// Continue/follow-up after a settled turn must not reuse a quiet Claude
// query that accepted the next prompt but emitted no further SDK messages.
// Retire the idle generation here (before enqueue) so ensureQuery resumes,
// while still leaving the previous generation alive long enough for late
// post-result traffic such as compact_boundary.
this.retireIdleQueryBeforeNextTurn();
const executionEpoch = this.executionEpoch;
this.turns.enqueue(turn);
this.compaction.selectCommand(turnId, isCompactCommandPrompt(prompt));
void this.ensureQuery()
.then(() =>
this.runConfigurationTask(() => this.configuration.applyPendingFlags())
)
.then(() => {
const generation = this.queryGeneration;
if (
!generation ||
!this.isQueryGenerationActive(generation) ||
executionEpoch !== this.executionEpoch ||
turn.canceling ||
turn.settled
) {
return;
}
generation.registerTurn(turn.turnId);
const sdkContent = sdkContentFromPromptBlocks(
content,
prompt
) as unknown as SDKUserMessage["message"]["content"];
let outboundContent = sdkContent;
if (hostContext.trim()) {
const hostContextBlock = {
type: "text" as const,
text: hostContext.trim()
};
if (Array.isArray(sdkContent)) {
sdkContent.unshift(hostContextBlock);
} else {
outboundContent = [
hostContextBlock,
{ type: "text" as const, text: sdkContent }
];
}
}
generation.expectPromptEcho(turn.promptUuid);
this.turns.expectProviderTurnIdentity(turn.turnId);
this.providerTurnAcceptance.markDispatched(turn.turnId);
if (goal) {
this.router.trackGoalCommand(goal.action, prompt);
}
generation.promptQueue.push({
uuid: turn.promptUuid,
type: "user",
session_id: this.providerSessionId,
parent_tool_use_id: null,
message: {
role: "user",
content: outboundContent
}
} as SDKUserMessage);
this.diagnostics.scheduleProviderTurnIdentityWarning(
turn,
generation.id
);
this.consume(generation);
})
.catch((error) => {
if (executionEpoch !== this.executionEpoch || turn.settled) {
return;
}
this.diagnostics.logAuthRefresh("exec.ensure_query_failed", {
turnId,
error: errorPayload(error)
});
emit({
type: "turn_failed",
payload: {
turnId,
error: errorMessage(error)
}
});
});
}
async guide(prompt: string, content?: unknown): Promise<void> {
if (this.driver) {
this.driver.guide(prompt);
return;
}
if (this.sessionClosed) {
throw new Error("Claude SDK query is closed");
}
const executionEpoch = this.executionEpoch;
try {
const generation = this.queryGeneration;
if (
!generation ||
!this.isQueryGenerationActive(generation) ||
executionEpoch !== this.executionEpoch
) {
throw new Error("Claude SDK query changed before guidance preemption");
}
const turnId = this.turns.activeId.trim();
if (!turnId) {
throw new Error("Claude SDK guidance requires an active turn");
}
const interrupt = generation.query?.interrupt;
if (typeof interrupt !== "function") {
throw new Error(
"Claude SDK query does not support guidance preemption"
);
}
// Preempt in-flight tool work before enqueueing guidance. Without
// interrupt, Bash/etc. finish first and the model often ignores the
// inserted prompt while the old response remains active.
// Mark the interrupt so the following error_during_execution result
// keeps this turn alive for the guided prompt (see messageRouter).
this.pendingGuidanceInterrupt = true;
try {
// Calling the SDK method happens before this async function first
// yields. Guidance must not wait behind query setup or configuration
// work while the current provider response keeps running.
await interrupt.call(generation.query);
} catch (error) {
this.pendingGuidanceInterrupt = false;
this.diagnostics.logAuthRefresh("guide.interrupt_failed", {
error: errorPayload(error)
});
throw new Error(
`Claude SDK guidance preemption failed: ${errorMessage(error)}`,
{ cause: error }
);
}
if (
!this.isQueryGenerationActive(generation) ||
executionEpoch !== this.executionEpoch
) {
this.pendingGuidanceInterrupt = false;
throw new Error("Claude SDK query changed during guidance preemption");
}
// Publish the response boundary as soon as the SDK confirms the
// interrupt, before the guided prompt is enqueued. The later
// error_during_execution result is only a provider bookkeeping event.
emit({
type: "guidance_interrupted",
payload: { turnId }
});
const sdkContent = sdkContentFromPromptBlocks(
content,
prompt
) as unknown as SDKUserMessage["message"]["content"];
try {
generation.promptQueue.push({
uuid: crypto.randomUUID(),
type: "user",
session_id: this.providerSessionId,
parent_tool_use_id: null,
message: {
role: "user",
content: sdkContent
}
} as SDKUserMessage);
this.consume(generation);
} catch (error) {
this.pendingGuidanceInterrupt = false;
throw error;
}
} catch (error) {
this.diagnostics.logAuthRefresh("guide.failed", {
error: errorPayload(error)
});
throw error;
}
}
async cancel(
expectedTurnId = "",
shutdownOptions: QueryShutdownOptions = {}
): Promise<SessionCancelResult> {
expectedTurnId =
expectedTurnId.trim() || this.turns.activeTurn?.turnId.trim() || "";
if (this.sessionClosed) {
return cancelResult(false, "absent", expectedTurnId);
}
if (!expectedTurnId) {
return cancelResult(false, "absent", "");
}
const pendingGoal = this.goalExecQueue.cancelExact(expectedTurnId);
if (pendingGoal) {
this.emitPendingGoalCanceled(pendingGoal);
return cancelResult(
true,
"pre_accept",
expectedTurnId,
"",
"pending_goal"
);
}
const generation = this.queryGeneration;
const preparation = this.turns.prepareExactCancellation(expectedTurnId);
// The canonical root Turn and a provider-native synthetic continuation
// have different Turn ids but can still be live members of one Query
// generation. The synthetic id may be the current active Turn, so an
// exact root cancellation can look absent/mismatched to TurnLifecycle.
// Generation ownership is the narrow proof that permits retiring that
// Query; it never retargets a stop request to an unrelated newer Query.
const ownsExpectedTurn = generation?.ownsTurn(expectedTurnId) === true;
const phase =
this.providerTurnAcceptance.phase(expectedTurnId) ?? "unknown";
const providerStateWasAccepted =
preparation.providerTurnId.trim() !== "" ||
this.acceptedProviderTurnIds.has(expectedTurnId) ||
(phase !== "queued" &&
phase !== "dispatched" &&
phase !== "provider_observed" &&
phase !== "resolving_identity" &&
phase !== "terminal" &&
phase !== "unknown");
if (preparation.disposition === "absent" && !ownsExpectedTurn) {
if (providerStateWasAccepted) {
return cancelResult(
false,
"provider_state_lost",
expectedTurnId,
preparation.providerTurnId,
phase
);
}
return cancelResult(false, "absent", expectedTurnId);
}
if (preparation.disposition === "mismatch" && !ownsExpectedTurn) {
return cancelResult(false, "mismatch", expectedTurnId);
}
if (preparation.disposition === "unresolved") {
throw new Error(
`Claude SDK exact cancellation remains unresolved for turn ${expectedTurnId}`
);
}
if (
preparation.differentActiveTurn &&
phase !== "queued" &&
!ownsExpectedTurn
) {
this.turns.releaseExactCancellation(expectedTurnId);
return cancelResult(false, "mismatch", expectedTurnId, "", phase);
}
if (preparation.disposition === "queued" && phase === "queued") {
if (!this.turns.commitExactCancellation(expectedTurnId)) {
throw new Error(
`Claude SDK lost exact cancellation target ${expectedTurnId}`
);
}
this.turns.clearCancelled();
return cancelResult(true, "pre_accept", expectedTurnId, "", phase);
}
if (!generation) {
if (providerStateWasAccepted) {
this.turns.releaseExactCancellation(expectedTurnId);
this.turns.clearCancelled();
return cancelResult(
false,
"provider_state_lost",
expectedTurnId,
preparation.providerTurnId,
phase
);
}
this.turns.discardExactAbsent(expectedTurnId);
this.turns.clearCancelled();
return cancelResult(false, "absent", expectedTurnId, "", phase);
}
// One SDK Query owns every Turn already handed to its prompt queue. Native
// interrupt retires that entire generation, so fence and settle every such
// Turn after the same shutdown ACK instead of stranding collateral Goal
// commands whose prompts can no longer run.
const generationTurnIds = this.turns.prepareGenerationCancellation();
this.activities.cancel();
this.executionEpoch += 1;
this.providerTurnAcceptance.cancel();
this.canceledQueryTailPending = true;
this.interactions.rejectAll(new Error("Tool use aborted"));
this.queryGeneration = undefined;
await generation.shutdown(true, shutdownOptions);
for (const turnId of generationTurnIds) {
if (!this.turns.commitExactCancellation(turnId)) {
throw new Error(`Claude SDK lost canceled Query turn ${turnId}`);
}
}
this.turns.clearCancelled();
return cancelResult(
true,
preparation.providerTurnId ? "provider_active" : "pre_accept",
expectedTurnId,
preparation.providerTurnId,
phase
);
}
private emitPendingGoalCanceled(input: GoalExecInput): void {
emit({
type: "goal_command_canceled",
payload: {
turnId: input.turnId,
operationId: input.goal.operationId,
revision: input.goal.revision,
repairEpoch: input.goal.repairEpoch ?? 0,
action: input.goal.action,
reason: "cancel_requested"
}
});
}
async stopTask(taskId: string, parentToolUseId = ""): Promise<boolean> {
if (this.sessionClosed) {
return false;
}
const resolvedTaskId = this.activities.resolveDelegatedTaskIdForStop(
taskId,
parentToolUseId
);
const stopTask = this.query?.stopTask;
if (!resolvedTaskId || !stopTask) {
return false;
}
// The stopped task_notification that follows settles the task's activity
// state; no local bookkeeping happens here so a failed stop stays running.
await stopTask.call(this.query, resolvedTaskId);
return true;
}
async close(): Promise<void> {
if (this.sessionClosed) {
return;
}
this.sessionClosed = true;
this.executionEpoch += 1;
this.providerTurnAcceptance.cancel();
this.interactions.rejectAll(new Error("Tool use aborted"));
this.activities.close();
await this.router.close();
this.turns.close();
const generation = this.queryGeneration;
this.queryGeneration = undefined;
await generation?.shutdown(false);
}
submitInteractive(
turnId: string,
requestId: string,
action: string,
optionId: string,
payload: Record<string, unknown>
) {
return this.interactions.submit(
turnId,
requestId,
action,
optionId,
payload
);
}
interactiveDisposition(
turnId: string,
requestId: string,
action: string,
optionId: string,
payload: Record<string, unknown>
) {
return this.interactions.disposition(turnId, requestId, {
action,
optionId,
payload
});
}
async applySettings(payload: Record<string, unknown>): Promise<void> {
try {
await this.runConfigurationTask(async () => {
// Live applyFlagSettings on a quiet post-turn Claude query can hang
// indefinitely. Because the sidecar stdin loop awaits each request,
// that hang also blocks every later exec/settings round-trip and the
// daemon UpdateSettings HTTP call times out at ~30s. Retire the idle
// generation first so effort/speed stay as pending flags and are baked
// into the next turn's ensureQuery create-time settings (not a live
// applyFlagSettings on the resumed quiet query, which still delayed
// provider identity / delivery confirmation by ~90s).
this.retireIdleQueryBeforeNextTurn();
await this.configuration.apply(payload);
this.emitSessionState();
});
} catch (error) {
// A failed/canceled live settings write must not leave the next turn on a
// half-mutated idle query (submitted with no SDK traffic for minutes).
this.retireIdleQueryBeforeNextTurn();
throw error;
}
}
private runConfigurationTask<T>(task: () => Promise<T>): Promise<T> {
const run = this.configurationGate.then(task, task);
this.configurationGate = run.then(
() => undefined,
() => undefined
);
return run;
}
private consume(generation: QueryGeneration): void {
if (!generation.query || generation.consumption) {
return;
}
generation.consumption = (async () => {
const iterator = generation.query?.[Symbol.asyncIterator]();
try {
if (!iterator) {
return;
}
for (;;) {
let next: IteratorResult<SDKMessage>;
try {
next = await this.nextQueryMessage(iterator, generation);
} catch (error) {
if (isAbortError(error)) {
break;
}
throw error;
}
if (next.done) {
break;
}
if (!this.isQueryGenerationActive(generation)) {
break;
}
const message = next.value;
const shouldRoute = generation.shouldRouteMessage(message);
if (!shouldRoute) {
continue;
}
await this.router.handle(message);
}
} catch (error) {
if (isAbortError(error) && !this.isQueryGenerationActive(generation)) {
return;
}
this.diagnostics.logQueryFailure(generation.id, error);
this.diagnostics.logAuthRefresh("query_consume.failed", {
activeTurnId: this.turns.activeId,
queuedTurnIds: this.turns.queue
.filter((turn) => !turn.settled)
.map((turn) => turn.turnId),
error: errorPayload(error)
});
this.turns.failLiveTurns(errorMessage(error));
} finally {
if (this.queryGeneration === generation) {
this.diagnostics.logQueryEnd(generation.id);
this.queryGeneration = undefined;
generation.revoke();
generation.closeQuery();
// Do not mark the Session closed here. A natural query iterator end
// (or a retired idle generation) must remain resumable for the next
// user turn; only SessionRuntime.close() makes the Session terminal.
if (this.turns.activeTurn) {
this.turns.settleActive(
this.turns.cancelled ? "turn_canceled" : "turn_failed"
);
}
this.turns.failQueuedTurns("Claude SDK session ended");
this.turns.clearCancelled();
}
}
})();
}
private nextQueryMessage(
iterator: AsyncIterator<SDKMessage>,
generation: QueryGeneration
): Promise<IteratorResult<SDKMessage>> {
const signal = generation.cancelController.signal;
if (signal.aborted) {
return Promise.reject(abortError());
}
return Promise.race([
iterator.next(),
new Promise<IteratorResult<SDKMessage>>((_, reject) => {
signal.addEventListener("abort", () => reject(abortError()), {
once: true
});
})
]);
}
private async ensureQuery(
startOptions: { initialize?: boolean } = {}
): Promise<void> {
if (this.queryGeneration || this.driver) {
return;
}
if (this.sessionClosed) {
throw new Error("Claude SDK session is closed");
}
const executionEpoch = this.executionEpoch;
const queryFactory =
this.queryFactory ??
(await import("@anthropic-ai/claude-agent-sdk")).query;
if (executionEpoch !== this.executionEpoch || this.sessionClosed) {
throw new Error("Claude SDK query generation was retired");
}
const generation = new QueryGeneration(
++this.nextQueryGenerationId,
this.canceledQueryTailPending
);
this.activities.resetBackgroundTaskLevel();
this.queryGeneration = generation;
const permissionMode = effectivePermissionMode(this.configuration.settings);
const allowBypassPermissions = canBypassPermissions();
const querySettings = querySettingsFromSessionSettings(
this.configuration.settings
);
// Pending effort/speed from apply_settings after idle-retire must land in
// create-time settings above. Absorb them here so the post-ensureQuery
// applyPendingFlags path does not call applyFlagSettings on this quiet
// resumed generation (that delayed turn-2 identity past the 30s host
// delivery timeout in C05).
this.configuration.absorbPendingFlagsIntoQueryCreate();
// One settings snapshot feeds both the executable resolution and the SDK
// env, so the two can never disagree (and the settings hierarchy is read
// once per query creation).
const settingsEnv = claudeSettingsEnv(this.cwd || process.cwd());
// Same merge (and precedence) as queryOptions.env below, so an override
// set in Claude settings files is honored exactly like one from the
// process or session environment.
const claudeExecutablePath = resolveClaudeCodeExecutablePath({
...process.env,
...settingsEnv,
...this.env
});
const queryOptions: ClaudeQueryOptions = {
cwd: this.cwd || process.cwd(),
env: {
...process.env,
...settingsEnv,
...this.env,
CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1"
},
...(claudeExecutablePath
? { pathToClaudeCodeExecutable: claudeExecutablePath }
: {}),
includePartialMessages: true,
includeHookEvents: true,
canUseTool: (toolName, toolInput, callbackOptions) =>
this.handleToolPermission(
generation,
String(toolName),
recordValue(toolInput) ?? {},
callbackOptions as ToolPermissionOptions
),
...(this.resumeQueries
? { resume: this.providerSessionId }
: { sessionId: this.providerSessionId }),
...(modelOptionValue(this.configuration.settings.model)
? { model: modelOptionValue(this.configuration.settings.model) }
: {}),
...(permissionMode ? { permissionMode } : {}),
allowDangerouslySkipPermissions: allowBypassPermissions,
...(Object.keys(querySettings).length > 0
? { settings: querySettings }
: {}),
...claudeQueryOptionOverrides(this.claudeOptions),
hooks: queryGenerationHooks({
generation,
isActive: () => this.isQueryGenerationActive(generation),
onSessionStart: async (input) => {
await this.router.observeSessionStartHook(input);
return { continue: true };
},
onPostToolUse: (input, toolUseID) =>
this.activities.handlePostToolUseHook(input, toolUseID),
onTaskLifecycle: (input) =>
this.activities.handleTaskLifecycleHook(input)
})
} as ClaudeQueryOptions;
this.diagnostics.logAuthRefresh("query_create.begin", {
initialize: startOptions.initialize === true,
restore: this.resumeQueries,
permissionMode,
hasExecutablePathOverride: Boolean(claudeExecutablePath),
hasModel: Boolean(modelOptionValue(this.configuration.settings.model)),
hasResumeCursor: Boolean(this.resumeCursor),
querySettingsKeys: Object.keys(querySettings),
claudeOptionKeys: Object.keys(
claudeQueryOptionOverrides(this.claudeOptions)
)
});
try {
generation.query = queryFactory({
prompt: generation.promptQueue.iterate(),
options: queryOptions
}) as ClaudeQueryRuntime;
this.diagnostics.logAuthRefresh("query_create.succeeded", {
initialize: startOptions.initialize === true,
restore: this.resumeQueries,
hasInitializationResult:
typeof generation.query.initializationResult === "function"
});
if (startOptions.initialize || this.resumeQueries) {
try {
this.diagnostics.logAuthRefresh("query_initialization.begin", {
restore: this.resumeQueries
});
const initializationResult =
await generation.query.initializationResult?.();
if (
executionEpoch !== this.executionEpoch ||
!this.isQueryGenerationActive(generation)
) {
throw new Error("Claude SDK query generation was retired");
}
this.configuration.applyInitializationResult(initializationResult);
this.initialized = true;
this.resumeQueries = true;
this.diagnostics.logAuthRefresh("query_initialization.succeeded", {
restore: this.resumeQueries,
resultKeys: Object.keys(recordValue(initializationResult) ?? {})
});
} catch (error) {
this.diagnostics.logAuthRefresh("query_initialization.failed", {
restore: this.resumeQueries,
error: errorPayload(error)
});
this.initialized = false;
throw error;
}
} else {
this.resumeQueries = true;
}
this.canceledQueryTailPending = false;
} catch (error) {
if (this.queryGeneration === generation) {
this.queryGeneration = undefined;
}
generation.revoke();
generation.closeQuery();
throw error;
}
}
private handleToolPermission(
generation: QueryGeneration,
toolName: string,
toolInput: Record<string, unknown>,
callbackOptions: ToolPermissionOptions
): Promise<PermissionResult> {
if (!this.isQueryGenerationActive(generation)) {
return Promise.resolve({
behavior: "deny",
message: "Tool use aborted"
});
}
return this.interactions.handleToolPermission(
toolName,
toolInput,
callbackOptions
);
}
private ensureProviderTurnAcceptance(
phase: Extract<
ProviderTurnPhase,
"streaming" | "waiting_approval" | "waiting_input" | "running_tool"
>,
signal?: AbortSignal
): Promise<void> {
return this.providerTurnAcceptance.ensure(phase, signal);
}
private emitProviderCheckpoint(
turnId: string,
providerTurnId: string,
providerCheckpointMessageId: string
): void {
turnId = turnId.trim();
providerTurnId = providerTurnId.trim();
providerCheckpointMessageId = providerCheckpointMessageId.trim();
if (!turnId || !providerTurnId || !providerCheckpointMessageId) {
return;
}
const key = `${turnId}\u0000${providerTurnId}\u0000${providerCheckpointMessageId}`;
if (this.emittedProviderCheckpoints.has(key)) {
return;
}
this.emittedProviderCheckpoints.add(key);
while (this.emittedProviderCheckpoints.size > 128) {
const oldest = this.emittedProviderCheckpoints.values().next().value;
if (typeof oldest !== "string") {
break;
}
this.emittedProviderCheckpoints.delete(oldest);
}
emit({
type: "provider_turn_checkpoint",
payload: { turnId, providerTurnId, providerCheckpointMessageId }
});
}
private isQueryGenerationActive(generation: QueryGeneration): boolean {
return (
!this.sessionClosed &&
!generation.revoked &&
this.queryGeneration === generation
);
}
private retireIdleQueryBeforeNextTurn(): void {
if (!this.queryGeneration) {
return;
}
// The session.start() initialization query must stay until the first turn
// uses it. Only retire after at least one root turn has already settled.
if (this.turns.turnCount === 0) {
return;
}
if (this.turns.activeTurn && !this.turns.activeTurn.settled) {
return;
}
if (this.turns.queue.some((turn) => !turn.settled)) {
return;
}
if (this.activities.hasPendingBackgroundContinuation()) {
return;
}
this.executionEpoch += 1;
this.resumeQueries = true;
QueryGeneration.retire(this.queryGeneration, () => {
this.queryGeneration = undefined;
});
}
private resetTurnScratch(): void {
this.assistantStream.reset();
this.activities.resetTurnScratch();
}
private currentResumeCursor(): Record<string, unknown> {
this.resumeCursor = {
kind: "claude-agent-sdk",
version: 1,
resume: this.providerSessionId,
...(this.lastAssistantUuid
? { resumeSessionAt: this.lastAssistantUuid }
: {}),
turnCount: this.turns.turnCount
};
return this.resumeCursor;
}
private emitSessionState(): void {
emit({
type: "session_state",
payload: {
providerSessionId: this.providerSessionId,
...this.sessionStatePayload(),
resumeCursor: this.currentResumeCursor()
}
});
}
private sessionStatePayload(): Record<string, unknown> {
return this.configuration.sessionStatePayload();
}
private async maybeEmitSessionTitleUpdated(
shouldEmit: () => boolean = () => true
): Promise<void> {
if (this.driver || !this.providerSessionId) {
return;
}
try {
const { getSessionInfo } = await import("@anthropic-ai/claude-agent-sdk");
const info = recordValue(
await getSessionInfo(this.providerSessionId, {
dir: this.cwd || process.cwd()
})
);
const title = normalizeTitle(
stringValue(info?.customTitle) || stringValue(info?.summary)
);
if (!shouldEmit() || !title || title === this.lastTitle) {
return;
}
this.lastTitle = title;
emit({
type: "session_title_updated",
payload: {
providerSessionId: this.providerSessionId,
title
}
});
} catch {
// Title updates are best-effort; the turn result should not fail because
// Claude Code has not written session metadata yet.
}
}
}