diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index b2133318648..da18e1a2edb 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -1,13 +1,11 @@ import { parseTerminalAttachmentLaunchResult } from '../../../../shared/terminal-attachment-launch-result' -import { - terminalLaunchFacts, - agentProviderSessionForEnsure -} from '../../../../shared/agent-session-resume' +import { terminalLaunchFacts } from '../../../../shared/agent-session-resume' import { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat' import { runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' import { readTerminalSpawnDeferral } from '../../../../shared/host-spawn-admission' import { remoteRestoreSpawnIntentParams } from '../../runtime/remote-restore-spawn-intent' import { createAgentSessionKeyboardOptions } from '@/runtime/agent-session-keyboard-capability' +import { agentProviderSessionForHostEnsure } from '@/runtime/agent-session-ensure-provider-session' import { createRemoteRuntimeDisconnectedInputGrace } from './remote-runtime-disconnected-input-grace' import { createRemoteRuntimeRecoveryInputHold, @@ -3518,10 +3516,16 @@ export function createRemoteRuntimePtyTransport( ) const hostAuthorityCreate = async (epoch = connectLifecycleEpoch) => { const keyboardOptions = await agentKeyboardOptions(createEnvironmentId) + const ensureProviderSession = resumeProviderSessionToSend + ? await agentProviderSessionForHostEnsure( + createEnvironmentId, + resumeProviderSessionToSend + ) + : undefined return createWithUnknownOutcomeRecovery( 'agent-session', (timeoutMs) => - resumeProviderSessionToSend + ensureProviderSession ? callRuntimeForEnvironment( createEnvironmentId, 'terminal.ensureAgentSession', @@ -3531,7 +3535,7 @@ export function createRemoteRuntimePtyTransport( ...originOptions, worktree: toRuntimeTerminalWorktreeSelector(worktreeId), agent: launchAgentToSend!, - providerSession: agentProviderSessionForEnsure(resumeProviderSessionToSend), + providerSession: ensureProviderSession, ...(launchConfigToSend?.ompResumeFilePath ? { ompResumeFilePath: launchConfigToSend.ompResumeFilePath } : {}), diff --git a/src/renderer/src/runtime/agent-session-ensure-provider-session.ts b/src/renderer/src/runtime/agent-session-ensure-provider-session.ts new file mode 100644 index 00000000000..15ef77eddc5 --- /dev/null +++ b/src/renderer/src/runtime/agent-session-ensure-provider-session.ts @@ -0,0 +1,29 @@ +import type { AgentProviderSessionMetadata } from '../../../shared/agent-session-resume' +import { AGENT_SESSION_RESUME_EFFORT_RUNTIME_CAPABILITY } from '../../../shared/agent-session-create-capabilities' +import { runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' +import { isRuntimeCompatBlockError } from './runtime-protocol-compat' + +/** The provider session an ensureAgentSession call may send to this host; older hosts' strict schema rejects `cwd` and `effort`. */ +export async function agentProviderSessionForHostEnsure( + environmentId: string, + providerSession: AgentProviderSessionMetadata +): Promise { + const { cwd: _cwd, effort, ...identity } = providerSession + return effort && (await hostAcceptsResumeEffort(environmentId)) + ? { ...identity, effort } + : identity +} + +async function hostAcceptsResumeEffort(environmentId: string): Promise { + try { + return await runtimeEnvironmentSupportsCapability( + environmentId, + AGENT_SESSION_RESUME_EFFORT_RUNTIME_CAPABILITY + ) + } catch (error) { + if (isRuntimeCompatBlockError(error)) { + throw error + } + return false + } +} diff --git a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts index 257c2a085cd..01184bb175a 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts @@ -3,7 +3,7 @@ import { createAgentSessionKeyboardOptions } from './agent-session-keyboard-capa import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { RuntimeMobileSessionCreateTerminalResult } from '../../../shared/runtime-types' import { toRuntimeExecutionHostId } from '../../../shared/execution-host' -import { agentProviderSessionForEnsure } from '../../../shared/agent-session-resume' +import { agentProviderSessionForHostEnsure } from './agent-session-ensure-provider-session' import { translate } from '../i18n/i18n' import { useAppStore } from '../store' import { agentResumeHostAuthorityCapability } from './agent-resume-host-authority-capability' @@ -110,7 +110,10 @@ export async function createWebRuntimeSessionTerminalResult( kind: 'explicit', worktree: toRuntimeWorktreeSelector(args.worktreeId), agent, - providerSession: agentProviderSessionForEnsure(args.providerSession!), + providerSession: await agentProviderSessionForHostEnsure( + environmentId, + args.providerSession! + ), ...(args.launchConfig?.ompResumeFilePath ? { ompResumeFilePath: args.launchConfig.ompResumeFilePath } : {}), diff --git a/src/shared/agent-effort-level.ts b/src/shared/agent-effort-level.ts new file mode 100644 index 00000000000..23f9b8eafd8 --- /dev/null +++ b/src/shared/agent-effort-level.ts @@ -0,0 +1,6 @@ +// Why a shape, not Claude's level list: a level a newer Claude adds must still round-trip into argv. +export const AGENT_EFFORT_LEVEL = /^[a-z][a-z0-9-]{0,31}$/ + +export function readAgentEffortLevel(value: unknown): string | undefined { + return typeof value === 'string' && AGENT_EFFORT_LEVEL.test(value) ? value : undefined +} diff --git a/src/shared/agent-hook-listener-claude-turn-state.test.ts b/src/shared/agent-hook-listener-claude-turn-state.test.ts index 07420b6c4c6..f42d9c82809 100644 --- a/src/shared/agent-hook-listener-claude-turn-state.test.ts +++ b/src/shared/agent-hook-listener-claude-turn-state.test.ts @@ -131,6 +131,51 @@ describe('shared agent-hook-listener', () => { expect(resumedElsewhere?.providerSession?.cwd).toBe('/elsewhere') }) + it("records a Claude session's live effort and keeps it across hooks that omit it", () => { + const sessionId = '88888888-8888-4888-8888-888888888888' + const prompt = normalizeAndAccept(state, 'claude', { + hook_event_name: 'UserPromptSubmit', + session_id: sessionId, + prompt: 'go' + }) + normalizeAndAccept(state, 'claude', { + hook_event_name: 'PreToolUse', + session_id: sessionId, + tool_name: 'Bash', + effort: { level: 'max' } + }) + // Subagent hooks name the parent session but report the subagent's own effort. + const subagent = normalizeAndAccept(state, 'claude', { + hook_event_name: 'PostToolUse', + session_id: sessionId, + agent_id: 'agent-1', + tool_name: 'Read', + effort: { level: 'low' } + }) + const nextPrompt = normalizeAndAccept(state, 'claude', { + hook_event_name: 'UserPromptSubmit', + session_id: sessionId, + prompt: 'again' + }) + // /clear starts a new session id in the same process, which keeps its effort. + const cleared = normalizeAndAccept(state, 'claude', { + hook_event_name: 'SessionStart', + source: 'clear', + session_id: '99999999-9999-4999-8999-999999999999' + }) + const otherSession = normalizeAndAccept(state, 'claude', { + hook_event_name: 'SessionStart', + source: 'startup', + session_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + }) + + expect(prompt?.providerSession?.effort).toBeUndefined() + expect(subagent?.providerSession?.effort).toBe('max') + expect(nextPrompt?.providerSession?.effort).toBe('max') + expect(cleared?.providerSession?.effort).toBe('max') + expect(otherSession?.providerSession?.effort).toBeUndefined() + }) + it('resets stale Claude turn state when SessionStart announces a new session on the pane', () => { normalizeAndAccept(state, 'claude', { hook_event_name: 'UserPromptSubmit', prompt: 'fix bug' }) normalizeAndAccept(state, 'claude', { diff --git a/src/shared/agent-hook-listener/pane-provider-session.ts b/src/shared/agent-hook-listener/pane-provider-session.ts index 33d24770f85..2b30227dcad 100644 --- a/src/shared/agent-hook-listener/pane-provider-session.ts +++ b/src/shared/agent-hook-listener/pane-provider-session.ts @@ -1,4 +1,5 @@ import type { AgentHookSource } from '../agent-hook-relay' +import { readAgentEffortLevel } from '../agent-effort-level' import { extractAgentProviderSession, readPathFromKeys, @@ -25,28 +26,69 @@ export function readPaneProviderSession( if (!providerSession) { return null } - const cwd = readAgentCwd(state, source, paneKey, providerSession.id, hookPayload) - return cwd ? { ...providerSession, cwd } : providerSession + const continued = continuedProviderSession( + state, + source, + paneKey, + providerSession.id, + hookPayload + ) + const cwd = readAgentCwd(source, hookPayload, continued) + const effort = readClaudeEffort(source, hookPayload, continued) + return { + ...providerSession, + ...(cwd ? { cwd } : {}), + ...(effort ? { effort } : {}) + } } -/** The folder the pane's agent started in. Only Claude/Codex hooks are known to report it (Cursor's cwd is a shell command's). */ -function readAgentCwd( +/** The pane's previous session when this hook continues it; /clear and compact restart Claude in the same process. */ +function continuedProviderSession( state: HookListenerState, source: AgentHookSource, paneKey: string, sessionId: string, hookPayload: Record -): string | undefined { - if (source !== 'claude' && source !== 'codex') { - return undefined - } +): AgentProviderSessionMetadata | undefined { const previous = state.lastStatusByPaneKey.get(paneKey) - const previousCwd = previous?.source === source ? previous.providerSession?.cwd : undefined - // Claude's hook cwd follows its Bash tool's `cd`; /clear and compact restart in the same process. - const keepsPreviousFolder = + const continues = previous?.providerSession?.id === sessionId || (source === 'claude' && hookPayload.hook_event_name === 'SessionStart' && (hookPayload.source === 'clear' || hookPayload.source === 'compact')) - return previousCwd && keepsPreviousFolder ? previousCwd : readPathFromKeys(hookPayload, ['cwd']) + return previous?.source === source && continues ? previous.providerSession : undefined +} + +/** The folder the pane's agent started in. Only Claude/Codex hooks are known to report it (Cursor's cwd is a shell command's). */ +function readAgentCwd( + source: AgentHookSource, + hookPayload: Record, + continued: AgentProviderSessionMetadata | undefined +): string | undefined { + if (source !== 'claude' && source !== 'codex') { + return undefined + } + // Claude's hook cwd follows its Bash tool's `cd`. + return continued?.cwd ?? readPathFromKeys(hookPayload, ['cwd']) +} + +/** Claude 2.1.133+ reports `effort.level` on tool and Stop hooks only, so other events keep the last one. */ +function readClaudeEffort( + source: AgentHookSource, + hookPayload: Record, + continued: AgentProviderSessionMetadata | undefined +): string | undefined { + if (source !== 'claude') { + return undefined + } + const effort = hookPayload.effort + // Why skip subagents: their hooks name the parent session but report the subagent's own effort. + const reported = + typeof effort === 'object' && + effort !== null && + 'level' in effort && + !readString(hookPayload, 'agent_id') + ? readAgentEffortLevel(effort.level) + : undefined + return reported ?? continued?.effort } diff --git a/src/shared/agent-resume-argv.ts b/src/shared/agent-resume-argv.ts index 52c93600d52..ad108bb5510 100644 --- a/src/shared/agent-resume-argv.ts +++ b/src/shared/agent-resume-argv.ts @@ -11,7 +11,14 @@ export function getAgentResumeArgv( case 'codebuddy': return providerSession.key === 'session_id' ? ['codebuddy', '--resume', id] : null case 'claude': - return providerSession.key === 'session_id' ? ['claude', '--resume', id] : null + return providerSession.key === 'session_id' + ? [ + 'claude', + ...(providerSession.effort ? ['--effort', providerSession.effort] : []), + '--resume', + id + ] + : null case 'cursor': return providerSession.key === 'conversation_id' ? ['cursor-agent', '--resume', id] : null case 'codex': diff --git a/src/shared/agent-resume-launch-command.spec.ts b/src/shared/agent-resume-launch-command.spec.ts index 1fbd103485e..437fd024759 100644 --- a/src/shared/agent-resume-launch-command.spec.ts +++ b/src/shared/agent-resume-launch-command.spec.ts @@ -565,3 +565,32 @@ describe('buildAgentResumeStartupPlan claude selector guard', () => { expect(restored?.launchConfig.agentCommand).toBe("claude '--resume'") }) }) + +describe('buildAgentResumeStartupPlan claude effort', () => { + it.each(SHELLS)( + "resumes at the session's effort after a stale launch flag ($platform/$shell)", + ({ platform, shell }) => { + const session = { ...providerSession, effort: 'max' } + const restored = buildAgentResumeStartupPlan({ + agent: 'claude', + providerSession: session, + cmdOverrides: {}, + agentArgs: '--effort low', + platform, + shell + }) + const command = restored?.launchCommand ?? '' + const tokens = tokenizeStartupCommand(command, shell) + // Claude keeps the last --effort it parses. + expect(tokens.ok && tokens.tokens.slice(-6)).toEqual([ + '--effort', + 'low', + '--effort', + 'max', + ...RESUME + ]) + // The saved launch recipe stays the user's own. + expect(restored?.launchConfig.agentArgs).toBe('--effort low') + } + ) +}) diff --git a/src/shared/agent-resume-record-equivalence.ts b/src/shared/agent-resume-record-equivalence.ts index 1129bf84cfa..62a0c55949b 100644 --- a/src/shared/agent-resume-record-equivalence.ts +++ b/src/shared/agent-resume-record-equivalence.ts @@ -61,6 +61,8 @@ export function recoveryRecordMatches( existing.state === next.state && agentMainAgentVerdict(existing) === agentMainAgentVerdict(next) && agentProviderSessionsEqual(existing.agent, existing.providerSession, next.providerSession) && + // Why: effort is not session identity, but a stale level would resume at the wrong effort. + existing.providerSession.effort === next.providerSession.effort && launchConfigsEqual(existing.launchConfig, next.launchConfig) ) } diff --git a/src/shared/agent-session-create-capabilities.ts b/src/shared/agent-session-create-capabilities.ts index 026c8ac1928..cec66a663e3 100644 --- a/src/shared/agent-session-create-capabilities.ts +++ b/src/shared/agent-session-create-capabilities.ts @@ -18,6 +18,10 @@ export const AGENT_SESSION_ACCOUNT_ENV_RUNTIME_CAPABILITY = 'agent-session.accou // Send is strict: advertise skills before sending selected paths; name-based agents still use the $name text. export const AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY = 'agent-session.send-skills.v1' as const +// Ensure is strict: send a resumed Claude session's effort only to hosts advertising support. +export const AGENT_SESSION_RESUME_EFFORT_RUNTIME_CAPABILITY = + 'agent-session.resume-effort.v1' as const + export const AGENT_SESSION_SEND_USAGE_LIMIT_CONTINUATION_RUNTIME_CAPABILITY = 'agent-session.send-usage-limit-continuation.v1' as const @@ -28,5 +32,6 @@ export const AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES = [ AGENT_SESSION_CREATE_TAB_PLACEMENT_RUNTIME_CAPABILITY, AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY, AGENT_SESSION_ACCOUNT_ENV_RUNTIME_CAPABILITY, - AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY + AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY, + AGENT_SESSION_RESUME_EFFORT_RUNTIME_CAPABILITY ] as const diff --git a/src/shared/agent-session-resume.test.ts b/src/shared/agent-session-resume.test.ts index 1790a442d8e..af6f99e6e09 100644 --- a/src/shared/agent-session-resume.test.ts +++ b/src/shared/agent-session-resume.test.ts @@ -65,6 +65,11 @@ describe('agent session resume metadata', () => { it.each([ ['claude', { key: 'session_id', id: 's1' }, ['claude', '--resume', 's1']], + [ + 'claude', + { key: 'session_id', id: 's1', effort: 'max' }, + ['claude', '--effort', 'max', '--resume', 's1'] + ], ['codebuddy', { key: 'session_id', id: 's1' }, ['codebuddy', '--resume', 's1']], ['codex', { key: 'session_id', id: 's1' }, ['codex', 'resume', 's1']], ['gemini', { key: 'session_id', id: 's1' }, ['gemini', '--resume', 's1']], @@ -168,6 +173,18 @@ describe('agent session resume metadata', () => { }) ).toEqual({ key: 'session_id', id: 'ok' }) }) + + it('round-trips a Claude effort level and drops values unsafe for argv', () => { + expect(normalizeAgentProviderSession({ key: 'session_id', id: 'ok', effort: 'xhigh' })).toEqual( + { key: 'session_id', id: 'ok', effort: 'xhigh' } + ) + for (const effort of ['--model', 'high; rm -rf /', 'HIGH', '', 3]) { + expect(normalizeAgentProviderSession({ key: 'session_id', id: 'ok', effort })).toEqual({ + key: 'session_id', + id: 'ok' + }) + } + }) }) describe('OMP recorded resume locators', () => { diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index f6a5a71158d..48c486030bc 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -1,3 +1,4 @@ +import { readAgentEffortLevel } from './agent-effort-level' import type { AgentHookSource } from './agent-hook-relay' import type { AgentStatusCacheIdentity, RefusedPaneResume } from './agent-status-ipc-payload' import type { AgentStatusState } from './agent-status-types' @@ -44,6 +45,8 @@ export type AgentProviderSessionMetadata = { transcriptPath?: string /** Folder the agent ran in; resume runs there, not the pane's start folder (#16960). */ cwd?: string + /** Claude's live effort level; resume replays it because Claude restores only its saved per-model default. */ + effort?: string } export type SleepingAgentLaunchConfig = { @@ -181,22 +184,16 @@ export function normalizeAgentProviderSession(raw: unknown): AgentProviderSessio // Persisted/relay paths cross trust boundaries too; reject controls as for hook paths. const transcriptPath = readPathFromKeys(record, ['transcriptPath']) const cwd = readPathFromKeys(record, ['cwd']) + const effort = readAgentEffortLevel(record.effort) return { key, id, ...(transcriptPath ? { transcriptPath } : {}), - ...(cwd ? { cwd } : {}) + ...(cwd ? { cwd } : {}), + ...(effort ? { effort } : {}) } } -/** Older hosts' strict ensureAgentSession schema rejects the newer `cwd` field. */ -export function agentProviderSessionForEnsure( - providerSession: AgentProviderSessionMetadata -): AgentProviderSessionMetadata { - const { cwd: _cwd, ...identity } = providerSession - return identity -} - /** Compare the provider-owned values that identify the CLI resume target. * Pi-family transcript resumes use file identity; other agents use provider ids. */ export function agentProviderSessionsEqual( diff --git a/src/shared/rpc-contract/agent-session-params.ts b/src/shared/rpc-contract/agent-session-params.ts index 15a98d809e6..dee2e92db1d 100644 --- a/src/shared/rpc-contract/agent-session-params.ts +++ b/src/shared/rpc-contract/agent-session-params.ts @@ -5,6 +5,7 @@ import { getAgentResumeArgv, hasUnsafeProviderSessionIdChars } from '../agent-session-resume' +import { AGENT_EFFORT_LEVEL } from '../agent-effort-level' import { parseAgentSessionOperationTimestamp } from '../agent-session-host-authority' import type { RuntimeCreateAgentSessionRequest, @@ -110,7 +111,8 @@ export const ProviderSession = z Buffer.byteLength(value, 'utf8') <= MAX_TRANSCRIPT_PATH_BYTES, 'Invalid transcript path' ) - .optional() + .optional(), + effort: z.string().regex(AGENT_EFFORT_LEVEL, 'Invalid effort level').optional() }) .strict() diff --git a/src/shared/workspace-session-schema.sleeping-agent.test.ts b/src/shared/workspace-session-schema.sleeping-agent.test.ts index df98b0e6461..3b2d314324e 100644 --- a/src/shared/workspace-session-schema.sleeping-agent.test.ts +++ b/src/shared/workspace-session-schema.sleeping-agent.test.ts @@ -148,6 +148,35 @@ describe('parseWorkspaceSession sleeping agents', () => { } }) + it("preserves a Claude session's effort level through hydration", () => { + const providerSession = { key: 'session_id', id: 'claude-session', effort: 'max' } + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'claude', + providerSession, + prompt: '', + state: 'done', + capturedAt: 10, + updatedAt: 10, + origin: 'live' + } + } + }) + + expect( + result.ok && result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.providerSession + ).toEqual(providerSession) + }) + it('preserves the AI Vault OMP resume file through hydration', () => { const result = parseWorkspaceSession({ activeRepoId: null, diff --git a/src/shared/workspace-session-sleeping-agents.ts b/src/shared/workspace-session-sleeping-agents.ts index a6bfc11a8d0..17c9ddc97ad 100644 --- a/src/shared/workspace-session-sleeping-agents.ts +++ b/src/shared/workspace-session-sleeping-agents.ts @@ -4,6 +4,7 @@ import { normalizeAgentProviderSession, RESUMABLE_TUI_AGENTS } from './agent-session-resume' +import { AGENT_EFFORT_LEVEL } from './agent-effort-level' import { isValidTerminalTabId } from './terminal-tab-id' import { normalizeMainAgentStatusField } from './agent-status-types' import { salvagingRecord } from './zod-salvage' @@ -20,7 +21,8 @@ const agentProviderSessionSchema = z.preprocess( id: z.string().min(1).max(512), // Pi resume requires its persisted authoritative transcript path. transcriptPath: z.string().min(1).optional(), - cwd: z.string().min(1).optional() + cwd: z.string().min(1).optional(), + effort: z.string().regex(AGENT_EFFORT_LEVEL).optional() }) )