From 468aa034652f21e0c352091f20a189052c59d24d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:30:48 -0400 Subject: [PATCH 1/5] feat(chat): mirror all Claude crons durably with native-fire grace backstop Every CronCreate is now mirrored into ADE's durable scheduler (durable gate removed); a 90s grace window lets the SDK's native scheduler fire first via claimNativeFire, with ADE's timer as the backstop for busy, skipped, or dead-process cases. Busy-session wakes queue for a turn boundary instead of steering the active turn. Restart reconciliation no longer cancels rows owned by a prior provider session when the fresh snapshot is empty. ADE state wins; the SDK's in-process CronList view is advisory. Co-Authored-By: Claude Fable 5 --- .../services/ai/tools/systemPrompt.test.ts | 3 +- .../main/services/ai/tools/systemPrompt.ts | 2 +- .../services/chat/agentChatService.test.ts | 123 ++++++++++++++++-- .../main/services/chat/agentChatService.ts | 102 +++++++++++---- .../chat/chatScheduledWorkScheduler.test.ts | 107 +++++++++++++++ .../chat/chatScheduledWorkScheduler.ts | 30 ++++- docs/ARCHITECTURE.md | 4 +- docs/features/chat/README.md | 45 +++++-- 8 files changed, 358 insertions(+), 58 deletions(-) diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts index 969b442ed..545b10e7a 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts @@ -126,7 +126,8 @@ describe("buildCodingAgentSystemPrompt", () => { expect(result).toContain("ScheduleWakeup"); expect(result).toContain("CronCreate"); expect(result).toContain("can start a later unattended turn"); - expect(result).toContain("session-bound by default"); + expect(result).toContain("mirrors every successful `CronCreate` durably"); + expect(result).toContain("regardless of the provider tool's `durable` input"); expect(result).toContain("CronCreate` always creates a new job"); expect(result).toContain("`CronList` and `CronDelete`"); expect(result).toContain("auto-expire after seven days"); diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts index 4b97177e3..0cccbf564 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts @@ -22,7 +22,7 @@ function describeRuntime(runtime: AdeRuntimeKind): string[] { case "claude-agent-sdk-query": return [ "**Runtime:** ADE Work chat hosted on the Claude Agent SDK stable `query()` streaming-input API.", - "**Wake-up semantics:** ADE follows the Claude Agent SDK schedule contract. `ScheduleWakeup`, `CronCreate`, and `/loop` can start a later unattended turn. `CronCreate` always creates a new job, so reset or replacement flows must use `CronList` and `CronDelete` to remove the prior job before creating another. `CronCreate` is session-bound by default; set `durable: true` only when the user wants it to survive restarts. Durable recurring crons auto-expire after seven days, matching Claude. ADE restores durable jobs after its brain restarts, late-fires overdue work once, and exposes pause/cancel controls in Chat Info plus a project-wide manager in Settings.", + "**Wake-up semantics:** ADE follows the Claude Agent SDK schedule contract. `ScheduleWakeup`, `CronCreate`, and `/loop` can start a later unattended turn. `CronCreate` always creates a new job, so reset or replacement flows must use `CronList` and `CronDelete` to remove the prior job before creating another. ADE mirrors every successful `CronCreate` durably, regardless of the provider tool's `durable` input. Recurring crons auto-expire after seven days, matching Claude. ADE restores mirrored jobs after its brain restarts, late-fires overdue work once, and exposes pause/cancel controls in Chat Info plus a project-wide manager in Settings.", "**To wait:** For short bounded waits inside the current turn, a foreground command such as `sleep ... && ` is fine. For longer waits or autonomous follow-up, prefer `ScheduleWakeup`, `CronCreate`, or `/loop` and include a concise reason/prompt so ADE can show the pending work clearly.", ]; case "codex-cli": diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 776b1019b..6c874a13c 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -13618,7 +13618,7 @@ describe("createAgentChatService", () => { expect(service.hasActiveWorkloads()).toBe(false); }); - it("keys Claude cron create and delete events by the provider cron id", async () => { + it("mirrors Claude CronCreate without a durable flag and keys events by the provider cron id", async () => { const events: AgentChatEventEnvelope[] = []; const scheduledWork = createScheduledWorkDb(); const setPermissionMode = vi.fn().mockResolvedValue(undefined); @@ -13708,7 +13708,19 @@ describe("createAgentChatService", () => { tool_input: { cron: "*/15 * * * *", prompt: "Check CI status." }, tool_response: { id: "cron-sdk-1", recurring: true, durable: false }, }); - expect(scheduledWork.readState()?.schedules ?? []).toEqual([]); + expect(scheduledWork.readState()?.schedules).toEqual([ + expect.objectContaining({ + id: "cron-sdk-1", + kind: "cron", + cron: "*/15 * * * *", + prompt: "Check CI status.", + durable: true, + provider: "claude", + providerSessionId: "sdk-cron-provider-id", + providerScheduleId: "cron-sdk-1", + status: "scheduled", + }), + ]); await postToolUseHook?.({ hook_event_name: "PostToolUse", tool_name: "CronDelete", @@ -13734,6 +13746,7 @@ describe("createAgentChatService", () => { status: "cancelled", cron: "*/15 * * * *", prompt: "Check CI status.", + durable: true, sourceToolUseId: "tool-cron-delete", }); }); @@ -13909,8 +13922,8 @@ describe("createAgentChatService", () => { expect(scheduledWork.readState()?.schedules).toEqual([ expect.objectContaining({ id: "cron-provider-1", - status: "paused", - pausedFlag: true, + status: "scheduled", + pausedFlag: false, providerSessionId: "sdk-cron-provider-id", }), ]); @@ -13930,7 +13943,7 @@ describe("createAgentChatService", () => { expect(scheduledWork.readState()?.schedules).toEqual([ expect.objectContaining({ id: "cron-provider-1", - status: "paused", + status: "scheduled", providerSessionId: "sdk-cron-provider-id", }), ]); @@ -14251,6 +14264,79 @@ describe("createAgentChatService", () => { original.forceDisposeAll(); }); + it("keeps a previous provider session's mirrored cron after a fresh session reports an empty snapshot", async () => { + const previousProviderSessionId = "sdk-before-brain-restart"; + const currentProviderSessionId = "sdk-after-brain-restart"; + const sdkHandle = { + send: vi.fn().mockResolvedValue(undefined), + stream: vi.fn(() => (async function* () { + yield { + type: "system", + subtype: "init", + session_id: currentProviderSessionId, + slash_commands: [], + }; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()), + close: vi.fn(), + sessionId: currentProviderSessionId, + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any; + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue(sdkHandle); + vi.mocked(claudeSdkResumeSessionCompat).mockReturnValue(sdkHandle); + + const original = createService().service; + const session = await original.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + writePersistedChatState(session.id, { + ...readPersistedChatState(session.id), + sdkSessionId: previousProviderSessionId, + }); + const scheduledWork = createScheduledWorkDb({ + version: 1, + schedules: [storedWakeup(session.id, { + id: "cron-survives-restart", + kind: "cron", + cron: "*/15 * * * *", + durable: true, + provider: "claude", + providerSessionId: previousProviderSessionId, + providerScheduleId: "cron-survives-restart", + })], + pausedSessionIds: [], + }); + const resumed = createService({ db: scheduledWork.db }).service; + await resumed.resumeSession({ sessionId: session.id }); + await resumed.runSessionTurn({ + sessionId: session.id, + text: "Resume after the brain restart.", + }); + const resumeOptions = vi.mocked(claudeSdkResumeSessionCompat).mock.calls.at(-1)?.[1] as { + hooks?: Record Promise> }>>; + } | undefined; + const stopHook = resumeOptions?.hooks?.Stop?.[0]?.hooks[0]; + + await stopHook?.({ + hook_event_name: "Stop", + session_id: currentProviderSessionId, + session_crons: [], + }); + + expect(scheduledWork.readState()?.schedules).toEqual([ + expect.objectContaining({ + id: "cron-survives-restart", + status: "scheduled", + pausedFlag: false, + providerSessionId: previousProviderSessionId, + }), + ]); + resumed.forceDisposeAll(); + original.forceDisposeAll(); + }); + it("cancels an unowned legacy wakeup when Claude confirms ScheduleWakeup stop", async () => { const sdkSessionId = "sdk-legacy-wakeup-stop"; const sdkHandle = { @@ -15026,7 +15112,7 @@ describe("createAgentChatService", () => { }); await vi.advanceTimersByTimeAsync(1_000); await foregroundTurn; - await vi.advanceTimersByTimeAsync(59_000); + await vi.advanceTimersByTimeAsync(149_000); expect(scheduledWork.readState()?.schedules[0]?.status).toBe("fired"); expect(service.pendingNativeScheduledWakeCountForTesting(session.id)).toBe(1); @@ -15245,7 +15331,7 @@ describe("createAgentChatService", () => { }); await restarted.service.refreshScheduledWork(); - await vi.advanceTimersByTimeAsync(60_000); + await vi.advanceTimersByTimeAsync(150_000); await vi.waitFor(() => { expect(events.some((event) => event.sessionId === session.id @@ -27717,7 +27803,7 @@ describe("createAgentChatService", () => { }, }); - expect(result).toMatchObject({ routedAction: "steer", delivery: "queued", queued: true }); + expect(result).toMatchObject({ routedAction: "sendMessage", delivery: "queued", queued: true }); const queued = events.find((event): event is AgentChatEventEnvelope & { event: Extract; } => @@ -27725,6 +27811,27 @@ describe("createAgentChatService", () => { && event.event.deliveryState === "queued" && event.event.text === "Check PR CI"); expect(queued?.event.metadata?.scheduledWake).toBeUndefined(); + expect(send.mock.calls.some(([payload]) => JSON.stringify(payload).includes("Check PR CI"))).toBe(false); + + const childCompletion = await service.messageSession({ + sessionId: session.id, + text: "Your subagent finished.", + kind: "wake", + metadata: { + spawnCompletion: { + childSessionId: "child-1", + childTitle: "Review agent", + spawnKind: "subagent", + status: "completed", + summary: "Review complete.", + }, + }, + }); + expect(childCompletion).toMatchObject({ + routedAction: "steer", + delivery: "queued", + queued: true, + }); finishActiveTurn(); await activeTurn; diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 19a4cb08b..7954a40cf 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -12505,18 +12505,13 @@ export function createAgentChatService(args: { }); }; - const adoptClaudeProviderSessionId = async ( + const adoptClaudeProviderSessionId = ( managed: ManagedChatSession, runtime: ClaudeRuntime, nextProviderSessionId: string | null | undefined, - ): Promise => { + ): void => { const next = nextProviderSessionId?.trim(); if (!next || runtime.sdkSessionId === next) return; - await quarantineClaudeScheduledWorkForProviderSessionChange( - managed, - runtime.sdkSessionId, - next, - ); runtime.sdkSessionId = next; mirrorClaudeSessionPointer(managed, next); persistChatState(managed); @@ -12617,7 +12612,7 @@ export function createAgentChatService(args: { const output = claudeScheduledWorkToolOutput(input.tool_response) ?? {}; const turnId = runtime.activeTurnId ?? undefined; const hookProviderSessionId = compactString(input.session_id); - await adoptClaudeProviderSessionId(managed, runtime, hookProviderSessionId); + adoptClaudeProviderSessionId(managed, runtime, hookProviderSessionId); const providerSessionId = hookProviderSessionId ?? runtime.sdkSessionId?.trim(); if (tool === "ScheduleWakeup") { @@ -12695,9 +12690,6 @@ export function createAgentChatService(args: { const recurring = typeof output.recurring === "boolean" ? output.recurring : args.recurring !== false; - const durable = typeof output.durable === "boolean" - ? output.durable - : args.durable === true; const kind: "cron" | "wakeup" | "loop" = recurring ? "cron" : isClaudeLoopPrompt(prompt) ? "loop" : "wakeup"; @@ -12714,17 +12706,14 @@ export function createAgentChatService(args: { prompt, ...(fireAt != null ? { nextRunAt: new Date(fireAt).toISOString() } : {}), recurring, - durable, + durable: true, sourceToolUseId: input.tool_use_id, ...(turnId ? { turnId } : {}), }); if (!scheduledWorkScheduler) return; - if (!durable) { - if (scheduledWorkScheduler.list(managed.session.id).some((schedule) => schedule.id === id)) { - await scheduledWorkScheduler.cancel(id); - } - return; - } + // ADE state wins, SDK view is advisory: every successful provider cron + // is mirrored durably even though Claude's tool schema says its durable + // input has no effect and its in-process CronList may drift. durableScheduleUiStatusById.set(id, "scheduled"); const createdAt = Date.now(); await scheduledWorkScheduler.upsert({ @@ -12779,7 +12768,7 @@ export function createAgentChatService(args: { ): Promise => { const hookRecord = input as unknown as Record; const hookProviderSessionId = compactString(hookRecord.session_id); - await adoptClaudeProviderSessionId(managed, runtime, hookProviderSessionId); + adoptClaudeProviderSessionId(managed, runtime, hookProviderSessionId); const turnId = runtime.activeTurnId ?? undefined; const backgroundTasks = Array.isArray(hookRecord.background_tasks) ? hookRecord.background_tasks : []; const snapshotBackgroundTaskIds = new Set(); @@ -12871,6 +12860,8 @@ export function createAgentChatService(args: { .find((schedule) => schedule.id === scheduledWorkId); const ownerMatches = !durableSchedule?.providerSessionId || durableSchedule.providerSessionId === providerSessionId; + const canAdoptCurrentProvider = durableSchedule?.provider === "claude" + && durableSchedule.pausedFlag !== true; const kind: "cron" | "wakeup" | "loop" = recurring ? "cron" : isClaudeLoopPrompt(prompt) ? "loop" : "wakeup"; @@ -12892,7 +12883,7 @@ export function createAgentChatService(args: { if ( scheduledWorkScheduler && durableSchedule - && ownerMatches + && (ownerMatches || canAdoptCurrentProvider) && durableSchedule.status !== "done" && durableSchedule.status !== "cancelled" ) { @@ -15709,7 +15700,7 @@ export function createAgentChatService(args: { const record = msg as unknown as Record; const messageSessionId = compactString(record.session_id); if (messageSessionId && runtime.sdkSessionId !== messageSessionId) { - await adoptClaudeProviderSessionId(managed, runtime, messageSessionId); + adoptClaudeProviderSessionId(managed, runtime, messageSessionId); } if (record.type === "conversation_reset") { @@ -16888,7 +16879,7 @@ export function createAgentChatService(args: { ? (msg as any).session_id.trim() : ""; if (messageSessionId.length > 0 && runtime.sdkSessionId !== messageSessionId) { - await adoptClaudeProviderSessionId(managed, runtime, messageSessionId); + adoptClaudeProviderSessionId(managed, runtime, messageSessionId); } if ((msg as any).type === "conversation_reset") { @@ -16925,7 +16916,7 @@ export function createAgentChatService(args: { ? initMsg.session_id.trim() : ""; if (initSessionId.length > 0 && runtime.sdkSessionId !== initSessionId) { - await adoptClaudeProviderSessionId(managed, runtime, initSessionId); + adoptClaudeProviderSessionId(managed, runtime, initSessionId); } reportedInitModel = normalizeReportedModelName(initMsg.model) ?? reportedInitModel; if (Array.isArray(initMsg.slash_commands)) { @@ -27797,7 +27788,7 @@ export function createAgentChatService(args: { }); if (args.managed.runtime?.kind === "claude") { await resetClaudeQuerySession(args.managed, args.managed.runtime, "session_reset", { clearSdkSessionId: true }); - await adoptClaudeProviderSessionId(args.managed, args.managed.runtime, placed.newSessionId); + adoptClaudeProviderSessionId(args.managed, args.managed.runtime, placed.newSessionId); args.managed.runtime.forkFromSdkSessionId = null; args.managed.runtimeInvalidated = false; } @@ -28417,7 +28408,7 @@ export function createAgentChatService(args: { const managed = ensureManagedSession(created.id); if (managed.runtime?.kind === "claude") { await resetClaudeQuerySession(managed, managed.runtime, "session_reset", { clearSdkSessionId: true }); - await adoptClaudeProviderSessionId(managed, managed.runtime, targetClaudeSessionId); + adoptClaudeProviderSessionId(managed, managed.runtime, targetClaudeSessionId); managed.runtime.forkFromSdkSessionId = null; managed.runtimeInvalidated = false; } @@ -32858,10 +32849,18 @@ export function createAgentChatService(args: { if (awaitingInputBefore && normalizedKind !== "wake") { throw new Error(`${PENDING_INPUT_SEND_BLOCKED_MESSAGE} Use chat.respondToInput for the pending input instead.`); } + const isScheduledWake = normalizedKind === "wake" && metadata?.scheduledWake != null; + const wakeNeedsQueue = isScheduledWake + && (managed.runtime?.kind === "claude" || managed.runtime?.kind === "opencode") + && ( + statusBefore === "active" + || managed.runtime.busy + ); const steerTarget = normalizedKind === "queue" || - ((normalizedKind === "auto" || normalizedKind === "wake") && statusBefore === "active"); + ((normalizedKind === "auto" || (normalizedKind === "wake" && !wakeNeedsQueue)) + && statusBefore === "active"); if (steerTarget) { const result = await steer({ sessionId, @@ -32885,6 +32884,55 @@ export function createAgentChatService(args: { }; } + if (wakeNeedsQueue) { + const runtime = managed.runtime; + if (!runtime || (runtime.kind !== "claude" && runtime.kind !== "opencode")) { + throw new Error("Scheduled wake delivery requires an active queueable chat runtime."); + } + const preparedWake = prepareSendMessage({ + sessionId, + text, + attachments, + contextAttachments, + metadata, + }); + if (!preparedWake) { + return { + sessionId, + kind: normalizedKind, + routedAction: "sendMessage", + statusBefore, + awaitingInputBefore, + delivery: "sent", + }; + } + const wakeId = randomUUID(); + const queued = enqueueSteerOrDrop( + managed, + runtime, + sessionId, + wakeId, + preparedWake.submittedText, + preparedWake.attachments, + preparedWake.contextAttachments, + preparedWake.resolvedAttachments, + preparedWake.metadata, + { displayText: preparedWake.visibleText }, + ); + if (!queued) { + throw new Error("The scheduled wake queue is full; the wake was not queued."); + } + return { + sessionId, + kind: normalizedKind, + routedAction: "sendMessage", + statusBefore, + awaitingInputBefore, + delivery: "queued", + queued: true, + }; + } + if (normalizedKind === "interrupt-replace") { if (managed.session.provider !== "claude") { await interrupt({ sessionId }); @@ -33860,7 +33908,7 @@ export function createAgentChatService(args: { if (managed.session.provider === "claude") { const runtime = ensureClaudeSessionRuntime(managed); resetClaudeQuerySession(managed, runtime, "session_reset"); - await adoptClaudeProviderSessionId(managed, runtime, originalThreadId); + adoptClaudeProviderSessionId(managed, runtime, originalThreadId); delete managed.session.continuityRecovery; managed.runtimeInvalidated = false; try { diff --git a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts index d276080df..4f11b958d 100644 --- a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts +++ b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts @@ -676,6 +676,113 @@ describe("createChatScheduledWorkScheduler", () => { scheduler.dispose(); }); + it("gives Claude native fire a grace window and cancels the ADE backstop after a native claim", async () => { + vi.useFakeTimers(); + vi.setSystemTime(START); + let state: ChatScheduledWorkState | null = null; + const fire = createFireMock(); + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => cloneState(state), + saveState: (next) => { state = structuredClone(next); }, + isGlobalPaused: () => false, + sessionState: () => "active", + fire, + }); + await scheduler.upsert(wakeup({ + fireAt: START, + durable: true, + provider: "claude", + providerScheduleId: "native-grace-claim", + })); + + await vi.advanceTimersByTimeAsync(5_000); + expect(fire).not.toHaveBeenCalled(); + expect(scheduler.claimNativeFire("session-1", "native-turn-grace")).toMatchObject({ + id: "wake-1", + status: "fired", + activeTurnId: "native-turn-grace", + }); + await vi.advanceTimersByTimeAsync(90_000); + + expect(fire).not.toHaveBeenCalled(); + scheduler.dispose(); + }); + + it("uses the Claude grace deadline for normal and genuinely late backstop fires", async () => { + vi.useFakeTimers(); + vi.setSystemTime(START); + const onTimeFire = createFireMock(); + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => null, + saveState: () => undefined, + isGlobalPaused: () => false, + sessionState: () => "active", + fire: onTimeFire, + }); + await scheduler.upsert(wakeup({ + fireAt: START, + durable: true, + provider: "claude", + providerScheduleId: "native-grace-backstop", + })); + + await vi.advanceTimersByTimeAsync(89_999); + expect(onTimeFire).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(onTimeFire).toHaveBeenCalledWith( + expect.objectContaining({ id: "wake-1", lateFlag: false }), + { late: false }, + ); + scheduler.dispose(); + + vi.setSystemTime(START + 91_001); + const lateFire = createFireMock(); + const lateScheduler = createChatScheduledWorkScheduler({ + loadState: () => storedState([wakeup({ + id: "wake-late", + fireAt: START, + durable: true, + provider: "claude", + providerScheduleId: "native-grace-late", + })]), + saveState: () => undefined, + isGlobalPaused: () => false, + sessionState: () => "active", + fire: lateFire, + }); + await lateScheduler.start(); + await vi.advanceTimersByTimeAsync(0); + + expect(lateFire).toHaveBeenCalledWith( + expect.objectContaining({ id: "wake-late", lateFlag: true }), + { late: true }, + ); + lateScheduler.dispose(); + }); + + it("fires non-Claude schedules at their exact fire time", async () => { + vi.useFakeTimers(); + vi.setSystemTime(START); + const fire = createFireMock(); + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => null, + saveState: () => undefined, + isGlobalPaused: () => false, + sessionState: () => "active", + fire, + }); + await scheduler.upsert(wakeup({ fireAt: START + 1_000 })); + + await vi.advanceTimersByTimeAsync(999); + expect(fire).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(fire).toHaveBeenCalledWith( + expect.objectContaining({ id: "wake-1", lateFlag: false }), + { late: false }, + ); + scheduler.dispose(); + }); + it("clears a native cron claim's active turn before re-arming", async () => { vi.useFakeTimers(); vi.setSystemTime(START); diff --git a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts index 2e71af85b..3999b55f9 100644 --- a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts +++ b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts @@ -2,6 +2,14 @@ import { nextCronFireAt as nextChatScheduledCronFireAt } from "../../../shared/c export { nextChatScheduledCronFireAt }; +/** + * Durable scheduled-work delivery for chat sessions. + * + * ADE state wins, SDK view is advisory: the mirrored ADE record is the source + * of truth for delivery. Claude's in-process scheduler is only a latency + * optimization, and its CronList view may drift after restarts or busy ticks. + */ + export type ChatScheduledWorkKind = "wakeup" | "cron" | "loop"; export type ChatScheduledWorkStatus = @@ -87,10 +95,15 @@ export type ChatScheduledWorkScheduler = { const MAX_TIMER_DELAY_MS = 2_147_483_647; const TIMER_LATE_TOLERANCE_MS = 1_000; +const NATIVE_FIRE_GRACE_MS = 90_000; const TERMINAL_HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000; const MAX_TERMINAL_HISTORY_RECORDS = 200; const LEGACY_PROVISIONAL_CRON_PREFIX = "cron-tool:"; +function nativeFireGraceMs(schedule: ChatScheduledWorkRecord): number { + return schedule.provider === "claude" ? NATIVE_FIRE_GRACE_MS : 0; +} + const defaultTimers: ChatScheduledWorkTimerApi = { setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), clearTimeout: (handle) => clearTimeout(handle as ReturnType), @@ -316,7 +329,8 @@ export function createChatScheduledWorkScheduler( } inFlight.add(scheduleId); - const late = overdueWhenArmed || currentTime - schedule.fireAt > TIMER_LATE_TOLERANCE_MS; + const lateThresholdMs = nativeFireGraceMs(schedule) + TIMER_LATE_TOLERANCE_MS; + const late = overdueWhenArmed || currentTime - schedule.fireAt > lateThresholdMs; schedule.status = "fired"; schedule.lastFiredAt = currentTime; schedule.lateFlag = late; @@ -348,15 +362,21 @@ export function createChatScheduledWorkScheduler( const arm = (schedule: ChatScheduledWorkRecord): void => { clearTimer(schedule.id); if (disposed || inFlight.has(schedule.id) || schedule.status !== "scheduled") return; + // Claude normally fires at fireAt (plus provider jitter). ADE waits through + // this grace window so claimNativeFire can claim the row first; this timer + // remains the backstop for busy, skipped, or dead provider processes. + const providerAdjustedFireAt = schedule.fireAt == null + ? undefined + : schedule.fireAt + nativeFireGraceMs(schedule); const nextAt = schedule.expiresAt == null - ? schedule.fireAt - : schedule.fireAt == null + ? providerAdjustedFireAt + : providerAdjustedFireAt == null ? schedule.expiresAt - : Math.min(schedule.fireAt, schedule.expiresAt); + : Math.min(providerAdjustedFireAt, schedule.expiresAt); if (nextAt == null || !Number.isFinite(nextAt)) return; const currentTime = now(); - const overdueWhenArmed = schedule.fireAt != null && schedule.fireAt < currentTime; + const overdueWhenArmed = providerAdjustedFireAt != null && providerAdjustedFireAt < currentTime; const delay = Math.min(MAX_TIMER_DELAY_MS, Math.max(0, nextAt - currentTime)); const handle = timers.setTimeout(() => { timerHandles.delete(schedule.id); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a65146741..569e7e608 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -306,7 +306,7 @@ Schema bootstrap in `kvDb.ts` creates ~104 tables. Anchor tables for agents read | `computer_use_artifacts` + `computer_use_artifact_links` | Canonical proof-artifact records and cross-domain ownership. | | `devices` + `sync_cluster_state` | Device registry and singleton host-authority row (host is `brain_device_id` internally; legacy naming). | | `local_crr_change_suppressions` | Local-only (excluded from CRR replication) high-water marks per `(table_name, site_id)`. `AdeDb.sync.exportChangesSince` filters local-site rows for any listed table at or below `through_db_version` so a viewer-join wipe of `devices` / `sync_cluster_state` cannot leak DELETE rows back to the host. See §13.1. | -| `kv` | Generic key-value store for UI layout, config trust hashes, misc settings, short-lived recovery records such as `agent-chat-parallel-launch::`, and the versioned durable Claude schedule state at `agent-chat:scheduled-work:v1`. The schedule value contains armed/terminal records, exact Claude SDK-session ownership, provider ids, expiry and terminal timestamps, plus paused chat ids; Electron-main and headless runtimes restore the same state. Startup drops legacy `cron-tool:` intent placeholders, quarantines older provider rows without ownership metadata as paused, and prunes terminal history after seven days or 200 rows. | +| `kv` | Generic key-value store for UI layout, config trust hashes, misc settings, short-lived recovery records such as `agent-chat-parallel-launch::`, and the versioned durable Claude schedule state at `agent-chat:scheduled-work:v1`. The schedule value contains armed/terminal records, exact Claude SDK-session ownership, provider ids, expiry and terminal timestamps, plus paused chat ids; Electron-main and headless runtimes restore the same state. Every successful Claude `CronCreate` is mirrored here as durable regardless of the provider's ineffective `durable` flag. Provider snapshots reconcile only rows owned by that exact SDK session, so an empty snapshot from a fresh session cannot cancel a prior owner's active rows. Startup drops legacy `cron-tool:` intent placeholders, quarantines older provider rows without ownership metadata as paused, and prunes terminal history after seven days or 200 rows. | Types for these tables are split into domain modules under `apps/desktop/src/shared/types/`. The barrel `index.ts` re-exports `core`, `models`, `git`, `lanes`, `conflicts`, `prs`, `files`, `sessions`, `chat`, `config`, `automations`, `packs`, `budget`, `usage`, and more. Feature docs under `docs/features/` call out the table subsets that are load-bearing for each surface. @@ -621,7 +621,7 @@ Most services described here live under `apps/desktop/src/main/services/ | `appControl/` | `appControlService.ts`, `appControlLaunchCommand.ts` | Chrome DevTools Protocol bridge for developer-owned Electron apps. Launches a chat-owned PTY running the user's dev command (or connects to an existing `--remote-debugging-port`), polls `/json` for ready CDP targets, attaches a long-lived `CdpClient` WebSocket, and exposes screenshot / DOM snapshot / hit-test / click / type / scroll / key dispatch / screencast frames. `appControlLaunchCommand.ts` owns the shell-command detection and debug-flag injection helpers for direct Electron and package-script launches. `inspectPoint` and `selectPoint` produce `AppControlContextItem`s for the chat composer (DOM packet + screenshot + source-file candidates resolved by `findSourceMatches` over an indexed tree of project source files). See [features/computer-use/app-control.md](./features/computer-use/app-control.md). | | `builtInBrowser/` | `builtInBrowserService.ts`, `builtInBrowserAgentAccess.ts`, `builtInBrowserActorCapabilities.ts`, `builtInBrowserAuthentication.ts`, `builtInBrowserProfileMigration.ts`, `builtInBrowserStateStore.ts`, `builtInBrowserNavigation.ts`, `builtInBrowserPermissions.ts`, `builtInBrowserWebAuthn.ts`, `desktopBridgeServer.ts` | In-app web browser owned by the main process. Every remote-content `WebContentsView` uses the single persistent `persist:ade-browser` storage profile (`storageProfileKey: "global"`), while service keys combine the ADE window id with a project/window/personal tab-collection key so visible tabs stay independent. Project roots route project commands and scratch observations; validated personal commands retain the personal tab collection and use the channel-specific machine-local browser-observation scratch root. Neither route partitions cookies or site storage. On first use, a bounded, idempotent migration copies unexpired persistent cookies from this channel's legacy project-derived partitions into the global profile without overwriting global cookies or copying session cookies; it preserves the old partition directories because Chromium DOM storage, IndexedDB, service-worker state, and WebAuthn credentials cannot be safely merged across partitions. The bounded machine-local state store restores HTTP(S)/blank tab URLs and the active tab for each collection, but never restores agent leases, lightweight browser sessions, or synthetic session cookies. The service caps each collection at 10 tabs, routes global-session network events back to their owning collection, drives OAuth popups and downloads, and emits targeted events. HTTP/proxy authentication uses a sandboxed, local credential prompt and passes values directly to Chromium without persisting or logging them; client-certificate requests use an explicit native chooser and only accept a certificate Electron offered. Permission requests are deny-by-default, limited to managed browser web contents and secure origins, and use persisted per-origin/embedding-origin decisions with a native human prompt; only Google's `storage-access` and `top-level-storage-access` requests retain a narrow accounts-domain compatibility exception. The Browser toolbar's trusted-renderer Profile panel exposes non-secret cookie/cache/flush diagnostics and list/remove/clear controls for remembered permission decisions; these operations are not bridged to agents or unbound CLI callers. A separate non-persistent agent-access controller requires a per-chat/lane native human grant for every non-local origin and for local origins with allowed privileged permissions; cross-origin navigations and redirects are intercepted, and sensitive popups are blocked until explicitly approved. The grant follows the agent-owned tab without a timer and clears only when an explicit trusted-renderer navigation reclaims the tab. Tabs carry owner/lease metadata. ADE-launched chats receive opaque in-memory browser actor capabilities bound to their trusted chat/lane/project or personal collection. The runtime requires the token and strips caller routing; Electron validates it in the issuing process, restores only the bound scope, forces `force: false`, and separately authenticates the bridge with the desktop launch's rotating token. Agents cannot force or impersonate a takeover, read another agent's tab status, inspect global cookie-domain diagnostics, or administer permissions. Browser sessions bind one workflow to one tab. Project observations live under `.ade/cache/browser-observations/`; personal observations live under the channel user-data `browser-observations/personal/` root, which is narrowly allowlisted for proof promotion. The issuer-restored scope selects the matching independent tab collection. Navigation/protocol policy lives in `builtInBrowserNavigation.ts`; WebAuthn account selection lives in `builtInBrowserWebAuthn.ts`. | | `automations/` | `automationService.ts`, `automationPlannerService.ts`, `automationIngressService.ts`, `automationSecretService.ts` | Rule lifecycle, NL → rule planner, inbound triggers, per-rule secrets. | -| `chat/` | `agentChatService.ts`, `chatScheduledWorkScheduler.ts`, `runtimeEvents.ts`, `claudeStructuredActivity.ts`, `openCodeStructuredActivity.ts`, `codexMcpElicitation.ts`, `buildClaudeV2Message.ts`, `markdownSlashCommandDiscovery.ts`, `claudeSlashCommandDiscovery.ts`, `codexSlashCommandDiscovery.ts`, `cursorSlashCommandDiscovery.ts`, `projectSlashCommandDiscovery.ts`, `slashCommandPromptExpansion.ts`, `cursorSdk*` (`cursorSdkPool.ts`, `cursorSdkWorker.ts`, `cursorSdkProtocol.ts`, `cursorSdkPolicy.ts`, `cursorSdkSystemPrompt.ts`, `cursorSdkEventMapper.ts`, `cursorSdkErrors.ts`), `droidSdkEventMapper.ts`, `sessionRecovery.ts` | Agent chat sessions (lane-scoped + orchestration worker/coordinator). Builds Claude messages, hosts the Cursor SDK in a Node worker pool with official local-store persistence, formalizes the cross-runtime event vocabulary, normalizes provider-native web/MCP/image activity into compact shared events, handles Codex app-server MCP elicitations and stalled-turn recovery, discovers and resolves provider-specific slash commands through a shared markdown engine, recovers sessions on restart, derives prompt-based lane names for parallel model launches, keeps Claude Agent SDK streams alive for scheduled wake/cron/background work after visible turns, emits transcript retractions for provider-superseded assistant rows, and manages Codex app-server goals with persisted, unlimited-budget session state. Claude remains authoritative for schedule tool success and canonical ids: ADE mutates its mirror only from successful `PostToolUse`, stores `ScheduleWakeup`, durable `CronCreate`, and `/loop` records in `kv`, and reconciles Stop/SubagentStop provider snapshots. The scheduler restores timers, coalesces missed occurrences to one late fire, applies chat/global pause state, queues collisions at the active turn boundary through `messageSession(kind: "wake")`, cold-starts idle sessions when necessary, expires recurring crons after seven days, and emits lifecycle rows while summaries expose management state. Cancellation of Claude-owned jobs routes through `CronDelete` and remains visible until provider confirmation. There is no scheduled-work-specific spend cap. | +| `chat/` | `agentChatService.ts`, `chatScheduledWorkScheduler.ts`, `runtimeEvents.ts`, `claudeStructuredActivity.ts`, `openCodeStructuredActivity.ts`, `codexMcpElicitation.ts`, `buildClaudeV2Message.ts`, `markdownSlashCommandDiscovery.ts`, `claudeSlashCommandDiscovery.ts`, `codexSlashCommandDiscovery.ts`, `cursorSlashCommandDiscovery.ts`, `projectSlashCommandDiscovery.ts`, `slashCommandPromptExpansion.ts`, `cursorSdk*` (`cursorSdkPool.ts`, `cursorSdkWorker.ts`, `cursorSdkProtocol.ts`, `cursorSdkPolicy.ts`, `cursorSdkSystemPrompt.ts`, `cursorSdkEventMapper.ts`, `cursorSdkErrors.ts`), `droidSdkEventMapper.ts`, `sessionRecovery.ts` | Agent chat sessions (lane-scoped + orchestration worker/coordinator). Builds Claude messages, hosts the Cursor SDK in a Node worker pool with official local-store persistence, formalizes the cross-runtime event vocabulary, normalizes provider-native web/MCP/image activity into compact shared events, handles Codex app-server MCP elicitations and stalled-turn recovery, recovers sessions on restart, derives prompt-based lane names for parallel model launches, keeps Claude Agent SDK streams alive for scheduled wake/cron/background work after visible turns, emits transcript retractions for provider-superseded assistant rows, and manages Codex app-server goals with persisted, unlimited-budget session state. Claude remains authoritative for schedule tool success and canonical ids, while ADE's mirror is authoritative for delivery: ADE mutates it only from successful `PostToolUse`, stores `ScheduleWakeup`, every successful `CronCreate` (always `durable: true` regardless of Claude's ineffective flag), and `/loop` records in `kv`, and scopes Stop/SubagentStop reconciliation to the exact provider-session owner so a new session's empty snapshot preserves prior-owner rows. The SDK gets the native fire opportunity at `fireAt`; ADE's timer waits through a 90-second grace window, then backstops a skipped, busy, or dead provider without marking that designed delay late. The scheduler restores timers, coalesces missed occurrences to one late fire, applies chat/global pause state, queues busy Claude/OpenCode scheduled wakes as their own message and starts a new turn at the active turn boundary rather than steering mid-turn, cold-starts idle sessions when necessary, expires recurring crons after seven days, and emits lifecycle rows while summaries expose management state. Cancellation of Claude-owned jobs routes through `CronDelete` and remains visible until provider confirmation. There is no scheduled-work-specific spend cap. | | `computerUse/` | `computerUseArtifactBrokerService.ts`, `controlPlane.ts`, `localComputerUse.ts`, `syntheticToolResult.ts` | Proof-artifact broker (ingests, owner links, review state, routing), control-plane snapshot helpers, macOS capture capability descriptor, and the synthetic-tool-result helper used by the Claude compaction path. `proofObserver.ts` was removed in the rebuild — there is no passive auto-ingest. Direct Codex Computer Use executable resolution lives outside this folder in `main/utils/codexComputerUse.ts` because it configures provider runtimes rather than ingesting proof. | | `proof/` | `agentBrowserArtifactAdapter.ts` | Parses agent-browser payloads into broker inputs. | | `config/` | `projectConfigService.ts`, `laneOverlayMatcher.ts` | Load/save `.ade/ade.yaml` + `local.yaml`; trust enforcement; lane overlays. | diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index bdec1f498..c3bc444b0 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -25,7 +25,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, external chat import orchestration (`importExternalChatSession` for Claude/Codex sessions discovered by the external-session service), turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, durable chat transcript replay/storage compaction, slash-command discovery/merge (delegates to per-provider discovery modules and `slashCommandPromptExpansion` for unified prompt expansion), and active-workload detection used by project/window close guards. Codex non-retrying app-server failures are deduplicated by turn plus semantic error identity across the early `error` notification and terminal `turn/completed`; retrying notifications (`willRetry: true`) remain provider-health notices while the turn stays active. Lane naming runs through the session-intelligence prompt path, retries the configured/requested/default title models — the auto-title candidate order prefers the configured `titleModelId` before the session's `requestedModelId` — then falls back to a deterministic prompt slug; branch uniqueness is handled by the lane id suffix added by lane creation. Tracks Fast Mode with the legacy `codexFastMode: boolean` session field for every provider whose descriptor advertises `serviceTiers: ["fast"]`; Codex forwards it as `serviceTier: "fast" \| null` on every `thread/start` and `turn/start` JSON-RPC call, while Cursor SDK sessions resolve it through discovered model parameters (see [Agent Routing](agent-routing.md#provider-service-tiers-fast-mode)). Codex chat goals are managed through the app-server `thread/goal/get` / `set` / `clear` RPCs, persisted in session summaries, validated to the provider's 4,000-character objective limit, and normalized to ADE's unlimited-budget policy by sending `tokenBudget: null` and clearing provider-reported budgets. `applyCodexEffectiveThreadState` accepts a `requestedCodexPolicy` option and uses `shouldPreserveRequestedCodexPolicy` to keep ADE-controlled picker selections authoritative when the lifecycle response echoes an older thread policy (prevents a manual Plan→Edit switch from snapping back); it also syncs the abstract `permissionMode` via `syncLegacyPermissionMode` after every policy application. Whenever an `updateSession` touches any permission/interaction/mode field, the service also emits a transient `session_meta_updated` chat event carrying the recomputed mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`/`codexSandbox`/`codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, and the `cursorModeSnapshot`) so any other client viewing the same session — a desktop refreshing a session an iOS device just re-moded, or vice versa — updates its composer controls live. It is a direct state patch, emitted after the Cursor policy sync so `cursorModeSnapshot` reflects the recomputed mode, and is kept off the session-list refresh path. Builds ADE guidance from the active lane worktree so Agent Skill roots are lane-scoped in persistent system/developer prompts and provider fallback injection. Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). When the session has Linear issues attached (`session_linear_issues`), `buildAgentRuntimeEnv` also materializes them into a per-session context file via `writeSessionLinearIssueContextFile` (`//linear-issues.json`, written atomically; stale files cleared when nothing is attached) and sets `ADE_LINEAR_ISSUE_IDS` (comma-joined identifiers) + `ADE_LINEAR_CONTEXT_FILE` so the agent reads its issue context without Linear credentials. Attaching a `linear_issue` context attachment at run time calls `laneService.attachLinearIssueToSession({ chatSessionId, issues, role: "worked", source: "chat_attach", includeInPr: true })` so the link is persisted even for standalone (laneless) chats; when the session has a lane it additionally runs `laneService.linkLinearIssues` for the lane/PR-card semantics. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks; interrupted Claude turns stop active subagents before emitting stopped `subagent_result`s, and every `subagent_result` is gated on a previously emitted `subagent_started` (tracked in `emittedSubagentStartIds`) so an interrupt can never emit a phantom stopped card for a subagent that never announced — terminal events clear both the taskId and agentId aliases. A plain Claude Code task run (`task_type` `other`, no agent metadata — e.g. "Re-run affected test files") is tracked for cleanup but never surfaces subagent rows. Claude resume paths run `claudeThinkingTranscriptRepair` before loading a transcript, and the runtime self-heals the same corruption after the Anthropic thinking-block 400 error. Full-auto plan acceptance emits the same plan-mode exit notice as the manual approval path so the renderer composer chip can update even when the session refresh races with compaction. Cursor SDK setup records interrupts that arrive while the worker is still being acquired, releases the acquired generation if setup loses the race, and suppresses false provider-health failures for user-initiated setup interrupts. Cursor provider slash commands use a dedicated discovery path (`cursorSlashCommandDiscovery`) instead of falling through to the generic filesystem-backed list. Claude query startup is single-flight: concurrent `ensureClaudeQuery` callers latch onto one in-flight `queryStartPromise`, and a per-runtime `queryGeneration` token aborts and reaps a start that a reset or interrupt superseded, so a resumed session never spawns twin subprocesses; both reset and interrupt reap the SDK subprocess through `claudeSubprocessReaper` because a closed `query()` still leaves a live `claude --resume` child. `run_in_background` shell tasks (SDK `task_type` `local_bash`/`background`) survive turn boundaries — the query stays alive across turns and delivers their real completion — so only interrupt, reset/dispose, or a host-restart rebind settle them as stopped; a reset that orphans still-open background tasks emits one `system_notice` that they were stopped without reporting completion, and background-task titles are sticky (the first spawn description is reused through the terminal row). A durable per-`(SDK message id, content index)` emitted-text record keeps a re-delivered assistant snapshot (after a stream-dedup reset from steer, message interleave, or idle handoff) from doubling the transcript. Claude `TaskCreate`/`TaskUpdate` tracking keys creates by tool-use id and remaps the harness's ordinal task id onto the Nth created task; an update for an id it cannot resolve or describe changes nothing rather than fabricating a todo row. `steer()` returns `AgentChatSteerResult` (`{ steerId, queued, reason?: "queue_full" }`); reasoning effort is normalized and applied at steer delivery, and an active Claude `interrupt-replace` uses SDK priority `now` without tearing down the query or its background work. When a spawned child chat ends, `reportChildSpawnEnded` reports its outcome to the spawner according to the child's `spawnKind` (see [Spawn types and completion reporting](#spawn-types-and-completion-reporting)); spawned agents also inherit `ADE_PARENT_CHAT_SESSION_ID` / `ADE_SPAWN_KIND` and a subagent self-report guidance line. Large service file. | | `apps/desktop/src/main/services/chat/providerResumeClassifier.ts` | Classifies Codex resume failures without conflating missing threads with MCP/provider-environment or transient transport failures; rollout-file evidence keeps a locally known thread from being declared missing. | | `apps/desktop/src/renderer/components/chat/ChatContinuityRecoveryCard.tsx` | Renders the explicit continuity-recovery choices from a `system_notice`: retry the preserved thread, reconstruct from durable ADE history, or start a separate chat. | -| `apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts` | Runtime-owned durable mirror and wake coordinator for Claude `ScheduleWakeup`, durable `CronCreate`, and `/loop`. Persists versioned records, provider ids, expiry/terminal timestamps, and per-chat pause state in the project SQLite `kv` store; restores and re-arms them on service start; coalesces overdue work to one late fire; and reports transitions back to `agentChatService`. Startup migration drops the pre-1.2.27 `cron-tool:` intent placeholders that Claude could never cancel, quarantines older active provider rows in a paused state for operator review, and bounds terminal history to the newest 200 rows or seven days. Uses injected time/timer/persistence adapters so restart, pause, collision, migration, expiry, and catch-up behavior can be tested without Electron. | +| `apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts` | Runtime-owned durable mirror and wake coordinator for Claude `ScheduleWakeup`, every successful `CronCreate`, and `/loop`. ADE's mirror is the delivery source of truth; Claude's native scheduler is an advisory latency path. The scheduler gives native Claude fire 90 seconds to claim a due record before ADE's timer backstops it, while non-Claude schedules retain their exact fire time. It persists versioned records, provider ids, expiry/terminal timestamps, and per-chat pause state in the project SQLite `kv` store; restores and re-arms them on service start; coalesces overdue work to one late fire; and reports transitions back to `agentChatService`. Startup migration drops the pre-1.2.27 `cron-tool:` intent placeholders that Claude could never cancel, quarantines older active provider rows in a paused state for operator review, and bounds terminal history to the newest 200 rows or seven days. Uses injected time/timer/persistence adapters so restart, pause, collision, migration, expiry, and catch-up behavior can be tested without Electron. | | `apps/desktop/src/main/services/chat/externalChatHistoryImport.ts` | Converts external Claude JSONL and Codex thread-turn history into ADE `AgentChatEventEnvelope` rows. It reads at most the last 32 MB of source transcript bytes, keeps the newest 2,000 imported content events, emits system notices for provenance/truncation, drops metadata-only/provider-wrapper user rows without stripping user-authored JSX/XML, preserves failed Claude tool-result status, maps user/assistant text plus tool calls/results/file changes/commands/search/image events where available, and derives a fallback imported-chat title from the first user or assistant text. | | `apps/desktop/src/main/services/chat/runtimeEvents.ts` | Canonical cross-runtime event vocabulary (`turn.*`, `content.delta`, `tool.*`, `subagent.*`, teammate/task events, compaction boundaries) plus shims between legacy `AgentChatEvent` rows and the canonical runtime envelope. Claude emits canonical subagent events alongside the legacy rows while the other adapters migrate. | | `apps/desktop/src/main/services/chat/contextCompactionEmitter.ts` | Normalizes Claude, Codex, OpenCode, Cursor, and Droid compaction lifecycle events into provider-tagged `context_compact` rows. It pairs started/completed boundaries, preserves provider-reported pre/post token counts and duration, and maintains the per-session compaction count used by transcript surfaces. | @@ -141,12 +141,15 @@ render them, but neither one *runs* them. ## Durable Claude scheduled work Claude SDK chats can arm `ScheduleWakeup` one-shots, `CronCreate` jobs, and -`/loop` self-pacing work. Claude remains the provider authority: ADE records a -job only after the SDK's successful `PostToolUse` hook returns its canonical -id, clamped fire time, recurrence, and `durable` value. A failed tool call or a -tool-use intent never creates an ADE schedule. Non-durable `CronCreate` jobs -remain provider-only transcript activity; only `durable: true` cron jobs enter -ADE's management store. `CronCreate` always creates a new provider job, so an +`/loop` self-pacing work. Claude remains the authority for whether the provider +tool succeeded and for its canonical job id, but ADE's durable mirror is the +source of truth for delivery. ADE records a job only after the SDK's successful +`PostToolUse` hook returns its canonical id, clamped fire time, and recurrence. +A failed tool call or a tool-use intent never creates an ADE schedule. Every +successful `CronCreate` enters ADE's management store with `durable: true`, +even when Claude reports or receives `durable: false`; the SDK schema treats +that flag as ineffective, so provider-only cron delivery is not a supported +state. `CronCreate` always creates a new provider job, so an agent replacing or resetting a watcher must `CronList` + `CronDelete` the old job before creating its replacement rather than relying on prompt de-duplication. @@ -160,9 +163,13 @@ and late marker. Electron main and headless `ade serve` construct the same scheduler. Claude's Stop/SubagentStop `session_crons` snapshot reconciles the mirror with provider state, while ADE keeps the full prompt captured at `PostToolUse` instead of replacing it with the hook's truncated snapshot. -If Claude replaces the SDK session pointer during compaction or recovery, ADE -pauses jobs owned by the earlier SDK session; it never treats the replacement -session's empty `CronList` as authority to delete or resume those older jobs. +Snapshots are scoped to their exact provider-session owner: a fresh provider +session's empty `session_crons` snapshot cannot cancel, pause, or re-own active +rows created by the prior provider session. If a later snapshot actually +reports an existing unpaused Claude job by canonical id, ADE may adopt that row +to the current provider session. Explicit continuity loss, disposal, or lineage +replacement still quarantines rows whose old provider owner is no longer +reachable. At service start, armed records are loaded and timers are restored. If the host was asleep or the runtime was down, an overdue one-shot fires once with @@ -172,6 +179,14 @@ does not replay every missed interval. Paused schedules remain armed. Work that became overdue while either the chat or global pause was active follows the same one-late-fire rule after resume. +For Claude-owned rows, the SDK normally fires natively at the scheduled time +and ADE's `claimNativeFire` claims the mirrored record. ADE arms its own timer +for 90 seconds later, giving provider jitter and native delivery time to win; +the timer becomes the backstop when Claude is busy, skips a tick, or its process +is unavailable. A claimed native fire cancels that backstop. Firing at the end +of this grace window is on time (`late: false`); only a materially later fire is +late. Schedules without `provider: "claude"` still fire at their exact `fireAt`. + Session teardown distinguishes deliberate end from runtime lifecycle. Deleting or archiving a chat cancels every durable schedule owned by that chat. Claude-owned jobs are not hidden first: ADE pauses the mirror, sends the owning chat an exact-id @@ -204,9 +219,10 @@ is cancelled, while durable provider-owned work is quarantined as paused. Delivery reuses the session peer-message path with `kind: "wake"`. A live, idle Claude query is resumed through its existing idle reader; otherwise the service resumes/cold-starts the session and sends a synthetic turn carrying -`metadata.scheduledWake`. If a normal turn is active, wake delivery joins the -steer queue and begins at the turn boundary instead of interrupting the -model. One-shot lifecycle is `scheduled` -> `fired` -> `completed`; completed +`metadata.scheduledWake`. If a Claude or OpenCode turn is busy, the scheduled +wake is queued as a new message and starts its own turn at the next boundary; +it is never steered into or dispatched during the running turn. One-shot +lifecycle is `scheduled` -> `fired` -> `completed`; completed wakeups move into Chat Info history. Recurring cron rows briefly record the fire, retain `lastRunAt`, and return to `scheduled` with their next fire time. Durable recurring Claude crons expire after seven days, matching the SDK @@ -295,7 +311,8 @@ Controls and summaries project this runtime state rather than owning it: errors in the same result remain user-visible. The successful `PostToolUse` hook is the authority for schedule creation/deletion metadata; Stop and SubagentStop snapshots reconcile provider state. ADE's durable mirror re-arms - `ScheduleWakeup`, durable `CronCreate`, and `/loop` after a runtime restart. + `ScheduleWakeup`, every successful `CronCreate`, and `/loop` after a runtime + restart. SDK-origin and cold-start wake paths both produce synthetic turns plus `scheduled_work_update` snapshots for desktop, ADE Code, and iOS Chat Info. SessionStore reads are limited to resume-time From fe0776b4dae70a4f6dde4e5e80113e058d284f69 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:01:11 -0400 Subject: [PATCH 2/5] =?UTF-8?q?feat(chat):=20chat.createScheduledWork=20ac?= =?UTF-8?q?tion=20=E2=80=94=20any=20runtime=20can=20schedule=20durable=20w?= =?UTF-8?q?akes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New action next to list/cancel/pause: creates provider-neutral durable chat schedules (recurring cron or one-shot), scoped to the caller's own session by default via daemon arg scoping; cross-session requests are denied for bound agents. Typed CLI: ade chat scheduled-work create. Remote commands chat.createScheduledWork and chat.setScheduledWorkPaused registered for mobile; desktop IPC/preload exposed. Co-Authored-By: Claude Fable 5 --- apps/ade-cli/README.md | 1 + apps/ade-cli/src/adeRpcServer.test.ts | 43 +++++ apps/ade-cli/src/adeRpcServer.ts | 22 ++- apps/ade-cli/src/cli.test.ts | 56 ++++++ apps/ade-cli/src/cli.ts | 28 ++- .../sync/syncRemoteCommandService.test.ts | 43 +++++ .../services/sync/syncRemoteCommandService.ts | 19 ++ .../src/main/services/adeActions/registry.ts | 6 + .../services/chat/agentChatService.test.ts | 163 ++++++++++++++++++ .../main/services/chat/agentChatService.ts | 98 ++++++++++- .../chat/chatScheduledWorkScheduler.test.ts | 33 +++- .../chat/chatScheduledWorkScheduler.ts | 1 + .../src/main/services/ipc/registerIpc.ts | 10 ++ .../main/services/usage/usageStatsStore.ts | 10 +- .../usage/usageTrackingService.test.ts | 4 +- apps/desktop/src/preload/global.d.ts | 5 + apps/desktop/src/preload/preload.test.ts | 15 ++ apps/desktop/src/preload/preload.ts | 17 ++ apps/desktop/src/renderer/browserMock.ts | 3 + apps/desktop/src/shared/ipc.ts | 1 + apps/desktop/src/shared/types/chat.ts | 13 ++ apps/desktop/src/shared/types/sync.ts | 2 + docs/ARCHITECTURE.md | 12 +- docs/features/agents/README.md | 10 +- docs/features/chat/README.md | 47 +++-- .../sync-and-multi-device/ios-companion.md | 9 + .../sync-and-multi-device/remote-commands.md | 12 ++ 27 files changed, 636 insertions(+), 47 deletions(-) diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index cd02eea2b..5f7b068de 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -391,6 +391,7 @@ ade chat message session-id --kind auto --text "status/context" ade chat steer session-id --text "active-turn context" ade chat schedules session-id --pause # pause this chat's durable wakeups/cron/loops (omit flag to inspect, --resume to re-arm) ade chat scheduled-work list [session-id] --all # list durable jobs; --all includes recent terminal history +ade chat scheduled-work create --cron "9,29,49 * * * *" --prompt "Check CI and report" --once --reason "CI check" --session session-id ade chat scheduled-work cancel session-id job-id # cancel one job; Claude-native jobs request CronDelete in the owning chat ade chat wait session-id --for idle --timeout-ms 600000 ade chat recover session-id --turn turn-id --action nudge # wait | nudge | retry | resume diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 10e198bd4..90e1c0d30 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -489,6 +489,21 @@ function createRuntime() { lastActivityAt: "2026-03-17T19:00:00.000Z", createdAt: "2026-03-17T19:00:00.000Z", })), + createScheduledWork: vi.fn(async ({ sessionId, cron, prompt, recurring = true }: { + sessionId: string; + cron: string; + prompt: string; + recurring?: boolean; + }) => ({ + item: { + id: `action:${sessionId}:schedule-1`, + sessionId, + cron, + prompt, + kind: recurring ? "cron" : "wakeup", + status: "scheduled", + }, + })), listScheduledWork: vi.fn(async ({ sessionId, includeTerminal }: { sessionId?: string; includeTerminal?: boolean; @@ -3194,6 +3209,26 @@ describe("adeRpcServer", () => { text: "own-chat write", }); + const ownScheduledWorkCreate = await callTool(handler, "run_ade_action", { + domain: "chat", + action: "createScheduledWork", + args: { cron: "0 * * * *", prompt: "Check CI." }, + }); + expect(ownScheduledWorkCreate?.isError).toBeUndefined(); + expect(fixture.runtime.agentChatService.createScheduledWork).toHaveBeenCalledWith({ + sessionId: "chat-1", + cron: "0 * * * *", + prompt: "Check CI.", + }); + + const deniedScheduledWorkCreate = await callTool(handler, "run_ade_action", { + domain: "chat", + action: "createScheduledWork", + args: { sessionId: "chat-2", cron: "0 * * * *", prompt: "Cross-chat schedule." }, + }); + expect(deniedScheduledWorkCreate.isError).toBe(true); + expect(fixture.runtime.agentChatService.createScheduledWork).toHaveBeenCalledTimes(1); + const deniedScheduledWorkList = await callTool(handler, "run_ade_action", { domain: "chat", action: "listScheduledWork", @@ -3825,6 +3860,14 @@ describe("adeRpcServer", () => { expect(scheduledWorkList.isError).toBe(true); expect(fixture.runtime.agentChatService.listScheduledWork).not.toHaveBeenCalled(); + const scheduledWorkCreate = await callTool(handler, "run_ade_action", { + domain: "chat", + action: "createScheduledWork", + args: { sessionId: "chat-2", cron: "0 * * * *", prompt: "Check CI." }, + }); + expect(scheduledWorkCreate.isError).toBe(true); + expect(fixture.runtime.agentChatService.createScheduledWork).not.toHaveBeenCalled(); + const scheduledWorkCancel = await callTool(handler, "run_ade_action", { domain: "chat", action: "cancelScheduledWork", diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 03414b452..5089ee74c 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -2382,18 +2382,21 @@ function scopeTerminalAdeActionArgs( } } +const SCOPED_CHAT_ACTIONS = new Set([ + "readTranscript", + "sendMessage", + "createScheduledWork", + "listScheduledWork", + "cancelScheduledWork", +]); + function scopeChatAdeActionArgs( session: SessionState, action: string, chatArgs: Record, ): Record { const method = `run_ade_action:chat.${action}`; - if ( - action !== "readTranscript" - && action !== "sendMessage" - && action !== "listScheduledWork" - && action !== "cancelScheduledWork" - ) return chatArgs; + if (!SCOPED_CHAT_ACTIONS.has(action)) return chatArgs; if (isUnboundAdeCliCaller(session)) return chatArgs; const scopedArgs = { ...chatArgs }; @@ -3481,12 +3484,7 @@ async function runTool(args: { } else if ( !callerIsCto && domain === "chat" - && ( - action === "readTranscript" - || action === "sendMessage" - || action === "listScheduledWork" - || action === "cancelScheduledWork" - ) + && SCOPED_CHAT_ACTIONS.has(action) ) { scopedObjectArgs = scopeChatAdeActionArgs( session, diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index f8788050e..e93cf90d8 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -4198,6 +4198,62 @@ describe("ADE CLI", () => { }, }); + const create = expectExecutePlan(buildCliPlan([ + "chat", + "scheduled-work", + "create", + "--cron", + "9,29,49 * * * *", + "--prompt", + "Check CI and report", + ])); + expect(create.label).toBe("chat scheduled-work create"); + expect(create.steps[0]?.params).toMatchObject({ + arguments: { + domain: "chat", + action: "createScheduledWork", + args: { + cron: "9,29,49 * * * *", + prompt: "Check CI and report", + recurring: true, + }, + }, + }); + + const createOnce = expectExecutePlan(buildCliPlan([ + "chat", + "scheduled-work", + "create", + "--cron", + "15 10 * * 1", + "--prompt", + "Prepare the weekly report", + "--once", + "--reason", + "Weekly report", + "--session", + "chat-1", + ])); + expect(createOnce.steps[0]?.params).toMatchObject({ + arguments: { + action: "createScheduledWork", + args: { + sessionId: "chat-1", + cron: "15 10 * * 1", + prompt: "Prepare the weekly report", + recurring: false, + reason: "Weekly report", + }, + }, + }); + expect(() => buildCliPlan([ + "chat", + "scheduled-work", + "create", + "--prompt", + "Missing cron", + ])).toThrow(/cron is required/); + for (const alias of ["schedule", "schedules"]) { expect(expectExecutePlan(buildCliPlan(["chat", alias, "list", "chat-1"])).steps[0]?.params) .toMatchObject({ diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 07ee75e28..629bde341 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1644,6 +1644,8 @@ const HELP_BY_COMMAND: Record = { $ ade chat schedules --pause Pause this chat's durable wakeups/cron/loops $ ade chat schedules Inspect pause state + next armed wake (--resume to re-arm) $ ade chat scheduled-work list [session] List durable jobs (--all includes recent history) + $ ade chat scheduled-work create --cron "" --prompt "" [--once] + Optional: --reason "" --session $ ade chat scheduled-work cancel Cancel one job; Claude crons also request CronDelete $ ade new chat --mode cli --lane --provider claude --reasoning-effort ultracode --prompt "fix" Start a tracked provider CLI session @@ -6633,7 +6635,7 @@ function buildChatPlan(args: string[]): CliPlan { || sub === "schedules" || sub === "schedule" ) - && (args[0] === "list" || args[0] === "cancel") + && (args[0] === "list" || args[0] === "create" || args[0] === "cancel") ? firstStandalonePositional(args) : null; const sessionId = @@ -7280,6 +7282,29 @@ function buildChatPlan(args: string[]): CliPlan { sub === "schedule" || sub === "scheduled-work" ) { + if (scheduledWorkOperation === "create") { + const cron = requireValue(readValue(args, ["--cron"]), "cron"); + const prompt = requireValue(readValue(args, ["--prompt", "--text"]), "prompt"); + const reason = readValue(args, ["--reason"]); + return { + kind: "execute", + label: "chat scheduled-work create", + steps: [ + actionStep( + "result", + "chat", + "createScheduledWork", + collectGenericObjectArgs(args, { + ...(sessionId ? { sessionId } : {}), + cron, + prompt, + recurring: !readFlag(args, ["--once"]), + ...(reason ? { reason } : {}), + }), + ), + ], + }; + } if (scheduledWorkOperation === "list") { return { kind: "execute", @@ -11242,6 +11267,7 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--compare-to", "--content", "--context-file", + "--cron", "--cwd", "--data", "--cpu", diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index 22e332c98..e60f80120 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -478,6 +478,47 @@ describe("createSyncRemoteCommandService", () => { expect(cancelScheduledWork).toHaveBeenCalledTimes(1); }); + it("routes scheduled-work creation and pause control through owner-only mobile commands", async () => { + const createScheduledWork = vi.fn(async (args: Record) => ({ item: args })); + const setScheduledWorkPaused = vi.fn(async (args: { sessionId: string; paused: boolean }) => ({ + ...args, + nextWakeAt: null, + })); + const { service } = createService({ + agentChatService: { createScheduledWork, setScheduledWorkPaused }, + }); + + expect(service.getDescriptor("chat.createScheduledWork")).toEqual({ + action: "chat.createScheduledWork", + scope: "project", + policy: { viewerAllowed: false, queueable: false }, + }); + expect(service.getDescriptor("chat.setScheduledWorkPaused")).toEqual({ + action: "chat.setScheduledWorkPaused", + scope: "project", + policy: { viewerAllowed: false, queueable: false }, + }); + await service.execute(makePayload("chat.createScheduledWork", { + sessionId: "chat-1", + cron: "0 * * * *", + prompt: "Check CI.", + recurring: false, + reason: "CI watcher", + })); + expect(createScheduledWork).toHaveBeenCalledWith({ + sessionId: "chat-1", + cron: "0 * * * *", + prompt: "Check CI.", + recurring: false, + reason: "CI watcher", + }); + await service.execute(makePayload("chat.setScheduledWorkPaused", { + sessionId: "chat-1", + paused: true, + })); + expect(setScheduledWorkPaused).toHaveBeenCalledWith({ sessionId: "chat-1", paused: true }); + }); + it("rejects unsupported Codex recovery actions before invoking chat", async () => { const recoverCodexTurn = vi.fn(); const { service } = createService({ agentChatService: { recoverCodexTurn } }); @@ -1116,7 +1157,9 @@ describe("createSyncRemoteCommandService", () => { "work.getSessionDelta", "chat.getSlashCommands", "chat.resolveSmartLinkPreview", + "chat.createScheduledWork", "chat.cancelScheduledWork", + "chat.setScheduledWorkPaused", "chat.getParallelLaunchState", "chat.setParallelLaunchState", "chat.handoff", diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index f2e504c7c..78776579a 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -3818,11 +3818,30 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio }); register("chat.getSummary", { viewerAllowed: true }, async (payload) => requireService(args.agentChatService, "Agent chat service not available.").getSessionSummary(parseAgentChatGetSummaryArgs(payload).sessionId)); + register("chat.createScheduledWork", { viewerAllowed: false, queueable: false }, async (payload) => { + const recurring = asOptionalBoolean(payload.recurring); + const reason = asTrimmedString(payload.reason); + return requireService(args.agentChatService, "Agent chat service not available.").createScheduledWork({ + sessionId: requireString(payload.sessionId, "chat.createScheduledWork requires sessionId."), + cron: requireString(payload.cron, "chat.createScheduledWork requires cron."), + prompt: requireString(payload.prompt, "chat.createScheduledWork requires prompt."), + ...(recurring !== undefined ? { recurring } : {}), + ...(reason ? { reason } : {}), + }); + }); register("chat.cancelScheduledWork", { viewerAllowed: true, queueable: false }, async (payload) => requireService(args.agentChatService, "Agent chat service not available.").cancelScheduledWork({ sessionId: requireString(payload.sessionId, "chat.cancelScheduledWork requires sessionId."), scheduleId: requireString(payload.scheduleId, "chat.cancelScheduledWork requires scheduleId."), })); + register("chat.setScheduledWorkPaused", { viewerAllowed: false, queueable: false }, async (payload) => { + const paused = asOptionalBoolean(payload.paused); + if (paused === undefined) throw new Error("chat.setScheduledWorkPaused requires paused."); + return requireService(args.agentChatService, "Agent chat service not available.").setScheduledWorkPaused({ + sessionId: requireString(payload.sessionId, "chat.setScheduledWorkPaused requires sessionId."), + paused, + }); + }); register("chat.getChatEventHistory", { viewerAllowed: true }, async (payload): Promise => { const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); const sessionId = requireString(payload.sessionId, "chat.getChatEventHistory requires sessionId."); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 5efc7ee57..a80d0a0db 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -506,6 +506,7 @@ export const ADE_ACTION_ALLOWLIST: Partial { expect(service.hasActiveWorkloads()).toBe(false); }); + it("creates durable recurring and one-shot scheduled work and validates inputs and session state", async () => { + const scheduledWork = createScheduledWorkDb(); + const events: AgentChatEventEnvelope[] = []; + const { service, sessionService } = createService({ + db: scheduledWork.db, + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4-codex", + }); + + const recurring = await service.createScheduledWork({ + sessionId: session.id, + cron: "9,29,49 * * * *", + prompt: " Check CI and report. ", + reason: " CI watcher ", + }); + const oneShot = await service.createScheduledWork({ + sessionId: session.id, + cron: "15 10 * * 1", + prompt: "Prepare the weekly report.", + recurring: false, + }); + + expect(recurring.item).toMatchObject({ + id: `action:${session.id}:test-uuid-2`, + sessionId: session.id, + kind: "cron", + status: "scheduled", + prompt: "Check CI and report.", + reason: "CI watcher", + cron: "9,29,49 * * * *", + durable: true, + }); + expect(oneShot.item).toMatchObject({ + id: `action:${session.id}:test-uuid-3`, + kind: "wakeup", + status: "scheduled", + durable: true, + }); + expect(await service.listScheduledWork({ sessionId: session.id })).toEqual([ + recurring.item, + oneShot.item, + ]); + const storedSchedules = scheduledWork.readState()?.schedules ?? []; + expect(storedSchedules).toEqual([ + expect.objectContaining({ + id: recurring.item.id, + kind: "cron", + expiresAt: expect.any(Number), + }), + expect.objectContaining({ + id: oneShot.item.id, + kind: "wakeup", + }), + ]); + expect(storedSchedules[0]?.provider).toBeUndefined(); + expect(storedSchedules[1]?.provider).toBeUndefined(); + expect(storedSchedules[1]?.expiresAt).toBeUndefined(); + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + sessionId: session.id, + event: expect.objectContaining({ + type: "scheduled_work_update", + id: recurring.item.id, + origin: "action", + status: "scheduled", + }), + }), + ])); + + await service.setScheduledWorkPaused({ sessionId: session.id, paused: true }); + const pausedCreate = await service.createScheduledWork({ + sessionId: session.id, + cron: "0 * * * *", + prompt: "Wait until scheduling resumes.", + }); + expect(pausedCreate.item.status).toBe("paused"); + const pausedCreateEvents = events.filter((envelope) => + envelope.event.type === "scheduled_work_update" + && envelope.event.id === pausedCreate.item.id + ); + expect(pausedCreateEvents.at(-1)?.event).toEqual(expect.objectContaining({ + status: "paused", + nextRunAt: expect.any(String), + })); + + await expect(service.createScheduledWork({ + sessionId: session.id, + cron: "not a cron", + prompt: "Check CI.", + })).rejects.toThrow(/cron.*valid 5-field cron/i); + await expect(service.createScheduledWork({ + sessionId: session.id, + cron: "0 * * * *", + prompt: " ", + })).rejects.toThrow(/prompt is required/i); + + sessionService.create({ + sessionId: "ended-chat", + laneId: "lane-1", + toolType: "codex-chat", + }); + sessionService.end({ sessionId: "ended-chat", status: "completed" }); + await expect(service.createScheduledWork({ + sessionId: "ended-chat", + cron: "0 * * * *", + prompt: "This must not be scheduled.", + })).rejects.toThrow(/ended or archived/i); + }); + + it("delivers a provider-neutral action schedule through messageSession for an idle live Claude chat", async () => { + vi.useFakeTimers(); + vi.setSystemTime(SCHEDULE_TEST_START); + const scheduledWork = createScheduledWorkDb(); + const events: AgentChatEventEnvelope[] = []; + installClaudeWakeupFixture({ + sdkSessionId: "sdk-action-schedule", + delaySeconds: 600, + lingerAfterTurn: new Promise(() => undefined), + }); + const { service } = createService({ + db: scheduledWork.db, + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + const foregroundTurn = service.runSessionTurn({ + sessionId: session.id, + text: "Keep the Claude runtime warm.", + }); + await vi.advanceTimersByTimeAsync(1_000); + await foregroundTurn; + + const created = await service.createScheduledWork({ + sessionId: session.id, + cron: "1 * * * *", + prompt: "Deliver this through the ADE wake path.", + recurring: false, + }); + await vi.advanceTimersByTimeAsync(60_000); + vi.useRealTimers(); + + expect(service.pendingNativeScheduledWakeCountForTesting(session.id)).toBe(0); + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + sessionId: session.id, + event: expect.objectContaining({ + type: "user_message", + metadata: expect.objectContaining({ + scheduledWake: expect.objectContaining({ scheduleId: created.item.id }), + }), + }), + }), + ])); + service.forceDisposeAll(); + }); + it("projects the next durable wake onto the chat session summary", async () => { const before = Date.now(); let streamCall = 0; diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 7954a40cf..0942e23ec 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -176,6 +176,8 @@ import type { AgentChatAcceptCrossMachineHandoffResult, AgentChatArchiveArgs, AgentChatCancelSteerArgs, + AgentChatCreateScheduledWorkArgs, + AgentChatCreateScheduledWorkResult, AgentChatClaudeOutputStyle, AgentChatClaudeOutputStylesArgs, AgentChatClaudePlugin, @@ -34464,6 +34466,81 @@ export function createAgentChatService(args: { ...(schedule.outcomeSummary ? { outcomeSummary: schedule.outcomeSummary } : {}), }); + const createScheduledWork = async ( + args: AgentChatCreateScheduledWorkArgs, + ): Promise => { + await scheduledWorkReady; + if (!scheduledWorkScheduler) { + throw new Error("Scheduled work is unavailable for this project runtime."); + } + + const normalizedSessionId = typeof args.sessionId === "string" ? args.sessionId.trim() : ""; + if (!normalizedSessionId) throw new Error("Chat session id is required."); + const row = sessionService.get(normalizedSessionId); + if (!row || !isChatToolType(row.toolType)) { + throw new Error(`Chat session '${normalizedSessionId}' was not found.`); + } + const summary = summarizeSessionRow(row); + if (summary.status === "ended" || summary.archivedAt) { + throw new Error(`Chat session '${normalizedSessionId}' is ended or archived.`); + } + + const prompt = typeof args.prompt === "string" ? args.prompt.trim() : ""; + if (!prompt) throw new Error("Scheduled work prompt is required."); + if (prompt.length > 4_000) { + throw new Error("Scheduled work prompt must be 4000 characters or fewer."); + } + const cron = typeof args.cron === "string" ? args.cron.trim() : ""; + const createdAt = Date.now(); + const fireAt = nextChatScheduledCronFireAt(cron, createdAt); + if (fireAt == null) { + throw new Error("Scheduled work cron must be a valid 5-field cron expression."); + } + if (args.recurring != null && typeof args.recurring !== "boolean") { + throw new Error("Scheduled work recurring must be a boolean."); + } + if (args.reason != null && typeof args.reason !== "string") { + throw new Error("Scheduled work reason must be a string."); + } + + const recurring = args.recurring !== false; + const reason = args.reason?.trim(); + const id = `action:${normalizedSessionId}:${randomUUID()}`; + const managed = ensureManagedSession(normalizedSessionId); + durableScheduleUiStatusById.set(id, "scheduled"); + const schedule = await scheduledWorkScheduler.upsert({ + id, + sessionId: normalizedSessionId, + kind: recurring ? "cron" : "wakeup", + prompt, + ...(reason ? { reason } : {}), + cron, + fireAt, + createdAt, + ...(recurring ? { expiresAt: createdAt + claudeRecurringCronTtlMs } : {}), + status: "scheduled", + lateFlag: false, + durable: true, + }); + emitChatEvent(managed, { + type: "scheduled_work_update", + id, + kind: schedule.kind, + status: schedule.status === "done" ? "completed" : schedule.status, + origin: "action", + title: reason || prompt, + prompt, + ...(reason ? { reason } : {}), + cron, + ...((schedule.status === "scheduled" || schedule.status === "paused") && schedule.fireAt != null + ? { nextRunAt: new Date(schedule.fireAt).toISOString() } + : {}), + recurring, + durable: true, + }); + return { item: toScheduledWorkItem(schedule) }; + }; + const listScheduledWork = async ({ sessionId, includeTerminal = false, @@ -38920,7 +38997,8 @@ export function createAgentChatService(args: { const liveManaged = managedSessions.get(schedule.sessionId); const liveRuntime = liveManaged?.runtime?.kind === "claude" ? liveManaged.runtime : null; if ( - liveManaged + schedule.provider === "claude" + && liveManaged && liveRuntime?.query && !liveRuntime.busy && liveManaged.session.status !== "active" @@ -38973,14 +39051,19 @@ export function createAgentChatService(args: { ?? (status === "fired" && (row.status === "running" || row.status === "detached") ? ensureManagedSession(schedule.sessionId) : null); - if (!managed || managed.session.provider !== "claude") return; + if (!managed) return; const eventStatus: ScheduledWorkEvent["status"] = status === "done" ? "completed" : status; - const origin: ScheduledWorkEvent["origin"] = schedule.kind === "cron" - ? "cron" - : schedule.kind === "loop" - ? "loop" - : "schedule_wakeup"; + let origin: ScheduledWorkEvent["origin"]; + if (schedule.id.startsWith("action:")) { + origin = "action"; + } else if (schedule.kind === "cron") { + origin = "cron"; + } else if (schedule.kind === "loop") { + origin = "loop"; + } else { + origin = "schedule_wakeup"; + } emitChatEvent(managed, { type: "scheduled_work_update", id: schedule.id, @@ -39038,6 +39121,7 @@ export function createAgentChatService(args: { markCrossMachineHandoff, sendMessage, messageSession, + createScheduledWork, listScheduledWork, cancelScheduledWork, setScheduledWorkPaused, diff --git a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts index 4f11b958d..b7ab39db6 100644 --- a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts +++ b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts @@ -320,7 +320,7 @@ describe("createChatScheduledWorkScheduler", () => { sessionState: () => sessionState, fire: createFireMock(), }); - await scheduler.upsert(wakeup({ fireAt: START })); + await scheduler.upsert(wakeup({ fireAt: START, provider: "claude" })); sessionState = "ended"; const claimed = scheduler.claimNativeFire("session-1", "native-turn-ended"); @@ -658,7 +658,7 @@ describe("createChatScheduledWorkScheduler", () => { sessionState: () => "active", fire, }); - await scheduler.upsert(wakeup({ fireAt: START + 500 })); + await scheduler.upsert(wakeup({ fireAt: START + 500, provider: "claude" })); const claimed = scheduler.claimNativeFire("session-1", "native-turn-1"); expect(claimed).toMatchObject({ @@ -708,6 +708,34 @@ describe("createChatScheduledWorkScheduler", () => { scheduler.dispose(); }); + it("does not let a native Claude turn claim provider-neutral scheduled work", async () => { + vi.useFakeTimers(); + vi.setSystemTime(START); + const fire = createFireMock(); + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => null, + saveState: () => undefined, + isGlobalPaused: () => false, + sessionState: () => "active", + fire, + }); + await scheduler.upsert(wakeup({ + id: "action:session-1:schedule-1", + fireAt: START, + durable: true, + })); + + expect(scheduler.claimNativeFire("session-1", "unrelated-native-turn")).toBeNull(); + await vi.advanceTimersByTimeAsync(0); + + expect(fire).toHaveBeenCalledWith( + expect.objectContaining({ id: "action:session-1:schedule-1" }), + { late: false }, + ); + expect(fire.mock.calls[0]?.[0]).not.toHaveProperty("provider"); + scheduler.dispose(); + }); + it("uses the Claude grace deadline for normal and genuinely late backstop fires", async () => { vi.useFakeTimers(); vi.setSystemTime(START); @@ -801,6 +829,7 @@ describe("createChatScheduledWorkScheduler", () => { kind: "cron", cron: "* * * * *", fireAt: START + 500, + provider: "claude", })); expect(scheduler.claimNativeFire("session-1", "native-turn-1")).toMatchObject({ diff --git a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts index 3999b55f9..c05000a9f 100644 --- a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts +++ b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts @@ -616,6 +616,7 @@ export function createChatScheduledWorkScheduler( const schedule = [...schedules.values()] .filter((candidate) => candidate.sessionId === sessionId + && candidate.provider === "claude" && candidate.status === "scheduled" && !candidate.pausedFlag && !isExpired(candidate, currentTime) diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 313b13ca8..5b17b0d4a 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -344,6 +344,8 @@ import type { AgentChatUpdateSessionArgs, AgentChatSetScheduledWorkPausedArgs, AgentChatSetScheduledWorkPausedResult, + AgentChatCreateScheduledWorkArgs, + AgentChatCreateScheduledWorkResult, AgentChatListScheduledWorkArgs, AgentChatScheduledWorkItem, AgentChatCancelScheduledWorkArgs, @@ -6685,6 +6687,14 @@ export function registerIpc({ }, ); + ipcMain.handle( + IPC.agentChatCreateScheduledWork, + async (_event, arg: AgentChatCreateScheduledWorkArgs): Promise => { + const ctx = ensureAgentChatContext(); + return ctx.agentChatService.createScheduledWork(arg); + }, + ); + ipcMain.handle( IPC.agentChatListScheduledWork, async (_event, arg: AgentChatListScheduledWorkArgs): Promise => { diff --git a/apps/desktop/src/main/services/usage/usageStatsStore.ts b/apps/desktop/src/main/services/usage/usageStatsStore.ts index fdbccba5a..31b8d4b3a 100644 --- a/apps/desktop/src/main/services/usage/usageStatsStore.ts +++ b/apps/desktop/src/main/services/usage/usageStatsStore.ts @@ -70,6 +70,7 @@ const MEANINGFUL_ACTIONS = new Set([ "chat.restart", "chat.handoff", "chat.rewindFiles", + "chat.createScheduledWork", "chat.cancelScheduledWork", "chat.delete", "chat.archive", @@ -137,7 +138,14 @@ const MEANINGFUL_ACTIONS = new Set([ export function usageActionFromIpcChannel(channel: string): string { const action = channel.replace(/^ade\./, ""); - if (action.startsWith("agentChat.")) return `chat.${action.slice("agentChat.".length)}`; + if (action.startsWith("agentChat.")) { + const chatAction = action.slice("agentChat.".length); + const aliases: Record = { + "scheduledWork.create": "createScheduledWork", + "scheduledWork.cancel": "cancelScheduledWork", + }; + return `chat.${aliases[chatAction] ?? chatAction}`; + } if (action === "pty.create") return "work.startCliSession"; if (action === "pty.resumeSession") return "work.resumeCliSession"; if (action === "pty.sendToSession") return "work.sendToSession"; diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts index dc7f16d8b..8c69f3ed1 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts @@ -4240,7 +4240,9 @@ describe("ADE database usage aggregation", () => { expect(usageClientSurfaceFromRpcName("ade-web-client")).toBe("web"); expect(usageClientSurfaceFromPeer("phone", "ios")).toBe("mobile"); expect(usageActionFromIpcChannel("ade.agentChat.send")).toBe("chat.send"); - expect(usageActionFromIpcChannel("ade.agentChat.cancelScheduledWork")).toBe("chat.cancelScheduledWork"); + expect(usageActionFromIpcChannel("ade.agentChat.scheduledWork.create")).toBe("chat.createScheduledWork"); + expect(isMeaningfulUsageAction("chat.createScheduledWork")).toBe(true); + expect(usageActionFromIpcChannel("ade.agentChat.scheduledWork.cancel")).toBe("chat.cancelScheduledWork"); expect(isMeaningfulUsageAction("chat.cancelScheduledWork")).toBe(true); expect(usageActionFromIpcChannel("ade.pty.create")).toBe("work.startCliSession"); expect(usageActionFromRpcDomain("lane", "create")).toBe("lanes.create"); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 3008298c8..3282b56e4 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -144,6 +144,8 @@ import type { AgentChatSetClaudeOutputStyleArgs, AgentChatSetScheduledWorkPausedArgs, AgentChatSetScheduledWorkPausedResult, + AgentChatCreateScheduledWorkArgs, + AgentChatCreateScheduledWorkResult, AgentChatListScheduledWorkArgs, AgentChatScheduledWorkItem, AgentChatCancelScheduledWorkArgs, @@ -1444,6 +1446,9 @@ declare global { updateSession: ( args: AgentChatUpdateSessionArgs, ) => Promise; + createScheduledWork: ( + args: AgentChatCreateScheduledWorkArgs, + ) => Promise; listScheduledWork: ( args?: AgentChatListScheduledWorkArgs, ) => Promise; diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 623e40147..e11c3069e 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -5213,10 +5213,25 @@ describe("preload OAuth bridge", () => { await expect( bridge.agentChat.send({ sessionId: "session-1", text: "hello" }), ).rejects.toThrow(/Project is switching/i); + await expect( + bridge.agentChat.createScheduledWork({ + sessionId: "session-1", + prompt: "Post the status update", + cron: "0 9 * * 1-5", + }), + ).rejects.toThrow(/Project is switching/i); + await expect( + bridge.agentChat.setScheduledWorkPaused({ + sessionId: "session-1", + paused: true, + }), + ).rejects.toThrow(/Project is switching/i); expect(invoke).toHaveBeenCalledWith(IPC.projectSwitchToPath, { rootPath: "/next" }); expect(invoke).not.toHaveBeenCalledWith(IPC.appGetWindowSession); expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatSend, expect.anything()); + expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatCreateScheduledWork, expect.anything()); + expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatSetScheduledWorkPaused, expect.anything()); resolveSwitch({ rootPath: "/next", displayName: "Next", baseRef: "main" }); await pendingSwitch; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 810844764..37fb63652 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -342,6 +342,8 @@ import type { AgentChatSetClaudeOutputStyleArgs, AgentChatSetScheduledWorkPausedArgs, AgentChatSetScheduledWorkPausedResult, + AgentChatCreateScheduledWorkArgs, + AgentChatCreateScheduledWorkResult, AgentChatListScheduledWorkArgs, AgentChatScheduledWorkItem, AgentChatCancelScheduledWorkArgs, @@ -1266,7 +1268,9 @@ const MUTATING_CHAT_ACTIONS = new Set([ "setClaudeOutputStyle", "reloadClaudePlugins", "setParallelLaunchState", + "createScheduledWork", "cancelScheduledWork", + "setScheduledWorkPaused", "ensureCtoSession", "warmupModel", "rewindFiles", @@ -5529,6 +5533,19 @@ contextBridge.exposeInMainWorld("ade", { agentChatSummaryCache.clear(); return session as AgentChatSession; }, + createScheduledWork: async ( + args: AgentChatCreateScheduledWorkArgs, + ): Promise => { + agentChatSummaryCache.clear(); + const result = await callProjectRuntimeActionOr( + "chat", + "createScheduledWork", + { args }, + () => ipcRenderer.invoke(IPC.agentChatCreateScheduledWork, args), + ); + agentChatSummaryCache.clear(); + return result; + }, listScheduledWork: async ( args: AgentChatListScheduledWorkArgs = {}, ): Promise => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 1d5083fba..39ae77ecb 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -5028,6 +5028,9 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { unarchive: resolvedArg(undefined), delete: resolvedArg(undefined), updateSession: resolvedArg({ id: "mock" }), + createScheduledWork: async () => { + throw new Error("Scheduled work is unavailable in the browser preview."); + }, listScheduledWork: resolved([]), cancelScheduledWork: async () => { throw new Error("Scheduled work is unavailable in the browser preview."); diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 46a522dae..66db19533 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -230,6 +230,7 @@ export const IPC = { agentChatUnarchive: "ade.agentChat.unarchive", agentChatEvent: "ade.agentChat.event", agentChatUpdateSession: "ade.agentChat.updateSession", + agentChatCreateScheduledWork: "ade.agentChat.scheduledWork.create", agentChatListScheduledWork: "ade.agentChat.scheduledWork.list", agentChatCancelScheduledWork: "ade.agentChat.scheduledWork.cancel", agentChatSetScheduledWorkPaused: "ade.agentChat.scheduledWork.pause", diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 5523baca4..2ea9289ac 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -492,6 +492,7 @@ export type AgentChatScheduledWorkOrigin = | "schedule_wakeup" | "cron" | "loop" + | "action" | "remote_trigger" | "background_task" | "sdk"; @@ -2193,6 +2194,18 @@ export type AgentChatListScheduledWorkArgs = { includeTerminal?: boolean; }; +export type AgentChatCreateScheduledWorkArgs = { + sessionId: string; + prompt: string; + cron: string; + recurring?: boolean; + reason?: string; +}; + +export type AgentChatCreateScheduledWorkResult = { + item: AgentChatScheduledWorkItem; +}; + export type AgentChatCancelScheduledWorkArgs = { sessionId: string; scheduleId: string; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 26a1647a9..cc8fd2ce7 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -1128,7 +1128,9 @@ export type SyncRemoteCommandAction = | "chat.getImageDataUrl" | "chat.listSessions" | "chat.getSummary" + | "chat.createScheduledWork" | "chat.cancelScheduledWork" + | "chat.setScheduledWorkPaused" | "chat.getTranscript" | "chat.getChatEventHistory" | "chat.listSubagents" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 569e7e608..6dfed310c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -139,9 +139,9 @@ Product positioning and workflows live in [`docs/PRD.md`](../docs/PRD.md). This **Optional ADE account auth.** `ade login` preserves the local-browser loopback OAuth path, but selects the account-directory device authorization bridge for explicit `--headless`, SSH, display-less hosts, or a failed browser launch. The brain generates and retains the device redemption secret, polls the bridge, and persists the resulting refresh-capable session under `account.session.v1`. For a JWT access token, its decoded `exp` claim is authoritative over the OAuth `expires_in` bookkeeping: status reports that expiry, and `getAccessToken()` refreshes inside the two-minute skew even when an older stored session record claims a later expiry. Tokens without a usable JWT expiry retain the stored `expiresAt` fallback. The desktop and brain share the encrypted session file and may race a rotating refresh credential; after an OAuth `invalid_grant`, the loser re-reads persistence and retries once only when another process has written a different refresh token. Other refresh failures are not replayed, and raw tokens are never logged. `ADE_ACCOUNT_TOKEN` takes precedence without starting a login flow: JWT access credentials are used through their declared expiry, while refresh credentials are exchanged and rotated only in memory. `ade account token create` wraps the current interactive refresh credential with its public issuer/client context in a versioned secret envelope, so a newly provisioned agent or CI host needs no local Clerk configuration. Legacy raw opaque refresh tokens retain local-config compatibility and return migration guidance when that config is absent. The desktop Account page exposes one honest browser continuation because the bridge opens the generic hosted account flow rather than selecting a provider; the browser presents whichever methods are enabled. Native iOS uses ClerkKit's transferable OAuth result to distinguish new accounts from returning users. Its identifier-first email path starts sign-in, falls back to sign-up only for Clerk's precise account-not-found codes, sends the sign-up email verification code, and verifies against the matching sign-in or sign-up attempt. Account status exposes `loopback`, `device`, or `env-token`; signed-out state never gates local projects, `ade code`, local pairing, or PIN workflows. -**Action surface.** First-class command families cover lanes (including `ade lanes link-linear-issue` / `detach-linear-issue` for post-creation Linear issue linking, and `ade lanes create-from-linear` / `batch-create-from-linear` to spin up one or many issue lanes — optionally launching an agent chat with `--start-chat`), git, diffs, files, PRs, runs, shells, chats (including `ade chat create --prompt` for a persistent Work chat followed by an initial chat message, `ade chat send` / `message` / `steer` / `wait` for peer chat delivery and status polling, `ade chat read ` for recent transcript messages, `ade chat create --from-linear-issue `, `ade chat attach-linear-issue` / `detach-linear-issue` / `linear-issues` for session-scoped issue attachment, and `--parent ` / `--no-parent` to control child-chat lineage — a chat created via `ade chat create` / `ade new --mode chat` defaults its parent to `$ADE_CHAT_SESSION_ID` (the spawning agent's own chat, injected into every tracked agent shell) so it lists in the parent's subagents panel instead of becoming an orphan, and `--no-parent` opts out), agents, CTO, Linear (the write bridge an attached CLI agent uses: `ade linear attach` / `detach` / `issues` / `issue` / `comment` / `set-state` / `assign` / `label`, with `--this-session` resolving the issue id from `$ADE_LINEAR_ISSUE_IDS` so a launched agent needs no Linear token — see [features/linear-integration/README.md](./features/linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection)), tests, proof, settings, the iOS Simulator (`ade ios-sim` / `ade ios` / `ade simulator` — see [features/ios-simulator/README.md](./features/ios-simulator/README.md)), the Cursor Cloud bridge (`ade cursor cloud agents | runs | artifacts | repos | models | me` — talks directly to `@cursor/sdk` without going through the ADE runtime endpoint), the App Control bridge for Electron apps (`ade app-control` / `ade app` / `ade electron` — `launch`, `connect`, `stop`, `status`, `screenshot`, `snapshot`, `inspect`, `select`, `click`, `type`, `scroll`, `key`, `targets`, `attach`, `logs`, `terminal write`, `terminal signal` — see [features/computer-use/app-control.md](./features/computer-use/app-control.md)), the chat-scoped terminal (`ade terminal list` / `read` / `write` / `signal` / `active`), universal search (`ade search ""` over chats, terminals, PRs, commits, branches, lanes, files, and Linear — see [features/search/README.md](./features/search/README.md)), and a generic `ade actions run ` escape hatch for every registered ADE service action. The chat action surface includes `chat.createSession`, `chat.sendMessage` (low-level normal-turn send), `chat.messageSession` (normalized peer delivery: auto, queue, wake, interrupt-replace), `chat.readTranscript`, and model-catalog actions; session-bound non-CTO callers are restricted to their own chat for `chat.sendMessage` and `chat.readTranscript`, while `chat.messageSession` is the reviewed primitive for deliberately messaging another ADE chat through routing semantics. The action allow-list adds three domains for these surfaces: `app_control` (every public method on `AppControlService`), `terminal` (`list`, `read`, `write`, `signal`, `activeForChat` against `ptyService`), named iOS Simulator actions for launch, live view, inspection, input, and Preview Lab workflows, and `search` (`query`, `indexStatus`, and the CTO-only `rebuildIndex` against `searchService`; session-bound non-CTO callers get chat/terminal hits scoped to their own session). +**Action surface.** First-class command families cover lanes (including `ade lanes link-linear-issue` / `detach-linear-issue` for post-creation Linear issue linking, and `ade lanes create-from-linear` / `batch-create-from-linear` to spin up one or many issue lanes — optionally launching an agent chat with `--start-chat`), git, diffs, files, PRs, runs, shells, chats (including `ade chat create --prompt` for a persistent Work chat followed by an initial chat message, `ade chat send` / `message` / `steer` / `wait` for peer chat delivery and status polling, `ade chat read ` for recent transcript messages, `ade chat scheduled-work create --cron "" --prompt "" [--once]` for durable provider-neutral scheduling, `ade chat create --from-linear-issue `, `ade chat attach-linear-issue` / `detach-linear-issue` / `linear-issues` for session-scoped issue attachment, and `--parent ` / `--no-parent` to control child-chat lineage — a chat created via `ade chat create` / `ade new --mode chat` defaults its parent to `$ADE_CHAT_SESSION_ID` (the spawning agent's own chat, injected into every tracked agent shell) so it lists in the parent's subagents panel instead of becoming an orphan, and `--no-parent` opts out), agents, CTO, Linear (the write bridge an attached CLI agent uses: `ade linear attach` / `detach` / `issues` / `issue` / `comment` / `set-state` / `assign` / `label`, with `--this-session` resolving the issue id from `$ADE_LINEAR_ISSUE_IDS` so a launched agent needs no Linear token — see [features/linear-integration/README.md](./features/linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection)), tests, proof, settings, the iOS Simulator (`ade ios-sim` / `ade ios` / `ade simulator` — see [features/ios-simulator/README.md](./features/ios-simulator/README.md)), the Cursor Cloud bridge (`ade cursor cloud agents | runs | artifacts | repos | models | me` — talks directly to `@cursor/sdk` without going through the ADE runtime endpoint), the App Control bridge for Electron apps (`ade app-control` / `ade app` / `ade electron` — `launch`, `connect`, `stop`, `status`, `screenshot`, `snapshot`, `inspect`, `select`, `click`, `type`, `scroll`, `key`, `targets`, `attach`, `logs`, `terminal write`, `terminal signal` — see [features/computer-use/app-control.md](./features/computer-use/app-control.md)), the chat-scoped terminal (`ade terminal list` / `read` / `write` / `signal` / `active`), universal search (`ade search ""` over chats, terminals, PRs, commits, branches, lanes, files, and Linear — see [features/search/README.md](./features/search/README.md)), and a generic `ade actions run ` escape hatch for every registered ADE service action. The chat action surface includes `chat.createSession`, `chat.sendMessage` (low-level normal-turn send), `chat.messageSession` (normalized peer delivery: auto, queue, wake, interrupt-replace), `chat.readTranscript`, `chat.createScheduledWork`, and model-catalog actions; session-bound non-CTO callers are restricted to their own chat for `chat.sendMessage`, `chat.readTranscript`, and scheduled-work create/list/cancel, while `chat.messageSession` is the reviewed primitive for deliberately messaging another ADE chat through routing semantics. The action allow-list adds three domains for these surfaces: `app_control` (every public method on `AppControlService`), `terminal` (`list`, `read`, `write`, `signal`, `activeForChat` against `ptyService`), named iOS Simulator actions for launch, live view, inspection, input, and Preview Lab workflows, and `search` (`query`, `indexStatus`, and the CTO-only `rebuildIndex` against `searchService`; session-bound non-CTO callers get chat/terminal hits scoped to their own session). -Scheduled-work recovery is part of that typed chat family: `ade chat scheduled-work list [session] [--all]` and `ade chat scheduled-work cancel ` call the explicitly adapted `chat.listScheduledWork` / `chat.cancelScheduledWork` actions. The list reads the KV-backed management snapshot rather than reconstructing ownership from transcript events; provider-owned cancellation is reported as requested vs confirmed instead of being hidden optimistically. +Scheduled work is part of that typed chat family: `ade chat scheduled-work create --cron "" --prompt "" [--once] [--reason ""] [--session ]` calls `chat.createScheduledWork`, while list/cancel call `chat.listScheduledWork` / `chat.cancelScheduledWork`. Create writes a durable provider-neutral row and delivers it through the same wake path for Claude, Codex, Cursor, Droid, and OpenCode. For a session-bound non-CTO caller, the daemon defaults an omitted target to the caller's own chat and denies cross-session or unbound-external scheduling. The list reads the KV-backed management snapshot rather than reconstructing ownership from transcript events; provider-owned cancellation is reported as requested vs confirmed instead of being hidden optimistically. Personal chat is an explicit machine-only variant of the typed chat family: `ade chat list|create|show|read|send|interrupt|archive|unarchive|delete --personal`. It connects to the running brain, invokes `personalChats.call`, and rejects lane/Linear project flags rather than falling back to `--headless` project dispatch. ADE Code remains a project Work TUI and intentionally has no personal-chat UI in this release. @@ -225,7 +225,7 @@ Native SwiftUI app acting as a controller. It pairs with an ADE machine over Web - Stack: native SwiftUI + `SQLite3` C API + iOS system SQLite. - CRDT: pure-SQL CRR emulation layer (trigger-based change tracking) since iOS blocks `sqlite3_load_extension()`/`sqlite3_auto_extension()`. Changesets are wire-compatible with desktop cr-sqlite. - Core services: `Database.swift`, `SyncService.swift`, `KeychainService.swift`, `DpopKeyService.swift` (Secure Enclave P-256 pairing proof), `PushNotificationService.swift`, `LiveActivityService.swift`, and `ProductAnalytics.swift` (affirmative-consent, content-free native analytics with an independent identity and 20-event daily ceiling). -- Shipped project tabs: Lanes, Files, Work, PRs, CTO, Settings (including a Push delivery panel). The projectless Chats surface is entered only from the Hub, outside the project tab bar. It uses runtime-scoped commands and the same chat event union/Work transcript renderer while suppressing lane/project actions. The Work chat decodes the same chat event union as desktop for live transcripts, including scheduled-work updates and transcript retractions; scheduled work appears in a native Chat Info popup/sheet while the phone remains a controller only. Durable active rows expose Cancel through the non-queueable `chat.cancelScheduledWork` remote action only when the connected host advertises that capability, so a newer phone remains view-only against an older brain. +- Shipped project tabs: Lanes, Files, Work, PRs, CTO, Settings (including a Push delivery panel). The projectless Chats surface is entered only from the Hub, outside the project tab bar. It uses runtime-scoped commands and the same chat event union/Work transcript renderer while suppressing lane/project actions. The Work chat decodes the same chat event union as desktop for live transcripts, including scheduled-work updates and transcript retractions; scheduled work appears in a native Chat Info popup/sheet while the phone remains a controller only. Durable active rows expose Cancel through `chat.cancelScheduledWork`. The host remote-command surface also advertises non-queueable `chat.createScheduledWork` and `chat.setScheduledWorkPaused` for mobile controllers; native clients gate any controls they implement on those descriptors, so transport availability does not make an older brain accept unsupported mutations. - Shipped widgets: a Lock Screen widget for prioritized agent/PR/sync/offline/idle status, plus an ActivityKit Live Activity + Dynamic Island for active agent runs (`ADEWidgets/ADEAgentActivityWidget.swift`). - Push: APNs alert pushes (deep-linked) and Live Activity updates arrive via the Cloudflare push relay (§2.7); the phone hands tokens/prefs to the brain over the paired sync WebSocket. - Connection: ADE account sign-in is the primary PIN-less path; direct pairing uses a user-set 6-digit PIN after scanning the v3 smart-URL QR or choosing a Nearby machine. Pairing is hardened with device-bound DPoP proofs. @@ -306,7 +306,7 @@ Schema bootstrap in `kvDb.ts` creates ~104 tables. Anchor tables for agents read | `computer_use_artifacts` + `computer_use_artifact_links` | Canonical proof-artifact records and cross-domain ownership. | | `devices` + `sync_cluster_state` | Device registry and singleton host-authority row (host is `brain_device_id` internally; legacy naming). | | `local_crr_change_suppressions` | Local-only (excluded from CRR replication) high-water marks per `(table_name, site_id)`. `AdeDb.sync.exportChangesSince` filters local-site rows for any listed table at or below `through_db_version` so a viewer-join wipe of `devices` / `sync_cluster_state` cannot leak DELETE rows back to the host. See §13.1. | -| `kv` | Generic key-value store for UI layout, config trust hashes, misc settings, short-lived recovery records such as `agent-chat-parallel-launch::`, and the versioned durable Claude schedule state at `agent-chat:scheduled-work:v1`. The schedule value contains armed/terminal records, exact Claude SDK-session ownership, provider ids, expiry and terminal timestamps, plus paused chat ids; Electron-main and headless runtimes restore the same state. Every successful Claude `CronCreate` is mirrored here as durable regardless of the provider's ineffective `durable` flag. Provider snapshots reconcile only rows owned by that exact SDK session, so an empty snapshot from a fresh session cannot cancel a prior owner's active rows. Startup drops legacy `cron-tool:` intent placeholders, quarantines older provider rows without ownership metadata as paused, and prunes terminal history after seven days or 200 rows. | +| `kv` | Generic key-value store for UI layout, config trust hashes, misc settings, short-lived recovery records such as `agent-chat-parallel-launch::`, and versioned durable chat schedule state at `agent-chat:scheduled-work:v1`. The schedule value contains ADE action rows plus Claude-owned armed/terminal records, optional exact Claude SDK-session ownership and provider ids, expiry and terminal timestamps, and paused chat ids; Electron-main and headless runtimes restore the same state. Every successful Claude `CronCreate` is mirrored here as durable regardless of the provider's ineffective `durable` flag, while `chat.createScheduledWork` writes a provider-neutral row that fires through `messageSession(kind: "wake")`. Provider snapshots reconcile only rows owned by that exact SDK session, so an empty snapshot from a fresh session cannot cancel a prior owner's active rows. Startup drops legacy `cron-tool:` intent placeholders, quarantines older provider rows without ownership metadata as paused, and prunes terminal history after seven days or 200 rows. | Types for these tables are split into domain modules under `apps/desktop/src/shared/types/`. The barrel `index.ts` re-exports `core`, `models`, `git`, `lanes`, `conflicts`, `prs`, `files`, `sessions`, `chat`, `config`, `automations`, `packs`, `budget`, `usage`, and more. Feature docs under `docs/features/` call out the table subsets that are load-bearing for each surface. @@ -534,7 +534,7 @@ ade.agentChat.* # agent chat sessions, model inventory, parallel la # recoverContinuity (retry_original / recover_from_history / # start_new_chat) for a chat whose provider thread could not be # resumed — see features/storage-and-recovery/README.md. Also includes the typed - # listScheduledWork, cancelScheduledWork, and + # createScheduledWork, listScheduledWork, cancelScheduledWork, and # setScheduledWorkPaused mutations; list/get session summaries # project durable nextWakeAt, scheduledWorkPaused, and the # optional KV-backed scheduledWork management snapshot. @@ -621,7 +621,7 @@ Most services described here live under `apps/desktop/src/main/services/ | `appControl/` | `appControlService.ts`, `appControlLaunchCommand.ts` | Chrome DevTools Protocol bridge for developer-owned Electron apps. Launches a chat-owned PTY running the user's dev command (or connects to an existing `--remote-debugging-port`), polls `/json` for ready CDP targets, attaches a long-lived `CdpClient` WebSocket, and exposes screenshot / DOM snapshot / hit-test / click / type / scroll / key dispatch / screencast frames. `appControlLaunchCommand.ts` owns the shell-command detection and debug-flag injection helpers for direct Electron and package-script launches. `inspectPoint` and `selectPoint` produce `AppControlContextItem`s for the chat composer (DOM packet + screenshot + source-file candidates resolved by `findSourceMatches` over an indexed tree of project source files). See [features/computer-use/app-control.md](./features/computer-use/app-control.md). | | `builtInBrowser/` | `builtInBrowserService.ts`, `builtInBrowserAgentAccess.ts`, `builtInBrowserActorCapabilities.ts`, `builtInBrowserAuthentication.ts`, `builtInBrowserProfileMigration.ts`, `builtInBrowserStateStore.ts`, `builtInBrowserNavigation.ts`, `builtInBrowserPermissions.ts`, `builtInBrowserWebAuthn.ts`, `desktopBridgeServer.ts` | In-app web browser owned by the main process. Every remote-content `WebContentsView` uses the single persistent `persist:ade-browser` storage profile (`storageProfileKey: "global"`), while service keys combine the ADE window id with a project/window/personal tab-collection key so visible tabs stay independent. Project roots route project commands and scratch observations; validated personal commands retain the personal tab collection and use the channel-specific machine-local browser-observation scratch root. Neither route partitions cookies or site storage. On first use, a bounded, idempotent migration copies unexpired persistent cookies from this channel's legacy project-derived partitions into the global profile without overwriting global cookies or copying session cookies; it preserves the old partition directories because Chromium DOM storage, IndexedDB, service-worker state, and WebAuthn credentials cannot be safely merged across partitions. The bounded machine-local state store restores HTTP(S)/blank tab URLs and the active tab for each collection, but never restores agent leases, lightweight browser sessions, or synthetic session cookies. The service caps each collection at 10 tabs, routes global-session network events back to their owning collection, drives OAuth popups and downloads, and emits targeted events. HTTP/proxy authentication uses a sandboxed, local credential prompt and passes values directly to Chromium without persisting or logging them; client-certificate requests use an explicit native chooser and only accept a certificate Electron offered. Permission requests are deny-by-default, limited to managed browser web contents and secure origins, and use persisted per-origin/embedding-origin decisions with a native human prompt; only Google's `storage-access` and `top-level-storage-access` requests retain a narrow accounts-domain compatibility exception. The Browser toolbar's trusted-renderer Profile panel exposes non-secret cookie/cache/flush diagnostics and list/remove/clear controls for remembered permission decisions; these operations are not bridged to agents or unbound CLI callers. A separate non-persistent agent-access controller requires a per-chat/lane native human grant for every non-local origin and for local origins with allowed privileged permissions; cross-origin navigations and redirects are intercepted, and sensitive popups are blocked until explicitly approved. The grant follows the agent-owned tab without a timer and clears only when an explicit trusted-renderer navigation reclaims the tab. Tabs carry owner/lease metadata. ADE-launched chats receive opaque in-memory browser actor capabilities bound to their trusted chat/lane/project or personal collection. The runtime requires the token and strips caller routing; Electron validates it in the issuing process, restores only the bound scope, forces `force: false`, and separately authenticates the bridge with the desktop launch's rotating token. Agents cannot force or impersonate a takeover, read another agent's tab status, inspect global cookie-domain diagnostics, or administer permissions. Browser sessions bind one workflow to one tab. Project observations live under `.ade/cache/browser-observations/`; personal observations live under the channel user-data `browser-observations/personal/` root, which is narrowly allowlisted for proof promotion. The issuer-restored scope selects the matching independent tab collection. Navigation/protocol policy lives in `builtInBrowserNavigation.ts`; WebAuthn account selection lives in `builtInBrowserWebAuthn.ts`. | | `automations/` | `automationService.ts`, `automationPlannerService.ts`, `automationIngressService.ts`, `automationSecretService.ts` | Rule lifecycle, NL → rule planner, inbound triggers, per-rule secrets. | -| `chat/` | `agentChatService.ts`, `chatScheduledWorkScheduler.ts`, `runtimeEvents.ts`, `claudeStructuredActivity.ts`, `openCodeStructuredActivity.ts`, `codexMcpElicitation.ts`, `buildClaudeV2Message.ts`, `markdownSlashCommandDiscovery.ts`, `claudeSlashCommandDiscovery.ts`, `codexSlashCommandDiscovery.ts`, `cursorSlashCommandDiscovery.ts`, `projectSlashCommandDiscovery.ts`, `slashCommandPromptExpansion.ts`, `cursorSdk*` (`cursorSdkPool.ts`, `cursorSdkWorker.ts`, `cursorSdkProtocol.ts`, `cursorSdkPolicy.ts`, `cursorSdkSystemPrompt.ts`, `cursorSdkEventMapper.ts`, `cursorSdkErrors.ts`), `droidSdkEventMapper.ts`, `sessionRecovery.ts` | Agent chat sessions (lane-scoped + orchestration worker/coordinator). Builds Claude messages, hosts the Cursor SDK in a Node worker pool with official local-store persistence, formalizes the cross-runtime event vocabulary, normalizes provider-native web/MCP/image activity into compact shared events, handles Codex app-server MCP elicitations and stalled-turn recovery, recovers sessions on restart, derives prompt-based lane names for parallel model launches, keeps Claude Agent SDK streams alive for scheduled wake/cron/background work after visible turns, emits transcript retractions for provider-superseded assistant rows, and manages Codex app-server goals with persisted, unlimited-budget session state. Claude remains authoritative for schedule tool success and canonical ids, while ADE's mirror is authoritative for delivery: ADE mutates it only from successful `PostToolUse`, stores `ScheduleWakeup`, every successful `CronCreate` (always `durable: true` regardless of Claude's ineffective flag), and `/loop` records in `kv`, and scopes Stop/SubagentStop reconciliation to the exact provider-session owner so a new session's empty snapshot preserves prior-owner rows. The SDK gets the native fire opportunity at `fireAt`; ADE's timer waits through a 90-second grace window, then backstops a skipped, busy, or dead provider without marking that designed delay late. The scheduler restores timers, coalesces missed occurrences to one late fire, applies chat/global pause state, queues busy Claude/OpenCode scheduled wakes as their own message and starts a new turn at the active turn boundary rather than steering mid-turn, cold-starts idle sessions when necessary, expires recurring crons after seven days, and emits lifecycle rows while summaries expose management state. Cancellation of Claude-owned jobs routes through `CronDelete` and remains visible until provider confirmation. There is no scheduled-work-specific spend cap. | +| `chat/` | `agentChatService.ts`, `chatScheduledWorkScheduler.ts`, `runtimeEvents.ts`, `claudeStructuredActivity.ts`, `openCodeStructuredActivity.ts`, `codexMcpElicitation.ts`, `buildClaudeV2Message.ts`, `markdownSlashCommandDiscovery.ts`, `claudeSlashCommandDiscovery.ts`, `codexSlashCommandDiscovery.ts`, `cursorSlashCommandDiscovery.ts`, `projectSlashCommandDiscovery.ts`, `slashCommandPromptExpansion.ts`, `cursorSdk*` (`cursorSdkPool.ts`, `cursorSdkWorker.ts`, `cursorSdkProtocol.ts`, `cursorSdkPolicy.ts`, `cursorSdkSystemPrompt.ts`, `cursorSdkEventMapper.ts`, `cursorSdkErrors.ts`), `droidSdkEventMapper.ts`, `sessionRecovery.ts` | Agent chat sessions (lane-scoped + orchestration worker/coordinator). Builds Claude messages, hosts the Cursor SDK in a Node worker pool with official local-store persistence, formalizes the cross-runtime event vocabulary, normalizes provider-native web/MCP/image activity into compact shared events, handles Codex app-server MCP elicitations and stalled-turn recovery, recovers sessions on restart, derives prompt-based lane names for parallel model launches, keeps Claude Agent SDK streams alive for scheduled wake/cron/background work after visible turns, emits transcript retractions for provider-superseded assistant rows, and manages Codex app-server goals with persisted, unlimited-budget session state. `chat.createScheduledWork` validates a five-field cron plus a bounded prompt and writes an ADE-owned recurring or one-shot row that works for every provider runtime. Claude remains authoritative for provider schedule tool success and canonical ids, while ADE's store is authoritative for delivery: successful `PostToolUse` mirrors `ScheduleWakeup`, every successful `CronCreate` (always `durable: true` regardless of Claude's ineffective flag), and `/loop` records in `kv`, and scopes Stop/SubagentStop reconciliation to the exact provider-session owner so a new session's empty snapshot preserves prior-owner rows. The SDK gets the native fire opportunity at `fireAt`; ADE's timer waits through a 90-second grace window, then backstops a skipped, busy, or dead provider without marking that designed delay late. Provider-neutral rows fire at their exact time through `messageSession(kind: "wake")`. The scheduler restores timers, coalesces missed occurrences to one late fire, applies chat/global pause state, queues busy Claude/OpenCode scheduled wakes as their own message and starts a new turn at the active turn boundary rather than steering mid-turn, cold-starts idle sessions when necessary, expires recurring crons after seven days, and emits lifecycle rows while summaries expose management state. Cancellation of Claude-owned jobs routes through `CronDelete` and remains visible until provider confirmation; ADE-owned rows cancel directly. There is no scheduled-work-specific spend cap. | | `computerUse/` | `computerUseArtifactBrokerService.ts`, `controlPlane.ts`, `localComputerUse.ts`, `syntheticToolResult.ts` | Proof-artifact broker (ingests, owner links, review state, routing), control-plane snapshot helpers, macOS capture capability descriptor, and the synthetic-tool-result helper used by the Claude compaction path. `proofObserver.ts` was removed in the rebuild — there is no passive auto-ingest. Direct Codex Computer Use executable resolution lives outside this folder in `main/utils/codexComputerUse.ts` because it configures provider runtimes rather than ingesting proof. | | `proof/` | `agentBrowserArtifactAdapter.ts` | Parses agent-browser payloads into broker inputs. | | `config/` | `projectConfigService.ts`, `laneOverlayMatcher.ts` | Load/save `.ade/ade.yaml` + `local.yaml`; trust enforcement; lane overlays. | diff --git a/docs/features/agents/README.md b/docs/features/agents/README.md index b4589ff7b..4f5b80bb5 100644 --- a/docs/features/agents/README.md +++ b/docs/features/agents/README.md @@ -12,9 +12,9 @@ The former worker/hiring agents were removed. There is one persistent identity | `apps/desktop/src/main/services/cto/ctoMemoryService.ts` | The CTO's smart-memory file store (`MEMORY.md`, `thread-state.md`, daily logs, search, injection sections). | | `apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts` | CTO operator tools for chat spawning, lanes/PRs/git/tests, Linear reads/writes, and the `saveMemory` / `searchMemory` / `readMemory` memory tools. | | `apps/desktop/src/main/services/agentTools/agentToolsService.ts` | Detects external CLI tools on PATH. | -| `apps/ade-cli/src/cli.ts` | Agent-focused `ade` command surface and text/JSON output formatters. Includes the `ade ios-sim` (alias `ade ios`, `ade simulator`) family — see [iOS Simulator feature](../ios-simulator/README.md), the `ade --socket app-control ...` driver for live Electron apps, and the `ade --socket browser ...` driver for the in-app browser. `ade secrets list|get|set|delete` is the typed surface for encrypted project-scoped ADE secrets that agents may read when the user names a secret. `ade new chat --mode chat|cli --lane --provider codex --model --reasoning-effort --no-fast --permissions full-auto --type --prompt "..."` mirrors the desktop New Chat toggle. `ade chat create` / `ade new --mode chat` default `orchestrationParentSessionId` from `ADE_CHAT_SESSION_ID` so agent-spawned chats link back to the spawning chat (`--parent ` overrides, `--no-parent` opts out). `--type` (chat mode only; alias `--spawn-type`) sets the child's `AgentChatSpawnKind` and thereby its completion-report policy — see [Chat › Spawn types and completion reporting](../chat/README.md#spawn-types-and-completion-reporting). `ade chat read --text` reads recent transcript messages. `ade chat scheduled-work list [session] [--all]` reads the runtime's durable management store, while `ade chat scheduled-work cancel ` uses the same provider-aware cancellation path as desktop Settings. `ade chat ... --personal` lists, creates, reads, sends to, interrupts, archives, and deletes machine-owned projectless chats through the running brain. `ade lanes link-linear-issue --linear-issue-json '{...}'` (aliases `link-linear`, `linear-link`) links Linear issues to an existing lane. | +| `apps/ade-cli/src/cli.ts` | Agent-focused `ade` command surface and text/JSON output formatters. Includes the `ade ios-sim` (alias `ade ios`, `ade simulator`) family — see [iOS Simulator feature](../ios-simulator/README.md), the `ade --socket app-control ...` driver for live Electron apps, and the `ade --socket browser ...` driver for the in-app browser. `ade secrets list|get|set|delete` is the typed surface for encrypted project-scoped ADE secrets that agents may read when the user names a secret. `ade new chat --mode chat|cli --lane --provider codex --model --reasoning-effort --no-fast --permissions full-auto --type --prompt "..."` mirrors the desktop New Chat toggle. `ade chat create` / `ade new --mode chat` default `orchestrationParentSessionId` from `ADE_CHAT_SESSION_ID` so agent-spawned chats link back to the spawning chat (`--parent ` overrides, `--no-parent` opts out). `--type` (chat mode only; alias `--spawn-type`) sets the child's `AgentChatSpawnKind` and thereby its completion-report policy — see [Chat › Spawn types and completion reporting](../chat/README.md#spawn-types-and-completion-reporting). `ade chat read --text` reads recent transcript messages. `ade chat scheduled-work create --cron "" --prompt "" [--once] [--reason ""] [--session ]` creates a provider-neutral durable wakeup; list/cancel use the same runtime management store and cancellation path as desktop Settings. `ade chat ... --personal` lists, creates, reads, sends to, interrupts, archives, and deletes machine-owned projectless chats through the running brain. `ade lanes link-linear-issue --linear-issue-json '{...}'` (aliases `link-linear`, `linear-link`) links Linear issues to an existing lane. | | `apps/ade-cli/src/services/account/accountAuthService.ts` | Optional ADE account auth for humans, remote agents, and CI: loopback OAuth, account-directory device authorization, shared `account.session.v1` refresh storage, JWT-`exp`-authoritative access-token refresh, one cross-process refresh-rotation recovery attempt after `invalid_grant`, and ephemeral `ADE_ACCOUNT_TOKEN` credentials. | -| `apps/ade-cli/src/adeRpcServer.ts` | Private ADE action RPC: registers actions, handles JSON-RPC, applies session-identity-based filtering, builds lane-scoped ADE guidance / `ADE_AGENT_SKILLS_DIRS` for CLI launches, injects an explicitly enabled and verified direct Computer Use MCP client into tracked Codex launches, and returns GitHub + ADE PR URLs from PR creation tools when available. | +| `apps/ade-cli/src/adeRpcServer.ts` | Private ADE action RPC: registers actions, handles JSON-RPC, applies session-identity-based filtering, builds lane-scoped ADE guidance / `ADE_AGENT_SKILLS_DIRS` for CLI launches, injects an explicitly enabled and verified direct Computer Use MCP client into tracked Codex launches, and returns GitHub + ADE PR URLs from PR creation tools when available. For `chat.createScheduledWork`, `listScheduledWork`, and `cancelScheduledWork`, a bound non-CTO agent defaults an omitted target to its own chat and cannot name another session; an external role without a bound chat is denied. | | `apps/desktop/src/main/services/builtInBrowser/builtInBrowserActorCapabilities.ts`, `desktopBridgeServer.ts`; `apps/ade-cli/src/services/builtInBrowser/desktopBridgeClient.ts` | Browser-automation security boundary. ADE issues an opaque in-memory capability for each chat-owned agent/terminal; the runtime strips caller routing and carries the token over a separately authenticated bridge, then Electron validates it in the issuing process and restores only its bound browser scope. | | `apps/desktop/src/main/utils/codexComputerUse.ts` | Security boundary for direct Codex Computer Use: explicit config opt-in, stable/cache candidate resolution, executable check, and strict OpenAI code-signature identity verification. | | `apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md` | Agent-facing ADE CLI control-plane guidance. | @@ -52,6 +52,12 @@ rejects missing capabilities and strips forged routing; Electron validates the opaque token in its issuing process before restoring the bound scope. Neither path exposes renderer-only profile diagnostics or permission administration. +Every regular chat runtime can schedule its own durable future work through +`ade chat scheduled-work create` or +`ade actions run chat.createScheduledWork`. The schedule is ADE-owned rather +than provider-owned, so Codex, Cursor, Droid, OpenCode, and Claude all use the +same runtime scheduler and `messageSession(kind: "wake")` delivery path. + On macOS, Codex agents can use the canonical `mcp__computer_use` tool when the user explicitly enabled the bundled Computer Use plugin or MCP server and the standalone OpenAI helper passes signature verification. The same path is diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index c3bc444b0..49362b26a 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -25,7 +25,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, external chat import orchestration (`importExternalChatSession` for Claude/Codex sessions discovered by the external-session service), turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, durable chat transcript replay/storage compaction, slash-command discovery/merge (delegates to per-provider discovery modules and `slashCommandPromptExpansion` for unified prompt expansion), and active-workload detection used by project/window close guards. Codex non-retrying app-server failures are deduplicated by turn plus semantic error identity across the early `error` notification and terminal `turn/completed`; retrying notifications (`willRetry: true`) remain provider-health notices while the turn stays active. Lane naming runs through the session-intelligence prompt path, retries the configured/requested/default title models — the auto-title candidate order prefers the configured `titleModelId` before the session's `requestedModelId` — then falls back to a deterministic prompt slug; branch uniqueness is handled by the lane id suffix added by lane creation. Tracks Fast Mode with the legacy `codexFastMode: boolean` session field for every provider whose descriptor advertises `serviceTiers: ["fast"]`; Codex forwards it as `serviceTier: "fast" \| null` on every `thread/start` and `turn/start` JSON-RPC call, while Cursor SDK sessions resolve it through discovered model parameters (see [Agent Routing](agent-routing.md#provider-service-tiers-fast-mode)). Codex chat goals are managed through the app-server `thread/goal/get` / `set` / `clear` RPCs, persisted in session summaries, validated to the provider's 4,000-character objective limit, and normalized to ADE's unlimited-budget policy by sending `tokenBudget: null` and clearing provider-reported budgets. `applyCodexEffectiveThreadState` accepts a `requestedCodexPolicy` option and uses `shouldPreserveRequestedCodexPolicy` to keep ADE-controlled picker selections authoritative when the lifecycle response echoes an older thread policy (prevents a manual Plan→Edit switch from snapping back); it also syncs the abstract `permissionMode` via `syncLegacyPermissionMode` after every policy application. Whenever an `updateSession` touches any permission/interaction/mode field, the service also emits a transient `session_meta_updated` chat event carrying the recomputed mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`/`codexSandbox`/`codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, and the `cursorModeSnapshot`) so any other client viewing the same session — a desktop refreshing a session an iOS device just re-moded, or vice versa — updates its composer controls live. It is a direct state patch, emitted after the Cursor policy sync so `cursorModeSnapshot` reflects the recomputed mode, and is kept off the session-list refresh path. Builds ADE guidance from the active lane worktree so Agent Skill roots are lane-scoped in persistent system/developer prompts and provider fallback injection. Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). When the session has Linear issues attached (`session_linear_issues`), `buildAgentRuntimeEnv` also materializes them into a per-session context file via `writeSessionLinearIssueContextFile` (`//linear-issues.json`, written atomically; stale files cleared when nothing is attached) and sets `ADE_LINEAR_ISSUE_IDS` (comma-joined identifiers) + `ADE_LINEAR_CONTEXT_FILE` so the agent reads its issue context without Linear credentials. Attaching a `linear_issue` context attachment at run time calls `laneService.attachLinearIssueToSession({ chatSessionId, issues, role: "worked", source: "chat_attach", includeInPr: true })` so the link is persisted even for standalone (laneless) chats; when the session has a lane it additionally runs `laneService.linkLinearIssues` for the lane/PR-card semantics. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks; interrupted Claude turns stop active subagents before emitting stopped `subagent_result`s, and every `subagent_result` is gated on a previously emitted `subagent_started` (tracked in `emittedSubagentStartIds`) so an interrupt can never emit a phantom stopped card for a subagent that never announced — terminal events clear both the taskId and agentId aliases. A plain Claude Code task run (`task_type` `other`, no agent metadata — e.g. "Re-run affected test files") is tracked for cleanup but never surfaces subagent rows. Claude resume paths run `claudeThinkingTranscriptRepair` before loading a transcript, and the runtime self-heals the same corruption after the Anthropic thinking-block 400 error. Full-auto plan acceptance emits the same plan-mode exit notice as the manual approval path so the renderer composer chip can update even when the session refresh races with compaction. Cursor SDK setup records interrupts that arrive while the worker is still being acquired, releases the acquired generation if setup loses the race, and suppresses false provider-health failures for user-initiated setup interrupts. Cursor provider slash commands use a dedicated discovery path (`cursorSlashCommandDiscovery`) instead of falling through to the generic filesystem-backed list. Claude query startup is single-flight: concurrent `ensureClaudeQuery` callers latch onto one in-flight `queryStartPromise`, and a per-runtime `queryGeneration` token aborts and reaps a start that a reset or interrupt superseded, so a resumed session never spawns twin subprocesses; both reset and interrupt reap the SDK subprocess through `claudeSubprocessReaper` because a closed `query()` still leaves a live `claude --resume` child. `run_in_background` shell tasks (SDK `task_type` `local_bash`/`background`) survive turn boundaries — the query stays alive across turns and delivers their real completion — so only interrupt, reset/dispose, or a host-restart rebind settle them as stopped; a reset that orphans still-open background tasks emits one `system_notice` that they were stopped without reporting completion, and background-task titles are sticky (the first spawn description is reused through the terminal row). A durable per-`(SDK message id, content index)` emitted-text record keeps a re-delivered assistant snapshot (after a stream-dedup reset from steer, message interleave, or idle handoff) from doubling the transcript. Claude `TaskCreate`/`TaskUpdate` tracking keys creates by tool-use id and remaps the harness's ordinal task id onto the Nth created task; an update for an id it cannot resolve or describe changes nothing rather than fabricating a todo row. `steer()` returns `AgentChatSteerResult` (`{ steerId, queued, reason?: "queue_full" }`); reasoning effort is normalized and applied at steer delivery, and an active Claude `interrupt-replace` uses SDK priority `now` without tearing down the query or its background work. When a spawned child chat ends, `reportChildSpawnEnded` reports its outcome to the spawner according to the child's `spawnKind` (see [Spawn types and completion reporting](#spawn-types-and-completion-reporting)); spawned agents also inherit `ADE_PARENT_CHAT_SESSION_ID` / `ADE_SPAWN_KIND` and a subagent self-report guidance line. Large service file. | | `apps/desktop/src/main/services/chat/providerResumeClassifier.ts` | Classifies Codex resume failures without conflating missing threads with MCP/provider-environment or transient transport failures; rollout-file evidence keeps a locally known thread from being declared missing. | | `apps/desktop/src/renderer/components/chat/ChatContinuityRecoveryCard.tsx` | Renders the explicit continuity-recovery choices from a `system_notice`: retry the preserved thread, reconstruct from durable ADE history, or start a separate chat. | -| `apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts` | Runtime-owned durable mirror and wake coordinator for Claude `ScheduleWakeup`, every successful `CronCreate`, and `/loop`. ADE's mirror is the delivery source of truth; Claude's native scheduler is an advisory latency path. The scheduler gives native Claude fire 90 seconds to claim a due record before ADE's timer backstops it, while non-Claude schedules retain their exact fire time. It persists versioned records, provider ids, expiry/terminal timestamps, and per-chat pause state in the project SQLite `kv` store; restores and re-arms them on service start; coalesces overdue work to one late fire; and reports transitions back to `agentChatService`. Startup migration drops the pre-1.2.27 `cron-tool:` intent placeholders that Claude could never cancel, quarantines older active provider rows in a paused state for operator review, and bounds terminal history to the newest 200 rows or seven days. Uses injected time/timer/persistence adapters so restart, pause, collision, migration, expiry, and catch-up behavior can be tested without Electron. | +| `apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts` | Runtime-owned durable mirror and wake coordinator for provider-neutral ADE action schedules plus Claude `ScheduleWakeup`, every successful `CronCreate`, and `/loop`. ADE's mirror is the delivery source of truth; Claude's native scheduler is an advisory latency path. The scheduler gives native Claude fire 90 seconds to claim a due record before ADE's timer backstops it, while provider-neutral schedules retain their exact fire time. It persists versioned records, optional provider ids, expiry/terminal timestamps, and per-chat pause state in the project SQLite `kv` store; restores and re-arms them on service start; coalesces overdue work to one late fire; and reports transitions back to `agentChatService`. Startup migration drops the pre-1.2.27 `cron-tool:` intent placeholders that Claude could never cancel, quarantines older active provider rows in a paused state for operator review, and bounds terminal history to the newest 200 rows or seven days. Uses injected time/timer/persistence adapters so restart, pause, collision, migration, expiry, and catch-up behavior can be tested without Electron. | | `apps/desktop/src/main/services/chat/externalChatHistoryImport.ts` | Converts external Claude JSONL and Codex thread-turn history into ADE `AgentChatEventEnvelope` rows. It reads at most the last 32 MB of source transcript bytes, keeps the newest 2,000 imported content events, emits system notices for provenance/truncation, drops metadata-only/provider-wrapper user rows without stripping user-authored JSX/XML, preserves failed Claude tool-result status, maps user/assistant text plus tool calls/results/file changes/commands/search/image events where available, and derives a fallback imported-chat title from the first user or assistant text. | | `apps/desktop/src/main/services/chat/runtimeEvents.ts` | Canonical cross-runtime event vocabulary (`turn.*`, `content.delta`, `tool.*`, `subagent.*`, teammate/task events, compaction boundaries) plus shims between legacy `AgentChatEvent` rows and the canonical runtime envelope. Claude emits canonical subagent events alongside the legacy rows while the other adapters migrate. | | `apps/desktop/src/main/services/chat/contextCompactionEmitter.ts` | Normalizes Claude, Codex, OpenCode, Cursor, and Droid compaction lifecycle events into provider-tagged `context_compact` rows. It pairs started/completed boundaries, preserves provider-reported pre/post token counts and duration, and maintains the per-session compaction count used by transcript surfaces. | @@ -72,7 +72,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/main/services/chat/claudeWorkflowProgress.ts` | Defensive normalizer for the Claude Agent SDK's undocumented `workflow_progress` snapshot on `system:task_progress` (Workflow orchestration runs). Parses phases + per-agent entries (caps counts, clips previews, drops malformed entries, unknown states degrade to queued/running; unparseable snapshots return undefined so the generic task rendering is untouched), then `planClaudeWorkflowAgentTransitions` diffs each cumulative tick against per-task emit state to fan out `subagent_started/progress/result` events under a stable `::a` identity with the emitted agentId latched at first emission. Consumed by `agentChatService`'s `task_progress`/`task_notification` handlers and the interrupt path (which close still-running agents as `stopped`). | | `apps/desktop/src/shared/chatMosaic.ts` | Mosaic v1 — agent-emitted interactive cards. Strict versioned (`"v":1`) parser for ```` ```mosaic ```` fence bodies (`parseMosaicCard`: unknown version/element types, duplicate ids, or malformed JSON → null → callers render the plain fence), submission serializer (`serializeMosaicSubmission`: readable lines + machine JSON, sent through the normal `agentChat.send` path with `displayText`), and `summarizeMosaicCard` for the TUI's one-line summary. Data only — no expressions, no eval, no host actions. Schema documented for agents in the `ade-mosaic` Agent Skill (`apps/desktop/resources/agent-skills/ade-mosaic/SKILL.md`). | | `apps/desktop/src/renderer/components/chat/MosaicCard.tsx` | Interactive mosaic card renderer (text, select, multiselect, number/slider, input, approve/deny, key-value table). Hooked in at `MarkdownBlock`'s code-fence handler behind a Claude-gated `mosaic` context prop from `AgentChatPane`; answered state persists across virtualized unmounts via a session-lifetime latch that rolls back on send failure. Non-Claude sessions render the plain fence. | -| `apps/desktop/src/shared/types/chat.ts` | All chat types: `AgentChatSession`, `AgentChatEvent` union, `AgentChatEventHistorySnapshot` (with optional `sessionFound` for stale-session detection), provider-neutral `AgentChatMcpToolSource` / app context metadata, Codex goal/token-usage/runtime-state DTOs (`CodexSafetyBufferingState`, moderation metadata, sleep/thread-deleted/stall events), the `web_search` event's structured `CodexWebSearchResult[]` (`results`, max 8) plus `resultsTotal`, typed Codex goal/recovery control args, image generation/view events with large-inline-payload omission metadata, permission modes, pending input (including app-server `autoResolutionMs`), completion reports, `AgentChatMessageSession*` peer-message routing DTOs (`auto` / `queue` / `wake` / `interrupt-replace`), `AgentChatSetScheduledWorkPaused*`, `PARALLEL_CHAT_MAX_ATTACHMENTS`, and parallel launch state DTOs. `AgentChatSubagentSnapshot.label` carries provider-assigned display labels such as Codex Agent #N. `AgentChatScheduledWakeMetadata` marks synthetic unattended user turns with schedule id, kind, fire time, reason, and late state. `scheduled_work_update` captures Claude wake/cron/background lifecycle including `paused`, `firedAt`, and `late`; `AgentChatSessionSummary.nextWakeAt` and `scheduledWorkPaused` project durable scheduler state into session lists. `transcript_retraction` removes provider-superseded assistant text from renderers without rewriting the persisted JSONL stream. `user_message` events may also carry metadata such as `hideFullPrompt` for internal handoff briefs, while `displayText` remains the user-facing transcript text. `AgentChatSessionSummary.linearIssueLinks?: SessionLinearIssueLink[]` carries the Linear issues attached to the session (chat or CLI), populated from `session_linear_issues` independent of any lane link. The `session_meta_updated` event additionally carries optional permission/interaction mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`, `codexSandbox`, `codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, `cursorModeSnapshot`) so a mode change made on one client patches every other client's composer state; a title-only emit carries none of them and stays backward-compatible. | +| `apps/desktop/src/shared/types/chat.ts` | All chat types: `AgentChatSession`, `AgentChatEvent` union, `AgentChatEventHistorySnapshot` (with optional `sessionFound` for stale-session detection), provider-neutral `AgentChatMcpToolSource` / app context metadata, Codex goal/token-usage/runtime-state DTOs (`CodexSafetyBufferingState`, moderation metadata, sleep/thread-deleted/stall events), the `web_search` event's structured `CodexWebSearchResult[]` (`results`, max 8) plus `resultsTotal`, typed Codex goal/recovery control args, image generation/view events with large-inline-payload omission metadata, permission modes, pending input (including app-server `autoResolutionMs`), completion reports, `AgentChatMessageSession*` peer-message routing DTOs (`auto` / `queue` / `wake` / `interrupt-replace`), `AgentChatCreateScheduledWork*`, `AgentChatSetScheduledWorkPaused*`, `PARALLEL_CHAT_MAX_ATTACHMENTS`, and parallel launch state DTOs. `AgentChatSubagentSnapshot.label` carries provider-assigned display labels such as Codex Agent #N. `AgentChatScheduledWakeMetadata` marks synthetic unattended user turns with schedule id, kind, fire time, reason, and late state. `scheduled_work_update` captures action/Claude wake/cron/background lifecycle including `paused`, `firedAt`, and `late`; `AgentChatSessionSummary.nextWakeAt` and `scheduledWorkPaused` project durable scheduler state into session lists. `transcript_retraction` removes provider-superseded assistant text from renderers without rewriting the persisted JSONL stream. `user_message` events may also carry metadata such as `hideFullPrompt` for internal handoff briefs, while `displayText` remains the user-facing transcript text. `AgentChatSessionSummary.linearIssueLinks?: SessionLinearIssueLink[]` carries the Linear issues attached to the session (chat or CLI), populated from `session_linear_issues` independent of any lane link. The `session_meta_updated` event additionally carries optional permission/interaction mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`, `codexSandbox`, `codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, `cursorModeSnapshot`) so a mode change made on one client patches every other client's composer state; a title-only emit carries none of them and stays backward-compatible. | | `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Top-level renderer surface: state derivation, IPC wiring, composer mount, message-list mount, End/Delete chat controls in the header, parallel multi-model lane launch orchestration, transient-lane cleanup, and multi-lane deep-link navigation. Renders the inline `InlineQuestionRequestCard` (in `AgentChatMessageList`) when the active pending input is a question/structured-question. Resolves the surface accent colour through `providerChatAccent(provider)` so Claude/Codex/Cursor stay visually consistent regardless of model variant; the question/plan cards inherit that same `--chat-accent`. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts when IPC misses an event, even when the tile is not focused. A visible session whose transcript is still empty but whose summary no longer looks active receives two bounded forced history reads (after 900 ms and 3 s), covering newly-created headless sessions whose first append/event raced the renderer without introducing idle polling. Event-history snapshots with `sessionFound: false` clear stale locked-pane state instead of rendering a dead transcript. Draft chats scope their last-launch config by project/lane/surface/draft-kind and mark local model/reasoning/permission edits as touched so late lane-session hydration cannot overwrite the user's draft selection; composer text is also keyed by the real session id or the lane draft key (`draft:`) so switching draft lanes does not leak text through a shared null session key. During project transitions the pane blocks send/model/permission mutations and shows a "Project is switching..." composer placeholder so chat calls do not hit the wrong runtime binding. On macOS, polls `ade.iosSimulator.getStatus` and renders the iOS Simulator drawer toggle in the header when the platform is supported (see [iOS Simulator feature](../ios-simulator/README.md)); selecting elements inside the drawer flows back through the pane as `IosElementContextItem` chips on the composer. Polls `ade.appControl.getStatus` and exposes the App Control drawer toggle when the platform is supported, mounting `ChatAppControlPanel`; selections become `AppControlContextItem` chips + attachments on the composer. See [App Control](../computer-use/app-control.md). When mounted as a Work tile (`SessionSurface` passes `hideLaneToolDrawers={true}`) the iOS, App Control, and chat terminal drawer toggles are suppressed because the Work right-edge sidebar owns those lane-scoped drawers; hidden lane-tool mode also skips App Control status polling and terminal listing. Remote-bound panes further defer local-only App Control / proof snapshot polling until the matching drawer is open, delay unfinished parallel-launch cleanup recovery briefly after mount, cache chat-session lists and slash-command catalogs by active project root, and avoid mount-time session-delta fetches until a remote turn completes. The pane still listens on `ade:agent-chat:add-attachment` / `add-ios-context` / `add-app-control-context` / `add-builtin-browser-context` / `insert-draft` window events so selections from the sidebar flow into the active chat composer; event handlers match on either `sessionId` (for active sessions) or `draftTargetId` (for unsaved draft composers when `draftContextTargetId` is set), enabling the Work sidebar to insert context into a draft composer before a chat session exists. Work-tab CLI launches pass the active lane worktree into the shared launcher so the spawned CLI sees lane-aware Agent Skill roots. Work CLI launches intentionally skip the direct-argv path: the pane drops `command` / `args` from the `onLaunchPtySession` payload and always sends `startupCommand` plus `workCliStartupDelayMs = 180` so the spawned shell can finish drawing its prompt before the CLI invocation is typed in (see [pty-and-processes.md](../terminals-and-sessions/pty-and-processes.md#create-flow-createargs) for how `ptyService.create` consumes the delay). The `onLaunchCliSession` prop is typed as `(args: WorkPtyLaunchArgs) => Promise` and passes `disposition` matching the draft launch mode so background CLI launches do not steal focus. Internal draft launch state is structured through `DraftLaunchMode`, `DraftLaunchKind`, `DraftLaunchLaneTarget`, `StartedDraftLaunch`, and `DraftLaunchJob`. Each draft launch creates a `DraftLaunchJob` that tracks multi-step progress through a state machine (`creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` | `failed`; auto-created lanes are named deterministically up front and the AI rename runs in the background via `startBackgroundLaneNaming` / `startBackgroundParallelLaneNaming`, surfaced through `laneNamingStore`, so there is no blocking `naming-lane` phase) and stores it in the **root** store's `draftLaunchJobsByScope` (read via `useRootAppStore` / `rootAppStoreApi.getState()`) keyed by project root, lane, surface profile, and Work draft kind so loading/error strips survive pane remounts — and a remote project switch that tears down the originating per-project store — without leaking into another lane pane. The detached launch chain captures the originating `OpenProjectBinding`, passes it as a `pin` to branch/lane/chat/orchestration/PTY calls so a mid-launch project switch keeps targeting the originating runtime, pins rollback (`lanes.delete` / `agentChat.delete` with a `pin`) to that binding, and caps each step with `withDraftLaunchTimeout` (90 s). The composer is cleared optimistically when the job starts rather than after it finishes; active jobs remain visible while terminal rows are pruned by scope. The pane renders status strips with Open/Restore for ready/failed jobs, Dismiss for terminal jobs, and a hide-status escape hatch for stale active jobs. Failed jobs offer a Restore button that merges the snapshot back into the composer (merging attachments and context items by identity rather than replacing). `clearDraftLaunchComposer` resets the draft, attachments, and context items after a successful launch. `DraftLaunchJob` carries `draftKind` so the dismissible job strip's "Open" action restores the correct Work draft kind (chat vs. CLI). Proof remains chat-scoped and stays on the chat header. | | `apps/desktop/src/renderer/components/chat/usage/contextUsageModel.ts` | Provider-neutral context meter reducer. A completed `context_compact` boundary invalidates older usage for Claude, Codex, OpenCode, Cursor, and Droid; generic same-turn counters are ignored because those SDKs can report pre-compaction per-turn/cumulative totals after the history was replaced. Claude `postTokens` / exact `context_usage` and Codex `thread/tokenUsage/updated` snapshots can repopulate the meter immediately. Desktop, ADE Code, and iOS mirror this boundary rule. Codex token breakdowns are normalized in `agentChatService.ts` (`normalizeCodexTokenBreakdown`), which maps 0.145's `cacheWriteInputTokens` / `reasoningOutputTokens` onto `cacheWriteTokens` / `reasoningTokens`; the desktop `ContextUsageDial` tooltip renders `cache write` and `reasoning` segments (in addition to in/out/cached) whenever those counts are present. | | `apps/desktop/src/renderer/components/usage/ActivityModule.tsx` | Reusable activity, token, code-movement, and client-mix module. `AgentChatPane` mounts `WorkActivityModule` directly below an empty Work draft composer (desktop and web only); it reads `usage.getAdeStats` through the active `window.ade` adapter, defaults to all-time activity, and preserves explicit tab/range choices locally. | @@ -108,7 +108,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` → `InlineQuestionRequestCard` | Inline question / structured-question card rendered in the transcript (there is no longer a separate `AgentQuestionModal`). Header is the provider logo + a kind-derived verb (`{Provider} asks` / `{Provider} · Plan ready` via `pendingInputHeaderLabel`); body shows the question's `header` kicker then the question text once (no generic title); options render with radio/checkbox a11y roles; option previews render through `QuestionOptionPreview` — a column-preserving monospace `
` for wireframes/ASCII (detected via `looksLikeWireframe`) and the code-fence-aware `ChatMarkdown` for prose. Card chrome inherits `--chat-accent` (per-provider). Keyboard: digits toggle options, ↑↓ move highlight, ←→ page, Enter advances/sends; recommended option auto-focuses; ≥2 previews enable an A/B compare toggle. |
 | `apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts` | Two-layer event-to-row pipeline (render events + grouped envelopes) that powers the message list. It threads per-subagent anchor state through the collapse pass to emit identity-keyed `subagent_spawn_anchor` / `subagent_result_card` / `background_finish_chip` render events (keys `subagent-spawn:` / `subagent-result:` / `background-chip:`), mutating anchors in place as progress/result events arrive and repairing row positions when a `transcript_retraction` splices a row out — so the virtualizer's measured heights survive rebind. It derives a `scheduled_wake_divider` render event immediately before every synthetic `user_message` carrying `metadata.scheduledWake`, with a stable `scheduled-wake::` key for digest jumps. It also diffs `todo_update` snapshots per turn so only changed tasks render, normalizes dotted `subagent.*` lifecycle events into the legacy renderer shape while providers migrate, and falls back to a full collapse when incremental append would miss todo state. A second-layer grouping pass (`groupStoppedSubagentResultCards`) folds a run of two or more consecutive interrupt-stopped `subagent_result_card` rows into one `subagent_stopped_group` event; completed/failed cards and a lone stopped card stay individual. |
 | `apps/desktop/src/main/services/ai/tools/` | Tool tiers consumed by the service when it provisions a Claude/Codex/OpenCode runtime (see [Tool System](tool-system.md)). |
-| `apps/desktop/src/main/services/ipc/registerIpc.ts` | Validates chat IPC args, exposes `agentChat.*` handlers (including scheduled-work list, per-job cancel, and per-chat pause), persists/retrieves parallel launch recovery state in `kv`, and refreshes the runtime scheduler after the global AI config pause changes. |
+| `apps/desktop/src/main/services/ipc/registerIpc.ts` | Validates chat IPC args, exposes `agentChat.*` handlers (including scheduled-work create, list, per-job cancel, and per-chat pause), persists/retrieves parallel launch recovery state in `kv`, and refreshes the runtime scheduler after the global AI config pause changes. |
 | `apps/desktop/src/shared/ipc.ts` | `ade.agentChat.*` IPC channel constants. |
 
 ## Built-in browser authentication limits
@@ -138,7 +138,16 @@ This is the framing to internalise: chat sessions are runtime-owned,
 not desktop-owned. The renderer can render them, and the iOS app can
 render them, but neither one *runs* them.
 
-## Durable Claude scheduled work
+## Durable scheduled work
+
+Every provider runtime can create an ADE-owned schedule through
+`chat.createScheduledWork`. The action validates a non-empty prompt (maximum
+4,000 characters) and a five-field cron expression, defaults to recurring, and
+uses `recurring: false` for a one-shot at the next cron match. Its durable id is
+`action::` and it deliberately has no provider owner: delivery
+goes through `messageSession({ kind: "wake" })`, while cancellation is handled
+directly by ADE rather than a provider `CronDelete` round trip. Recurring action
+schedules use the same seven-day expiry as recurring Claude cron rows.
 
 Claude SDK chats can arm `ScheduleWakeup` one-shots, `CronCreate` jobs, and
 `/loop` self-pacing work. Claude remains the authority for whether the provider
@@ -153,11 +162,11 @@ state. `CronCreate` always creates a new provider job, so an
 agent replacing or resetting a watcher must `CronList` + `CronDelete` the old
 job before creating its replacement rather than relying on prompt de-duplication.
 
-ADE maintains a project-scoped durable mirror under the SQLite `kv` key
+ADE maintains a project-scoped durable store under the SQLite `kv` key
 `agent-chat:scheduled-work:v1` so a renderer, `ade serve`, or app restart does
 not lose the wake boundary. Each record carries the owning ADE session, the
-exact Claude SDK session that owns provider controls, ADE and provider
-schedule ids, provider, kind, full prompt/reason, cron or next
+optional exact Claude SDK session that owns provider controls, ADE and optional
+provider schedule ids, provider, kind, full prompt/reason, cron or next
 fire time, lifecycle and pause state, expiry, last fire, terminal timestamp,
 and late marker. Electron main and headless `ade serve` construct the same
 scheduler. Claude's Stop/SubagentStop `session_crons` snapshot reconciles the
@@ -225,8 +234,8 @@ it is never steered into or dispatched during the running turn. One-shot
 lifecycle is `scheduled` -> `fired` -> `completed`; completed
 wakeups move into Chat Info history. Recurring cron rows briefly record the
 fire, retain `lastRunAt`, and return to `scheduled` with their next fire time.
-Durable recurring Claude crons expire after seven days, matching the SDK
-contract. Terminal rows are retained for at most seven days and the newest 200
+Durable recurring Claude and action crons expire after seven days. Terminal
+rows are retained for at most seven days and the newest 200
 records, then pruned. No scheduled-work spend cap is applied.
 
 The startup migration is deliberately conservative. Pre-1.2.27
@@ -244,15 +253,22 @@ Controls and summaries project this runtime state rather than owning it:
   scheduled work** manager that lists every chat's durable job and can cancel
   one directly. Jobs normally clean themselves up; the manager is a recovery
   surface rather than a required setup step.
-- `ade chat scheduled-work list [session]` lists active managed jobs;
+- `ade chat scheduled-work create --cron "" --prompt ""
+  [--once] [--reason ""] [--session ]` creates an ADE-owned job.
+  `ade chat scheduled-work list [session]` lists active managed jobs;
   `--all` includes recent terminal history, and
   `ade chat scheduled-work cancel  ` routes through the same
   provider-aware cancellation path. The equivalent generic actions are
-  `chat.listScheduledWork` and `chat.cancelScheduledWork`.
-- iOS exposes Cancel in Chat Info only for durable rows and only when the
-  connected host advertises the non-queueable `chat.cancelScheduledWork`
-  remote action; older hosts remain view-only instead of accepting an action
-  they cannot execute.
+  `chat.createScheduledWork`, `chat.listScheduledWork`, and
+  `chat.cancelScheduledWork`. In an ADE-launched agent, create/list/cancel
+  default an omitted `sessionId` to `ADE_CHAT_SESSION_ID`; the daemon rejects a
+  bound agent that names another chat and rejects an external caller with no
+  bound session.
+- The mobile remote-command registry advertises non-queueable
+  `chat.createScheduledWork` and `chat.setScheduledWorkPaused` mutations, plus
+  `chat.cancelScheduledWork`. A controller gates create/pause/cancel on the
+  connected host's descriptors, so an older host stays read-only for whichever
+  controls it does not support.
 - Session summaries expose the earliest unpaused `nextWakeAt`; Work rows show
   it as a compact alarm countdown. The optional `scheduledWork` management
   snapshot lets desktop merge KV truth over stale transcript projections.
@@ -889,6 +905,7 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`.
 |---|---|---|
 | `ade.agentChat.list` | invoke | List sessions with optional `includeIdentity`, `includeAutomation`, `includeArchived` (defaults to `true`; pass `false` to filter out archived rows). Claude summaries include `nextWakeAt` (earliest armed, unpaused schedule), `scheduledWorkPaused`, and the optional KV-backed `scheduledWork` management snapshot. |
 | `ade.agentChat.getSummary` | invoke | Fetch `AgentChatSessionSummary` for a single session, including the durable schedule summary and management fields. |
+| `ade.agentChat.scheduledWork.create` | invoke | Create a durable ADE-owned recurring cron or one-shot wakeup for an active chat. The typed preload method is `window.ade.agentChat.createScheduledWork`. |
 | `ade.agentChat.listScheduledWork` | invoke | List KV-backed durable jobs across the project or for one `sessionId`. Active jobs are returned by default; `includeTerminal: true` adds bounded recent completed/cancelled history. |
 | `ade.agentChat.cancelScheduledWork` | invoke | Cancel one managed job by exact `sessionId` + `scheduleId`. ADE-only jobs cancel immediately. Claude-owned jobs are paused and routed through that chat's `CronDelete`; the result reports `providerCancellationRequested` and `providerCancellationConfirmed` instead of pretending an unconfirmed request already succeeded. If the stored owner is an earlier SDK session, ADE explicitly tombstones its local mirror and reports both provider fields false. |
 | `ade.agentChat.setScheduledWorkPaused` | invoke | Pause or resume every durable wakeup/cron/loop schedule for one chat. Returns the resulting pause state and recomputed `nextWakeAt`; overdue work follows the one-late-fire rule after resume. |
diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md
index ed5ded430..2d085cdf0 100644
--- a/docs/features/sync-and-multi-device/ios-companion.md
+++ b/docs/features/sync-and-multi-device/ios-companion.md
@@ -104,6 +104,10 @@ an active durable row and only when `canInvokeChatRemoteAction` confirms that
 the selected chat's host advertises `chat.cancelScheduledWork`. The command is
 non-queueable: an offline phone or older brain leaves Chat Info view-only rather
 than recording a cancellation that could run after the job has already fired.
+The host registry also advertises non-queueable `chat.createScheduledWork` and
+`chat.setScheduledWorkPaused` mutations to mobile controllers. Those are remote
+transport capabilities; the current native Chat Info surface still implements
+only Cancel and must not infer create/pause UI from descriptor availability.
 
 The Settings machine card mirrors this state through
 `SettingsConnectionHeader`: connected limited hosts show a compact "Machine
@@ -1468,6 +1472,11 @@ reflected in the phone's UI on the next descriptor read.
 `WorkSessionDestinationView` asks `canInvokeChatRemoteAction` before constructing
 the cancellation callback, and `WorkScheduledWorkRow` additionally requires
 `durable == true` and an active status before rendering the control.
+`chat.createScheduledWork` and `chat.setScheduledWorkPaused` are also
+project-scoped and non-queueable, but require a controller role with mutation
+access (`viewerAllowed: false`). They are available to mobile clients through
+the host registry even though the current Swift UI does not render create or
+pause controls.
 
 The usage commands are viewer-allowed project actions:
 
diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md
index 67cf8068c..a6b44ddde 100644
--- a/docs/features/sync-and-multi-device/remote-commands.md
+++ b/docs/features/sync-and-multi-device/remote-commands.md
@@ -220,6 +220,7 @@ so the win is transport and decode, not host compute.
 
 **Chat** (`chat.*`)
 - `listSessions`, `getSummary`, `getTranscript`
+- `createScheduledWork`, `cancelScheduledWork`, `setScheduledWorkPaused`
 - `launch`, `getSlashCommands`, `resolveSmartLinkPreview`, `getContextUsage`, `warmupModel`,
   `getParallelLaunchState`, `setParallelLaunchState`, `handoff`,
   `prepareCrossMachineHandoff`, `validateCrossMachineSource`,
@@ -245,6 +246,17 @@ local/private, oversized, or non-HTML URLs fall back to the deterministic
 preview instead of failing the composer. The canonical URL is never replaced by
 the title.
 
+`chat.createScheduledWork` takes `{ sessionId, cron, prompt, recurring?,
+reason? }` and creates an ADE-owned durable schedule for any provider-backed
+chat. `recurring` defaults to true; false creates a one-shot at the next
+five-field cron match. `chat.setScheduledWorkPaused` takes `{ sessionId,
+paused }`, and `chat.cancelScheduledWork` takes `{ sessionId, scheduleId }`.
+Create and pause are controller mutations (`viewerAllowed: false`); cancel
+retains its viewer-allowed recovery policy. All three are deliberately
+non-queueable so an offline replay cannot create a duplicate schedule or apply
+stale management state. Controllers expose each control only when the brain's
+descriptor list advertises it.
+
 `chat.modelCatalog` accepts `{ mode?, refreshProvider?, cursorSource? }`
 where `mode` is `"cached" | "refresh-stale" | "force"` (default
 `"cached"`) and `refreshProvider` is `"opencode" | "cursor" | "droid" |

From f14dcf6460554e0ed8b861c6aeab83baa7e9fca8 Mon Sep 17 00:00:00 2001
From: Arul Sharma <31745423+arul28@users.noreply.github.com>
Date: Thu, 16 Jul 2026 21:11:22 -0400
Subject: [PATCH 3/5] feat(sched): per-runtime scheduling guidance + iOS/TUI
 scheduled-work parity

Prompts: Claude blocks now state native cron tools are ADE-durable-backed
(SDK view advisory); Codex/Cursor/Droid/OpenCode blocks replace 'no autonomous
wake' with chat.createScheduledWork guidance; CLI guidance + ade-cli-control-plane
skill document the scheduled-work surface. Docs aligned (ARCHITECTURE, chat
features, public capabilities.mdx).

iOS: scheduledWorkPaused/nextWakeAt summary fields (optional, legacy-safe),
pause/resume via chat.setScheduledWorkPaused with optimistic rollback, paused
row treatment, next-wake line. TUI: paused header suffix. Desktop: regression
coverage for action-origin rows.

Co-Authored-By: Claude Fable 5 
---
 .../tuiClient/__tests__/RightPane.test.tsx    | 33 +++++++
 .../src/tuiClient/__tests__/chatInfo.test.ts  | 48 +++++++++-
 apps/ade-cli/src/tuiClient/chatInfo.ts        |  1 +
 .../src/tuiClient/components/RightPane.tsx    | 25 ++++-
 apps/ade-cli/src/tuiClient/types.ts           |  2 +
 .../ade-cli-control-plane/SKILL.md            | 22 +++++
 .../services/ai/tools/systemPrompt.test.ts    | 22 +++--
 .../main/services/ai/tools/systemPrompt.ts    | 14 +--
 .../main/services/chat/agentChatService.ts    |  4 +-
 .../chat/ChatSubagentsPanel.test.tsx          | 30 ++++++
 .../settings/AiFeaturesSection.test.tsx       |  7 +-
 .../desktop/src/shared/adeCliGuidance.test.ts |  2 +
 apps/desktop/src/shared/adeCliGuidance.ts     |  1 +
 apps/ios/ADE/Models/RemoteModels.swift        | 12 +++
 apps/ios/ADE/Services/SyncService.swift       | 13 +++
 .../Views/Work/WorkChatRichCardViews.swift    | 95 ++++++++++++++++---
 .../Work/WorkSessionDestinationView.swift     | 42 +++++++-
 apps/ios/ADETests/ADETests.swift              | 29 +++++-
 chat/capabilities.mdx                         |  7 +-
 docs/ARCHITECTURE.md                          |  4 +-
 docs/features/chat/README.md                  | 11 ++-
 docs/features/chat/transcript-and-turns.md    |  2 +-
 22 files changed, 376 insertions(+), 50 deletions(-)

diff --git a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx
index 298abb988..b89601dad 100644
--- a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx
+++ b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx
@@ -369,6 +369,39 @@ describe("RightPane chat info", () => {
     expect(frame).toContain("⏰ next wake 12m");
   });
 
+  it("labels a paused schedule in the Schedule header", () => {
+    const result = render(
+      ,
+    );
+    const frame = stripAnsi(result.lastFrame() ?? "");
+
+    expect(frame).toContain("SCHEDULE (paused)");
+    expect(frame).toContain("Check CI");
+    expect(frame).toContain("cron · scheduled");
+    expect(frame).toContain("*/20 * * * *");
+  });
+
   it("does not render a next wake line for invalid or past timestamps", () => {
     for (const nextWakeAt of ["not-a-date", new Date(Date.now() - 1_000).toISOString()]) {
       const result = render(
diff --git a/apps/ade-cli/src/tuiClient/__tests__/chatInfo.test.ts b/apps/ade-cli/src/tuiClient/__tests__/chatInfo.test.ts
index b316e9932..e4ee3a56b 100644
--- a/apps/ade-cli/src/tuiClient/__tests__/chatInfo.test.ts
+++ b/apps/ade-cli/src/tuiClient/__tests__/chatInfo.test.ts
@@ -177,10 +177,13 @@ describe("deriveChatInfoSnapshot", () => {
     expect(snapshot.inspectedSubagentId).toBe("x1");
   });
 
-  it("carries the active session's earliest next wake into chat info", () => {
+  it("carries the active session's scheduled-work state into chat info", () => {
     const snapshot = deriveChatInfoSnapshot({
       events: [],
-      activeSession: session({ nextWakeAt: "2026-07-09T12:12:00.000Z" }),
+      activeSession: session({
+        scheduledWorkPaused: true,
+        nextWakeAt: "2026-07-09T12:12:00.000Z",
+      }),
       provider: "claude",
       modelLabel: "claude-opus-4-8",
       laneLabel: "lane",
@@ -190,9 +193,50 @@ describe("deriveChatInfoSnapshot", () => {
       streaming: false,
     });
 
+    expect(snapshot.scheduledWorkPaused).toBe(true);
     expect(snapshot.nextWakeAt).toBe("2026-07-09T12:12:00.000Z");
   });
 
+  it("keeps action-created providerless schedules in the normal schedule pipeline", () => {
+    const snapshot = deriveChatInfoSnapshot({
+      events: [{
+        sessionId: "session-1",
+        timestamp: "2026-07-09T12:00:00.000Z",
+        sequence: 1,
+        event: {
+          type: "scheduled_work_update",
+          id: "action:session-1:job-1",
+          kind: "cron",
+          status: "scheduled",
+          origin: "action",
+          title: "Check CI",
+          prompt: "Inspect the latest CI run",
+          reason: "Keep the PR moving",
+          cron: "*/20 * * * *",
+          recurring: true,
+          durable: true,
+        },
+      }],
+      activeSession: session({ provider: "codex" }),
+      provider: "codex",
+      modelLabel: "gpt-5.5",
+      laneLabel: "lane",
+      snapshots: [],
+      tokenStats: null,
+      goal: null,
+      streaming: false,
+    });
+
+    expect(snapshot.scheduledWork).toEqual([
+      expect.objectContaining({
+        id: "action:session-1:job-1",
+        kind: "cron",
+        origin: "action",
+        title: "Check CI",
+      }),
+    ]);
+  });
+
   it("carries the Claude session tag into the chat info header", () => {
     const snapshot = deriveChatInfoSnapshot({
       events: [],
diff --git a/apps/ade-cli/src/tuiClient/chatInfo.ts b/apps/ade-cli/src/tuiClient/chatInfo.ts
index 23575a58a..0272afae6 100644
--- a/apps/ade-cli/src/tuiClient/chatInfo.ts
+++ b/apps/ade-cli/src/tuiClient/chatInfo.ts
@@ -90,6 +90,7 @@ export function deriveChatInfoSnapshot(args: {
     // Schedule kinds only — background command tasks render in their own block.
     scheduledWork: mergeManagedScheduledWorkSnapshots(args.events, args.activeSession?.scheduledWork)
       .filter((item) => item.kind !== "background_task"),
+    scheduledWorkPaused: args.activeSession?.scheduledWorkPaused === true,
     nextWakeAt: args.activeSession?.nextWakeAt ?? null,
     backgroundWork: deriveBackgroundItems(args.events),
     pr: args.pr ?? null,
diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx
index cbbb8b2a5..c89e9b2da 100644
--- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx
+++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx
@@ -686,15 +686,28 @@ function selectedRosterDetail(snapshot: SubagentSnapshot, capability: SubagentCa
     || "…";
 }
 
-function ChatInfoSectionHead({ title, hint, color, width }: { title: string; hint?: string; color: string; width?: number }) {
+function ChatInfoSectionHead({
+  title,
+  dimSuffix,
+  hint,
+  color,
+  width,
+}: {
+  title: string;
+  dimSuffix?: string;
+  hint?: string;
+  color: string;
+  width?: number;
+}) {
   // Section header with a hairline rule that fills the gap to the hint, so each
   // block reads as a titled card divider rather than a bare label.
   const inner = Math.max(12, (width ?? 40) - 4);
-  const used = title.length + 2 + (hint ? hint.length + 1 : 0);
+  const used = title.length + (dimSuffix ? dimSuffix.length + 1 : 0) + 2 + (hint ? hint.length + 1 : 0);
   const ruleLen = Math.max(1, inner - used);
   return (
     
       {title}
+      {dimSuffix ? {` ${dimSuffix}`} : null}
       {` ${"─".repeat(ruleLen)}${hint ? " " : ""}`}
       {hint ? {hint} : null}
     
@@ -1127,7 +1140,13 @@ function ChatInfoScheduleBlock({ info, brandColor, width, viewState }: { info: C
   };
   return (
     
-      
+      
       {nextWake ? (
         
           {` ⏰ next wake ${nextWake}`}
diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts
index 9ea11bb53..fd9f29338 100644
--- a/apps/ade-cli/src/tuiClient/types.ts
+++ b/apps/ade-cli/src/tuiClient/types.ts
@@ -188,6 +188,8 @@ export type ChatInfoSnapshot = {
   todos: ChatInfoTodoItem[];
   /** Schedule kinds only (wakeup/cron/loop/remote_trigger). */
   scheduledWork: ChatScheduledWorkSnapshot[];
+  /** True when all durable schedules for the active chat are paused. */
+  scheduledWorkPaused?: boolean;
   /** Earliest armed, unpaused wake reported by the active chat session. */
   nextWakeAt?: string | null;
   /** Background command tasks (kind background_task). */
diff --git a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md
index 12755690c..75a6e5298 100644
--- a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md
+++ b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md
@@ -148,6 +148,28 @@ Use `ade chat show  --text` before messaging a chat you do not own:
   `ade chat send ...` with the new instruction. Do not send a second normal turn
   into an active chat and hope the provider interprets it as steering.
 
+### Scheduled work
+
+Persistent ADE chats can schedule their own durable wakeups from every provider
+runtime. Use the typed `ade chat scheduled-work create|list|cancel` commands or
+the generic `chat.createScheduledWork`, `chat.listScheduledWork`, and
+`chat.cancelScheduledWork` actions. Pause or resume a chat with
+`ade chat schedules  --pause|--resume` or
+`chat.setScheduledWorkPaused`.
+
+Omitting the target in a bound agent session defaults create/list to
+`ADE_CHAT_SESSION_ID`; a caller cannot schedule another chat, and an unbound CLI
+session fails instead of creating orphaned work. Delivery starts a new turn at
+the next safe turn boundary, survives brain restarts, and recurring schedules
+expire after seven days. Users can also pause one chat in Chat Info or all
+scheduled work in Settings.
+
+```
+ade actions run chat.createScheduledWork --input-json '{"cron":"9,29,49 * * * *","prompt":"Check CI and report"}' --text
+ade chat scheduled-work list --all --text
+ade chat schedules "$ADE_CHAT_SESSION_ID" --pause --text
+```
+
 Compatibility commands still exist, but do not teach them as the first choice:
 
 ```
diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts
index 545b10e7a..0267c6d4b 100644
--- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts
+++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts
@@ -108,7 +108,8 @@ describe("buildCodingAgentSystemPrompt", () => {
       const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "codex-cli" });
       expect(result).toContain("## Runtime Environment");
       expect(result).toContain("Codex CLI");
-      expect(result).toContain("No autonomous wake from ADE");
+      expect(result).toContain("chat.createScheduledWork");
+      expect(result).toContain("targets your own chat session automatically");
     });
 
     it("describes the Codex app-server runtime", () => {
@@ -116,7 +117,8 @@ describe("buildCodingAgentSystemPrompt", () => {
       expect(result).toContain("## Runtime Environment");
       expect(result).toContain("Codex app-server protocol");
       expect(result).toContain("JSON-RPC");
-      expect(result).toContain("No autonomous wake from ADE");
+      expect(result).toContain("chat.createScheduledWork");
+      expect(result).toContain("next turn boundary");
     });
 
     it("describes durable Claude scheduled self-resume and pause controls", () => {
@@ -125,13 +127,14 @@ describe("buildCodingAgentSystemPrompt", () => {
       expect(result).toContain("Claude Agent SDK stable `query()`");
       expect(result).toContain("ScheduleWakeup");
       expect(result).toContain("CronCreate");
-      expect(result).toContain("can start a later unattended turn");
-      expect(result).toContain("mirrors every successful `CronCreate` durably");
-      expect(result).toContain("regardless of the provider tool's `durable` input");
+      expect(result).toContain("automatically backed by ADE's durable scheduler");
+      expect(result).toContain("the `durable` flag is not needed");
+      expect(result).toContain("next turn boundary even if the chat was busy");
+      expect(result).toContain("`CronList` view is advisory; ADE state wins");
       expect(result).toContain("CronCreate` always creates a new job");
-      expect(result).toContain("`CronList` and `CronDelete`");
-      expect(result).toContain("auto-expire after seven days");
-      expect(result).toContain("project-wide manager in Settings");
+      expect(result).toContain("`CronList` + `CronDelete`");
+      expect(result).toContain("expire after seven days");
+      expect(result).toContain("project-wide in Settings");
       expect(result).not.toContain("unavailable in this ADE chat");
       expect(result).not.toContain("will not start a later turn by itself");
     });
@@ -139,16 +142,19 @@ describe("buildCodingAgentSystemPrompt", () => {
     it("describes the Cursor SDK runtime", () => {
       const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "cursor-sdk" });
       expect(result).toContain("Cursor SDK");
+      expect(result).toContain("chat.createScheduledWork");
     });
 
     it("describes the Droid SDK runtime", () => {
       const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "droid-sdk" });
       expect(result).toContain("Factory Droid SDK");
+      expect(result).toContain("chat.createScheduledWork");
     });
 
     it("describes the OpenCode runtime", () => {
       const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "opencode" });
       expect(result).toContain("OpenCode session");
+      expect(result).toContain("chat.createScheduledWork");
     });
   });
 
diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts
index 0cccbf564..2dfb68ed0 100644
--- a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts
+++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts
@@ -17,38 +17,40 @@ export type AdeRuntimeKind =
   | "droid-sdk"
   | "opencode";
 
+const adeScheduledWorkGuidance = "**Wake-up semantics:** Autonomous wake is available via `ade actions run chat.createScheduledWork --input-json '{\"cron\":\"<5-field>\",\"prompt\":\"\"}' --text`. Jobs recur by default; pass `{\"recurring\":false}` for a one-shot, and the action targets your own chat session automatically. List, cancel, or pause with `chat.listScheduledWork`, `chat.cancelScheduledWork`, and `chat.setScheduledWorkPaused`, or the typed `ade chat scheduled-work ...` / `ade chat schedules ...` commands. Delivery starts a new turn at the next turn boundary and survives brain restarts; recurring jobs expire after seven days. Keep shell `sleep` for short waits inside the current turn.";
+
 function describeRuntime(runtime: AdeRuntimeKind): string[] {
   switch (runtime) {
     case "claude-agent-sdk-query":
       return [
         "**Runtime:** ADE Work chat hosted on the Claude Agent SDK stable `query()` streaming-input API.",
-        "**Wake-up semantics:** ADE follows the Claude Agent SDK schedule contract. `ScheduleWakeup`, `CronCreate`, and `/loop` can start a later unattended turn. `CronCreate` always creates a new job, so reset or replacement flows must use `CronList` and `CronDelete` to remove the prior job before creating another. ADE mirrors every successful `CronCreate` durably, regardless of the provider tool's `durable` input. Recurring crons auto-expire after seven days, matching Claude. ADE restores mirrored jobs after its brain restarts, late-fires overdue work once, and exposes pause/cancel controls in Chat Info plus a project-wide manager in Settings.",
+        "**Wake-up semantics:** Native `ScheduleWakeup`, `CronCreate`, and `/loop` are automatically backed by ADE's durable scheduler; the `durable` flag is not needed. They survive brain restarts and start a new turn at the next turn boundary even if the chat was busy when they became due. The SDK's own `CronList` view is advisory; ADE state wins. Pause schedules in Chat Info or project-wide in Settings. Recurring jobs expire after seven days. `CronCreate` always creates a new job, so replace one with `CronList` + `CronDelete` before creating another.",
         "**To wait:** For short bounded waits inside the current turn, a foreground command such as `sleep ... && ` is fine. For longer waits or autonomous follow-up, prefer `ScheduleWakeup`, `CronCreate`, or `/loop` and include a concise reason/prompt so ADE can show the pending work clearly.",
       ];
     case "codex-cli":
       return [
         "**Runtime:** ADE Work chat wrapping the Codex CLI as a subprocess. Your turns are driven through the Codex agent loop, but the orchestration host is ADE — slash commands, attachments, and lane scoping come from ADE.",
-        "**Wake-up semantics:** No autonomous wake from ADE. If you need to wait, prefer `sleep ... && ` so the shell holds the wait without burning model tokens, then resume reasoning when the command produces output.",
+        adeScheduledWorkGuidance,
       ];
     case "codex-app-server":
       return [
         "**Runtime:** ADE Work chat hosted on the Codex app-server protocol. Your turns are driven through Codex app-server JSON-RPC, while the orchestration host is ADE — slash commands, attachments, and lane scoping come from ADE.",
-        "**Wake-up semantics:** No autonomous wake from ADE. If you need to wait, prefer `sleep ... && ` so the shell holds the wait without burning model tokens, then resume reasoning when the command produces output.",
+        adeScheduledWorkGuidance,
       ];
     case "cursor-sdk":
       return [
         "**Runtime:** ADE Work chat hosted on the Cursor SDK (`@cursor/sdk`).",
-        "**Wake-up semantics:** Each turn is driven by ADE through the SDK agent run. There is no autonomous wake; if you need to wait, use a shell `sleep` and surface results in the next user turn.",
+        adeScheduledWorkGuidance,
       ];
     case "droid-sdk":
       return [
         "**Runtime:** ADE Work chat hosted on the Factory Droid SDK (`@factory/droid-sdk`) and backed by the local Droid CLI.",
-        "**Wake-up semantics:** Each turn is driven by ADE through the Droid SDK stream. There is no autonomous wake; if you need to wait, use a shell `sleep` and surface results in the next user turn.",
+        adeScheduledWorkGuidance,
       ];
     case "opencode":
       return [
         "**Runtime:** ADE Work chat wrapping an OpenCode session.",
-        "**Wake-up semantics:** Turns are driven by ADE through the OpenCode HTTP session. There is no autonomous wake; use a shell `sleep` for waits.",
+        adeScheduledWorkGuidance,
       ];
   }
 }
diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts
index 0942e23ec..e36f9f716 100644
--- a/apps/desktop/src/main/services/chat/agentChatService.ts
+++ b/apps/desktop/src/main/services/chat/agentChatService.ts
@@ -24750,8 +24750,8 @@ export function createAgentChatService(args: {
         append: [
           "## Runtime Environment",
           "**Runtime:** ADE Work chat hosted on the Claude Agent SDK stable `query()` streaming-input API. The `claude_code` preset above is the same system prompt the Claude Code CLI uses, so you may think you're in the CLI — you are NOT. You are inside an ADE-hosted SDK session.",
-            "**Wake-up semantics:** ADE durably supports Claude Code scheduled self-resume. `ScheduleWakeup`, `CronCreate`, and `/loop` can start a later unattended turn even after the ADE brain restarts; overdue work fires once when ADE resumes, then recurring cron work returns to its normal cadence. The user can pause this chat's schedules in Chat Info or pause all scheduled work in Settings.",
-            "**To wait:** For short bounded waits inside the current turn, a foreground command such as `sleep ... && ` is fine. For longer waits or autonomous follow-up, prefer the SDK's scheduled/background tools and include a concise reason/prompt so ADE can show the pending work clearly.",
+            "**Wake-up semantics:** Native `ScheduleWakeup`, `CronCreate`, and `/loop` are automatically backed by ADE's durable scheduler; the `durable` flag is not needed. They survive brain restarts and start a new turn at the next turn boundary even if the chat was busy when they became due. The SDK's own `CronList` view is advisory; ADE state wins. Pause schedules in Chat Info or project-wide in Settings. Recurring jobs expire after seven days. `CronCreate` always creates a new job, so replace one with `CronList` + `CronDelete` before creating another.",
+            "**To wait:** For short bounded waits inside the current turn, a foreground command such as `sleep ... && ` is fine. For longer waits or autonomous follow-up, prefer `ScheduleWakeup`, `CronCreate`, or `/loop` and include a concise reason/prompt so ADE can show the pending work clearly.",
           "",
           "## ADE Workspace",
           `ADE launched this session in lane worktree: ${managed.laneWorktreePath}.`,
diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx
index f98b5dc48..bcaa9fb8a 100644
--- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx
+++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx
@@ -517,6 +517,36 @@ describe("ChatSubagentsPanel (pane variant)", () => {
     expect(onCancelScheduledWork).toHaveBeenCalledWith(item);
   });
 
+  it("renders and cancels an action-created providerless schedule", () => {
+    const onCancelScheduledWork = vi.fn();
+    const item = scheduledSnapshot({
+      id: "action:session-1:job-1",
+      kind: "cron",
+      origin: "action",
+      title: "Check CI",
+      prompt: "Inspect the latest CI run",
+      reason: "Keep the PR moving",
+      cron: "*/20 * * * *",
+      durable: true,
+      cancellable: true,
+    });
+    render(
+      ,
+    );
+
+    expect(screen.getByText("Check CI")).toBeTruthy();
+    expect(screen.getByText("cron")).toBeTruthy();
+    expect(screen.getByText("*/20 * * * * · Keep the PR moving")).toBeTruthy();
+    fireEvent.click(screen.getByRole("button", { name: "Cancel Check CI" }));
+    expect(onCancelScheduledWork).toHaveBeenCalledWith(item);
+  });
+
   it("does not offer ADE cancellation for a provider-only schedule", () => {
     render(
        {
   it("lists and cancels an active durable job", async () => {
     installAdeMocks();
     (window as any).ade.agentChat.listScheduledWork.mockResolvedValueOnce([{
-      id: "wake-1",
+      id: "action:chat-12345678:job-1",
       sessionId: "chat-12345678",
-      kind: "wakeup",
+      kind: "cron",
       status: "scheduled",
       title: "Check PR CI",
       prompt: "Check PR CI",
+      cron: "*/20 * * * *",
       createdAt: "2026-07-14T00:00:00.000Z",
       durable: true,
       cancellable: true,
@@ -197,7 +198,7 @@ describe("AiFeaturesSection", () => {
     await waitFor(() => {
       expect((window as any).ade.agentChat.cancelScheduledWork).toHaveBeenCalledWith({
         sessionId: "chat-12345678",
-        scheduleId: "wake-1",
+        scheduleId: "action:chat-12345678:job-1",
       });
     });
   });
diff --git a/apps/desktop/src/shared/adeCliGuidance.test.ts b/apps/desktop/src/shared/adeCliGuidance.test.ts
index d62a1d56b..8b22b738b 100644
--- a/apps/desktop/src/shared/adeCliGuidance.test.ts
+++ b/apps/desktop/src/shared/adeCliGuidance.test.ts
@@ -29,6 +29,8 @@ describe("ADE bootstrap guidance", () => {
     // The fallback: CLI help is ground truth (agents are not trained on `ade`).
     expect(bootstrap).toContain("ade help ");
     expect(bootstrap).toContain("ade actions list --text");
+    expect(bootstrap).toContain("ade chat scheduled-work create");
+    expect(bootstrap).toContain("requires a bound chat");
     // The skill index is still advertised so the model knows what exists.
     for (const skillName of adeBundledAgentSkills) {
       expect(bootstrap).toContain(`\`${skillName}\``);
diff --git a/apps/desktop/src/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts
index f37af9dc8..a3fea190e 100644
--- a/apps/desktop/src/shared/adeCliGuidance.ts
+++ b/apps/desktop/src/shared/adeCliGuidance.ts
@@ -50,6 +50,7 @@ export function buildAdeBootstrapGuidance(
     formatAdeAgentSkillRootsForPrompt(skillRoots),
     "If the direct `mcp__computer_use` tools are present, use them for Codex Computer Use and honor their per-app approvals; do not initialize `@oai/sky` through `node_repl` as a substitute.",
     "Ground truth for any `ade` invocation is `ade help ` and `ade actions list --text`; prefer typed commands with `--text`. Project secrets are available through `ade secrets`; read only the named secret the user asks you to use and avoid printing secret values. Track and clean up processes you start.",
+    "Chats only: schedule via `ade chat scheduled-work create` / `chat.createScheduledWork`; requires a bound chat.",
   ].join("\n");
 }
 
diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift
index 82fb8210b..b75e08e4f 100644
--- a/apps/ios/ADE/Models/RemoteModels.swift
+++ b/apps/ios/ADE/Models/RemoteModels.swift
@@ -748,6 +748,12 @@ struct AgentChatCancelScheduledWorkResult: Codable, Equatable {
   var providerCancellationConfirmed: Bool
 }
 
+struct AgentChatSetScheduledWorkPausedResult: Codable, Equatable {
+  var sessionId: String
+  var paused: Bool
+  var nextWakeAt: String?
+}
+
 struct AgentChatSessionSummary: Codable, Identifiable, Equatable {
   var id: String { sessionId }
   var sessionId: String
@@ -796,6 +802,10 @@ struct AgentChatSessionSummary: Codable, Identifiable, Equatable {
   var pendingInputItemId: String? = nil
   /// Durable scheduled work managed by the paired ADE host. Older hosts omit it.
   var scheduledWork: [AgentChatScheduledWorkItem]? = nil
+  /// True when this chat's durable schedules are paused. Older hosts omit it.
+  var scheduledWorkPaused: Bool? = nil
+  /// Earliest armed, unpaused wake reported by the host. Older hosts omit it.
+  var nextWakeAt: String? = nil
   var threadId: String?
   var requestedCwd: String?
   // Orchestration-mode fields (populated when session is part of an orchestration run)
@@ -849,6 +859,8 @@ struct AgentChatSessionSummary: Codable, Identifiable, Equatable {
       && lhs.awaitingInput == rhs.awaitingInput
       && lhs.pendingInputItemId == rhs.pendingInputItemId
       && lhs.scheduledWork == rhs.scheduledWork
+      && lhs.scheduledWorkPaused == rhs.scheduledWorkPaused
+      && lhs.nextWakeAt == rhs.nextWakeAt
       && lhs.threadId == rhs.threadId
       && lhs.requestedCwd == rhs.requestedCwd
       && lhs.orchestrationRunId == rhs.orchestrationRunId
diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift
index ae8ec4739..3344d77b3 100644
--- a/apps/ios/ADE/Services/SyncService.swift
+++ b/apps/ios/ADE/Services/SyncService.swift
@@ -7663,6 +7663,19 @@ final class SyncService: ObservableObject {
     )
   }
 
+  func setScheduledWorkPaused(sessionId: String, paused: Bool) async throws -> AgentChatSetScheduledWorkPausedResult {
+    let scope = chatCommandScope(for: sessionId)
+    let action = chatActionName("chat.setScheduledWorkPaused", sessionId: sessionId)
+    try requireInvokableRemoteAction(action)
+    return try await sendDecodableCommand(
+      action: action,
+      args: ["sessionId": sessionId, "paused": paused],
+      targetProjectId: scope.projectId,
+      targetProjectRootPath: scope.rootPath,
+      as: AgentChatSetScheduledWorkPausedResult.self
+    )
+  }
+
   func fetchChatEventHistorySnapshot(sessionId: String, maxEvents: Int = chatEventHistoryMaxEvents) async throws -> AgentChatEventHistorySnapshot {
     let scope = chatCommandScope(for: sessionId)
     return try await sendDecodableCommand(
diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift
index ac442b36d..08940f1e9 100644
--- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift
+++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift
@@ -2516,15 +2516,19 @@ struct WorkChatInfoDetailsSheet: View {
   let sessionId: String
   let subagentSnapshots: [WorkSubagentSnapshot]
   let scheduledWorkSnapshots: [WorkScheduledWorkSnapshot]
+  let scheduledWorkPaused: Bool
+  let nextWakeAt: String?
   let provider: String?
   let selectedTaskId: String?
   let probingTaskId: String?
   @Binding var expandedTaskIds: Set
   let onSelect: @MainActor (WorkSubagentSnapshot) async -> Void
   let onCancelScheduledWork: (@MainActor (WorkScheduledWorkSnapshot) async -> Void)?
+  let onSetScheduledWorkPaused: (@MainActor (Bool) async -> Void)?
   @AppStorage private var paneUiRaw: String
   @AppStorage private var paneClearedRaw: String
   @State private var showAllSections: Set = []
+  @State private var schedulePauseMutationInFlight = false
   @Environment(\.accessibilityReduceMotion) private var reduceMotion
 
   // Mirrors SUBAGENTS_ACTIVE_CAP / BACKGROUND_ACTIVE_CAP / SCHEDULE_ACTIVE_CAP,
@@ -2539,22 +2543,28 @@ struct WorkChatInfoDetailsSheet: View {
     sessionId: String,
     subagentSnapshots: [WorkSubagentSnapshot],
     scheduledWorkSnapshots: [WorkScheduledWorkSnapshot],
+    scheduledWorkPaused: Bool,
+    nextWakeAt: String?,
     provider: String?,
     selectedTaskId: String?,
     probingTaskId: String?,
     expandedTaskIds: Binding>,
     onSelect: @escaping @MainActor (WorkSubagentSnapshot) async -> Void,
-    onCancelScheduledWork: (@MainActor (WorkScheduledWorkSnapshot) async -> Void)? = nil
+    onCancelScheduledWork: (@MainActor (WorkScheduledWorkSnapshot) async -> Void)? = nil,
+    onSetScheduledWorkPaused: (@MainActor (Bool) async -> Void)? = nil
   ) {
     self.sessionId = sessionId
     self.subagentSnapshots = subagentSnapshots
     self.scheduledWorkSnapshots = scheduledWorkSnapshots
+    self.scheduledWorkPaused = scheduledWorkPaused
+    self.nextWakeAt = nextWakeAt
     self.provider = provider
     self.selectedTaskId = selectedTaskId
     self.probingTaskId = probingTaskId
     self._expandedTaskIds = expandedTaskIds
     self.onSelect = onSelect
     self.onCancelScheduledWork = onCancelScheduledWork
+    self.onSetScheduledWorkPaused = onSetScheduledWorkPaused
     self._paneUiRaw = AppStorage(
       wrappedValue: #"{"collapsed":{},"earlier":{}}"#,
       "ade.chat.paneUi.v1:\(sessionId)"
@@ -2585,6 +2595,51 @@ struct WorkChatInfoDetailsSheet: View {
     subagents.isEmpty && backgroundItems.isEmpty && scheduleItems.isEmpty
   }
 
+  private var nextWakeLabel: String? {
+    guard !scheduledWorkPaused,
+          scheduleItems.contains(where: { workScheduledWorkIsActive($0) && !workScheduledWorkIsPaused($0.status) }),
+          let nextWakeAt = workScheduledWorkText(nextWakeAt)
+    else { return nil }
+    return "Next wake · \(relativeTimestamp(nextWakeAt))"
+  }
+
+  private var scheduleHeaderAccessory: AnyView? {
+    guard scheduledWorkPaused || onSetScheduledWorkPaused != nil else { return nil }
+    return AnyView(
+      HStack(spacing: 6) {
+        if scheduledWorkPaused {
+          Text("Paused")
+            .font(.caption2.weight(.semibold))
+            .foregroundStyle(ADEColor.textMuted)
+        }
+        if let onSetScheduledWorkPaused {
+          Button {
+            guard !schedulePauseMutationInFlight else { return }
+            schedulePauseMutationInFlight = true
+            Task { @MainActor in
+              await onSetScheduledWorkPaused(!scheduledWorkPaused)
+              schedulePauseMutationInFlight = false
+            }
+          } label: {
+            Group {
+              if schedulePauseMutationInFlight {
+                ProgressView().controlSize(.mini)
+              } else {
+                Image(systemName: scheduledWorkPaused ? "play.fill" : "pause.fill")
+                  .font(.caption2.weight(.bold))
+              }
+            }
+            .frame(width: 32, height: 32)
+          }
+          .buttonStyle(.plain)
+          .foregroundStyle(ADEColor.textMuted)
+          .disabled(schedulePauseMutationInFlight)
+          .accessibilityLabel(scheduledWorkPaused ? "Resume scheduled work for this chat" : "Pause scheduled work for this chat")
+        }
+      }
+    )
+  }
+
   private func jsonObject(_ raw: String) -> [String: Any] {
     guard let data = raw.data(using: .utf8),
           let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
@@ -2775,18 +2830,27 @@ struct WorkChatInfoDetailsSheet: View {
                 collapsible: !schedulePartition.earlier.isEmpty || schedulePartition.active.count > scheduleCap,
                 clearIds: schedulePartition.earlier.map(\.id),
                 clearedCount: schedulePartition.clearedCount,
-                allClear: schedulePartition.active.isEmpty && schedulePartition.earlier.isEmpty && schedulePartition.clearedCount > 0
+                allClear: schedulePartition.active.isEmpty && schedulePartition.earlier.isEmpty && schedulePartition.clearedCount > 0,
+                headerAccessory: scheduleHeaderAccessory
               ) {
-                scalableSectionBody(title: "Schedule", sectionKey: "schedule", spacing: 8, partition: schedulePartition, visible: visibleSchedule) { item, isEarlier in
-                  if isEarlier && workScheduleItemIsFiredOneShotWakeup(item) {
-                    WorkScheduledWorkRow(item: item).opacity(0.55).allowsHitTesting(false)
-                  } else {
-                    WorkScheduledWorkRow(
-                      item: item,
-                      onCancel: onCancelScheduledWork.map { cancel in
-                        { await cancel(item) }
-                      }
-                    )
+                VStack(alignment: .leading, spacing: 8) {
+                  if let nextWakeLabel {
+                    Label(nextWakeLabel, systemImage: "alarm")
+                      .font(.caption2)
+                      .foregroundStyle(ADEColor.textMuted)
+                  }
+                  scalableSectionBody(title: "Schedule", sectionKey: "schedule", spacing: 8, partition: schedulePartition, visible: visibleSchedule) { item, isEarlier in
+                    if isEarlier && workScheduleItemIsFiredOneShotWakeup(item) {
+                      WorkScheduledWorkRow(item: item).opacity(0.55).allowsHitTesting(false)
+                    } else {
+                      WorkScheduledWorkRow(
+                        item: item,
+                        schedulesPaused: scheduledWorkPaused && !isEarlier,
+                        onCancel: onCancelScheduledWork.map { cancel in
+                          { await cancel(item) }
+                        }
+                      )
+                    }
                   }
                 }
               }
@@ -2841,6 +2905,7 @@ struct WorkChatInfoDetailsSheet: View {
     clearIds: [String],
     clearedCount: Int,
     allClear: Bool,
+    headerAccessory: AnyView? = nil,
     @ViewBuilder content: () -> Content
   ) -> some View {
     let collapsed = collapsible && paneFlag("collapsed", section: key)
@@ -2883,6 +2948,9 @@ struct WorkChatInfoDetailsSheet: View {
             .font(.caption)
             .foregroundStyle(ADEColor.textMuted)
         }
+        if let headerAccessory {
+          headerAccessory
+        }
       }
       if !collapsed { content() }
     }
@@ -3133,6 +3201,7 @@ private struct WorkBackgroundWorkRow: View {
 
 private struct WorkScheduledWorkRow: View {
   let item: WorkScheduledWorkSnapshot
+  var schedulesPaused = false
   var onCancel: (@MainActor () async -> Void)? = nil
   @State private var cancellationInFlight = false
 
@@ -3231,7 +3300,7 @@ private struct WorkScheduledWorkRow: View {
         .stroke(tint.opacity(0.18), lineWidth: 0.8)
     )
     // Paused schedules read as inactive (matches desktop's dimmed row).
-    .opacity(workScheduledWorkIsPaused(status) ? 0.55 : 1)
+    .opacity(schedulesPaused || workScheduledWorkIsPaused(status) ? 0.55 : 1)
   }
 }
 
diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift
index bdbbd3dc3..d377410c9 100644
--- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift
+++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift
@@ -809,6 +809,43 @@ struct WorkSessionDestinationView: View {
     }
   }
 
+  private var scheduledWorkPauseAction: (@MainActor (Bool) async -> Void)? {
+    guard syncService.canInvokeChatRemoteAction("chat.setScheduledWorkPaused", sessionId: sessionId) else {
+      return nil
+    }
+    return { paused in
+      await setScheduledWorkPausedOptimistically(paused)
+    }
+  }
+
+  @MainActor
+  private func setScheduledWorkPausedOptimistically(_ paused: Bool) async {
+    let previousSummary = composerChatSummary
+    if var optimisticSummary = previousSummary {
+      optimisticSummary.scheduledWorkPaused = paused
+      chatSummary = optimisticSummary
+      lastKnownChatSummary = optimisticSummary
+    }
+
+    do {
+      let result = try await syncService.setScheduledWorkPaused(sessionId: sessionId, paused: paused)
+      if var confirmedSummary = composerChatSummary {
+        confirmedSummary.scheduledWorkPaused = result.paused
+        confirmedSummary.nextWakeAt = result.nextWakeAt
+        chatSummary = confirmedSummary
+        lastKnownChatSummary = confirmedSummary
+      }
+      await refreshChatSummaryFromHost()
+      refreshScheduledWorkSnapshots()
+    } catch {
+      if let previousSummary {
+        chatSummary = previousSummary
+        lastKnownChatSummary = previousSummary
+      }
+      errorMessage = error.localizedDescription
+    }
+  }
+
   var body: some View {
     sessionDestinationRoot
       .workSessionNavigationChrome(
@@ -826,12 +863,15 @@ struct WorkSessionDestinationView: View {
           sessionId: sessionId,
           subagentSnapshots: subagentSnapshots,
           scheduledWorkSnapshots: scheduledWorkSnapshots,
+          scheduledWorkPaused: composerChatSummary?.scheduledWorkPaused == true,
+          nextWakeAt: composerChatSummary?.nextWakeAt,
           provider: subagentProvider,
           selectedTaskId: subagentView?.taskId,
           probingTaskId: probingSubagentTaskId,
           expandedTaskIds: $expandedSubagentDetailIds,
           onSelect: handleSubagentSelection,
-          onCancelScheduledWork: scheduledWorkCancelAction
+          onCancelScheduledWork: scheduledWorkCancelAction,
+          onSetScheduledWorkPaused: scheduledWorkPauseAction
         )
         .presentationDetents([.medium, .large])
         .presentationDragIndicator(.visible)
diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift
index 503f0c1ad..08b4f29eb 100644
--- a/apps/ios/ADETests/ADETests.swift
+++ b/apps/ios/ADETests/ADETests.swift
@@ -10915,8 +10915,8 @@ final class ADETests: XCTestCase {
 
   func testParseWorkChatTranscriptBuildsScheduledWorkSnapshots() {
     let raw = """
-    {"sessionId":"chat-1","timestamp":"2026-07-07T00:00:02.000Z","sequence":2,"event":{"type":"scheduled_work_update","id":"wakeup-1","kind":"wakeup","status":"running","origin":"schedule_wakeup","summary":"Wakeup fired","lastRunAt":"2026-07-07T00:05:00.000Z","turnId":"turn-2"}}
-    {"sessionId":"chat-1","timestamp":"2026-07-07T00:00:01.000Z","sequence":1,"event":{"type":"scheduled_work_update","id":"wakeup-1","kind":"wakeup","status":"scheduled","origin":"schedule_wakeup","title":"Wakeup scheduled","prompt":"Check CI","reason":"CI is still running","nextRunAt":"2026-07-07T00:05:00.000Z","recurring":false,"durable":true,"sourceToolUseId":"tool-1","turnId":"turn-1"}}
+    {"sessionId":"chat-1","timestamp":"2026-07-07T00:00:02.000Z","sequence":2,"event":{"type":"scheduled_work_update","id":"action:chat-1:job-1","kind":"wakeup","status":"running","origin":"action","summary":"Wakeup fired","lastRunAt":"2026-07-07T00:05:00.000Z","turnId":"turn-2"}}
+    {"sessionId":"chat-1","timestamp":"2026-07-07T00:00:01.000Z","sequence":1,"event":{"type":"scheduled_work_update","id":"action:chat-1:job-1","kind":"wakeup","status":"scheduled","origin":"action","title":"Wakeup scheduled","prompt":"Check CI","reason":"CI is still running","nextRunAt":"2026-07-07T00:05:00.000Z","recurring":false,"durable":true,"turnId":"turn-1"}}
     """
 
     let snapshot = buildWorkChatTimelineSnapshot(
@@ -10927,7 +10927,8 @@ final class ADETests: XCTestCase {
     )
 
     XCTAssertEqual(snapshot.scheduledWorkSnapshots.count, 1)
-    XCTAssertEqual(snapshot.scheduledWorkSnapshots.first?.id, "wakeup-1")
+    XCTAssertEqual(snapshot.scheduledWorkSnapshots.first?.id, "action:chat-1:job-1")
+    XCTAssertEqual(snapshot.scheduledWorkSnapshots.first?.origin, "action")
     XCTAssertEqual(snapshot.scheduledWorkSnapshots.first?.status, "running")
     XCTAssertEqual(snapshot.scheduledWorkSnapshots.first?.title, "Wakeup scheduled")
     XCTAssertEqual(snapshot.scheduledWorkSnapshots.first?.summary, "Wakeup fired")
@@ -11013,6 +11014,8 @@ final class ADETests: XCTestCase {
       "status":"idle",
       "startedAt":"2026-07-08T00:00:00.000Z",
       "lastActivityAt":"2026-07-08T00:00:03.000Z",
+      "scheduledWorkPaused":true,
+      "nextWakeAt":"2026-07-08T00:20:00.000Z",
       "scheduledWork":[{
         "id":"managed-1",
         "sessionId":"chat-1",
@@ -11050,6 +11053,8 @@ final class ADETests: XCTestCase {
     let byId = Dictionary(uniqueKeysWithValues: merged.map { ($0.id, $0) })
 
     XCTAssertEqual(summary.scheduledWork?.count, 1)
+    XCTAssertEqual(summary.scheduledWorkPaused, true)
+    XCTAssertEqual(summary.nextWakeAt, "2026-07-08T00:20:00.000Z")
     XCTAssertNil(byId["stale-1"])
     XCTAssertEqual(byId["managed-1"]?.status, "paused")
     XCTAssertEqual(byId["managed-1"]?.title, "Managed CI watcher")
@@ -11058,6 +11063,24 @@ final class ADETests: XCTestCase {
     XCTAssertNil(byId["provider-only"]?.cancellable)
   }
 
+  func testScheduledWorkSummaryFieldsRemainOptionalForOlderHosts() throws {
+    let legacyData = Data(#"""
+    {
+      "sessionId":"chat-legacy",
+      "laneId":"lane-1",
+      "provider":"claude",
+      "model":"claude-sonnet",
+      "status":"idle",
+      "startedAt":"2026-07-08T00:00:00.000Z",
+      "lastActivityAt":"2026-07-08T00:00:03.000Z"
+    }
+    """#.utf8)
+
+    let summary = try JSONDecoder().decode(AgentChatSessionSummary.self, from: legacyData)
+    XCTAssertNil(summary.scheduledWorkPaused)
+    XCTAssertNil(summary.nextWakeAt)
+  }
+
   func testWorkTimelineKeepsSubagentsOutOfMainActivityBundles() {
     // The two activity updates are consecutive so they cluster into one bundle;
     // the real subagent's spawn row is a hard timeline boundary that sits
diff --git a/chat/capabilities.mdx b/chat/capabilities.mdx
index 1d5052245..d908fcfe8 100644
--- a/chat/capabilities.mdx
+++ b/chat/capabilities.mdx
@@ -68,8 +68,8 @@ Some capabilities depend on the provider runtime behind the chat.
   
     Codex sessions can keep a visible goal that you set, update, complete, or clear from ADE without turning the goal into prompt text.
   
-  
-    Claude wake, cron, scheduled-work, and background-stream events render in the transcript and Chat Info surfaces so unattended work stays inspectable.
+  
+    Every chat runtime can schedule durable self-resume with `chat.createScheduledWork`; wake, cron, and background events stay inspectable in the transcript and Chat Info.
   
   
     Codex and Cursor runtime failures surface as structured recovery cards or errors instead of collapsing into silent transcript gaps.
@@ -79,6 +79,9 @@ Some capabilities depend on the provider runtime behind the chat.
   
 
 
+Scheduled chat work resumes one chat at a turn boundary. For event-triggered,
+rule-based workflows across ADE, use [automations](/tools/automations).
+
 ## See what an agent can reach
 
 Open the tools panel from the composer to see what a session has available and which actions need your approval before they run.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 6dfed310c..b4fca4ba2 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -141,7 +141,7 @@ Product positioning and workflows live in [`docs/PRD.md`](../docs/PRD.md). This
 
 **Action surface.** First-class command families cover lanes (including `ade lanes link-linear-issue` / `detach-linear-issue` for post-creation Linear issue linking, and `ade lanes create-from-linear` / `batch-create-from-linear` to spin up one or many issue lanes — optionally launching an agent chat with `--start-chat`), git, diffs, files, PRs, runs, shells, chats (including `ade chat create --prompt` for a persistent Work chat followed by an initial chat message, `ade chat send` / `message` / `steer` / `wait` for peer chat delivery and status polling, `ade chat read ` for recent transcript messages, `ade chat scheduled-work create --cron "" --prompt "" [--once]` for durable provider-neutral scheduling, `ade chat create --from-linear-issue `, `ade chat attach-linear-issue` / `detach-linear-issue` / `linear-issues` for session-scoped issue attachment, and `--parent ` / `--no-parent` to control child-chat lineage — a chat created via `ade chat create` / `ade new --mode chat` defaults its parent to `$ADE_CHAT_SESSION_ID` (the spawning agent's own chat, injected into every tracked agent shell) so it lists in the parent's subagents panel instead of becoming an orphan, and `--no-parent` opts out), agents, CTO, Linear (the write bridge an attached CLI agent uses: `ade linear attach` / `detach` / `issues` / `issue` / `comment` / `set-state` / `assign` / `label`, with `--this-session` resolving the issue id from `$ADE_LINEAR_ISSUE_IDS` so a launched agent needs no Linear token — see [features/linear-integration/README.md](./features/linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection)), tests, proof, settings, the iOS Simulator (`ade ios-sim` / `ade ios` / `ade simulator` — see [features/ios-simulator/README.md](./features/ios-simulator/README.md)), the Cursor Cloud bridge (`ade cursor cloud agents | runs | artifacts | repos | models | me` — talks directly to `@cursor/sdk` without going through the ADE runtime endpoint), the App Control bridge for Electron apps (`ade app-control` / `ade app` / `ade electron` — `launch`, `connect`, `stop`, `status`, `screenshot`, `snapshot`, `inspect`, `select`, `click`, `type`, `scroll`, `key`, `targets`, `attach`, `logs`, `terminal write`, `terminal signal` — see [features/computer-use/app-control.md](./features/computer-use/app-control.md)), the chat-scoped terminal (`ade terminal list` / `read` / `write` / `signal` / `active`), universal search (`ade search ""` over chats, terminals, PRs, commits, branches, lanes, files, and Linear — see [features/search/README.md](./features/search/README.md)), and a generic `ade actions run ` escape hatch for every registered ADE service action. The chat action surface includes `chat.createSession`, `chat.sendMessage` (low-level normal-turn send), `chat.messageSession` (normalized peer delivery: auto, queue, wake, interrupt-replace), `chat.readTranscript`, `chat.createScheduledWork`, and model-catalog actions; session-bound non-CTO callers are restricted to their own chat for `chat.sendMessage`, `chat.readTranscript`, and scheduled-work create/list/cancel, while `chat.messageSession` is the reviewed primitive for deliberately messaging another ADE chat through routing semantics. The action allow-list adds three domains for these surfaces: `app_control` (every public method on `AppControlService`), `terminal` (`list`, `read`, `write`, `signal`, `activeForChat` against `ptyService`), named iOS Simulator actions for launch, live view, inspection, input, and Preview Lab workflows, and `search` (`query`, `indexStatus`, and the CTO-only `rebuildIndex` against `searchService`; session-bound non-CTO callers get chat/terminal hits scoped to their own session).
 
-Scheduled work is part of that typed chat family: `ade chat scheduled-work create --cron "" --prompt "" [--once] [--reason ""] [--session ]` calls `chat.createScheduledWork`, while list/cancel call `chat.listScheduledWork` / `chat.cancelScheduledWork`. Create writes a durable provider-neutral row and delivers it through the same wake path for Claude, Codex, Cursor, Droid, and OpenCode. For a session-bound non-CTO caller, the daemon defaults an omitted target to the caller's own chat and denies cross-session or unbound-external scheduling. The list reads the KV-backed management snapshot rather than reconstructing ownership from transcript events; provider-owned cancellation is reported as requested vs confirmed instead of being hidden optimistically.
+Scheduled work is part of that typed chat family: `ade chat scheduled-work create --cron "" --prompt "" [--once] [--reason ""] [--session ]` calls `chat.createScheduledWork`, while list/cancel call `chat.listScheduledWork` / `chat.cancelScheduledWork`; `ade chat schedules  --pause|--resume` calls `chat.setScheduledWorkPaused`. Create writes a durable provider-neutral row and delivers it through the same wake path for Claude, Codex, Cursor, Droid, and OpenCode. For a session-bound non-CTO caller, the daemon defaults an omitted target to the caller's own chat and denies cross-session or unbound-external scheduling. The list reads the KV-backed management snapshot rather than reconstructing ownership from transcript events; provider-owned cancellation is reported as requested vs confirmed instead of being hidden optimistically.
 
 Personal chat is an explicit machine-only variant of the typed chat family: `ade chat list|create|show|read|send|interrupt|archive|unarchive|delete --personal`. It connects to the running brain, invokes `personalChats.call`, and rejects lane/Linear project flags rather than falling back to `--headless` project dispatch. ADE Code remains a project Work TUI and intentionally has no personal-chat UI in this release.
 
@@ -621,7 +621,7 @@ Most services described here live under `apps/desktop/src/main/services/
 | `appControl/` | `appControlService.ts`, `appControlLaunchCommand.ts` | Chrome DevTools Protocol bridge for developer-owned Electron apps. Launches a chat-owned PTY running the user's dev command (or connects to an existing `--remote-debugging-port`), polls `/json` for ready CDP targets, attaches a long-lived `CdpClient` WebSocket, and exposes screenshot / DOM snapshot / hit-test / click / type / scroll / key dispatch / screencast frames. `appControlLaunchCommand.ts` owns the shell-command detection and debug-flag injection helpers for direct Electron and package-script launches. `inspectPoint` and `selectPoint` produce `AppControlContextItem`s for the chat composer (DOM packet + screenshot + source-file candidates resolved by `findSourceMatches` over an indexed tree of project source files). See [features/computer-use/app-control.md](./features/computer-use/app-control.md). |
 | `builtInBrowser/` | `builtInBrowserService.ts`, `builtInBrowserAgentAccess.ts`, `builtInBrowserActorCapabilities.ts`, `builtInBrowserAuthentication.ts`, `builtInBrowserProfileMigration.ts`, `builtInBrowserStateStore.ts`, `builtInBrowserNavigation.ts`, `builtInBrowserPermissions.ts`, `builtInBrowserWebAuthn.ts`, `desktopBridgeServer.ts` | In-app web browser owned by the main process. Every remote-content `WebContentsView` uses the single persistent `persist:ade-browser` storage profile (`storageProfileKey: "global"`), while service keys combine the ADE window id with a project/window/personal tab-collection key so visible tabs stay independent. Project roots route project commands and scratch observations; validated personal commands retain the personal tab collection and use the channel-specific machine-local browser-observation scratch root. Neither route partitions cookies or site storage. On first use, a bounded, idempotent migration copies unexpired persistent cookies from this channel's legacy project-derived partitions into the global profile without overwriting global cookies or copying session cookies; it preserves the old partition directories because Chromium DOM storage, IndexedDB, service-worker state, and WebAuthn credentials cannot be safely merged across partitions. The bounded machine-local state store restores HTTP(S)/blank tab URLs and the active tab for each collection, but never restores agent leases, lightweight browser sessions, or synthetic session cookies. The service caps each collection at 10 tabs, routes global-session network events back to their owning collection, drives OAuth popups and downloads, and emits targeted events. HTTP/proxy authentication uses a sandboxed, local credential prompt and passes values directly to Chromium without persisting or logging them; client-certificate requests use an explicit native chooser and only accept a certificate Electron offered. Permission requests are deny-by-default, limited to managed browser web contents and secure origins, and use persisted per-origin/embedding-origin decisions with a native human prompt; only Google's `storage-access` and `top-level-storage-access` requests retain a narrow accounts-domain compatibility exception. The Browser toolbar's trusted-renderer Profile panel exposes non-secret cookie/cache/flush diagnostics and list/remove/clear controls for remembered permission decisions; these operations are not bridged to agents or unbound CLI callers. A separate non-persistent agent-access controller requires a per-chat/lane native human grant for every non-local origin and for local origins with allowed privileged permissions; cross-origin navigations and redirects are intercepted, and sensitive popups are blocked until explicitly approved. The grant follows the agent-owned tab without a timer and clears only when an explicit trusted-renderer navigation reclaims the tab. Tabs carry owner/lease metadata. ADE-launched chats receive opaque in-memory browser actor capabilities bound to their trusted chat/lane/project or personal collection. The runtime requires the token and strips caller routing; Electron validates it in the issuing process, restores only the bound scope, forces `force: false`, and separately authenticates the bridge with the desktop launch's rotating token. Agents cannot force or impersonate a takeover, read another agent's tab status, inspect global cookie-domain diagnostics, or administer permissions. Browser sessions bind one workflow to one tab. Project observations live under `.ade/cache/browser-observations/`; personal observations live under the channel user-data `browser-observations/personal/` root, which is narrowly allowlisted for proof promotion. The issuer-restored scope selects the matching independent tab collection. Navigation/protocol policy lives in `builtInBrowserNavigation.ts`; WebAuthn account selection lives in `builtInBrowserWebAuthn.ts`. |
 | `automations/` | `automationService.ts`, `automationPlannerService.ts`, `automationIngressService.ts`, `automationSecretService.ts` | Rule lifecycle, NL → rule planner, inbound triggers, per-rule secrets. |
-| `chat/` | `agentChatService.ts`, `chatScheduledWorkScheduler.ts`, `runtimeEvents.ts`, `claudeStructuredActivity.ts`, `openCodeStructuredActivity.ts`, `codexMcpElicitation.ts`, `buildClaudeV2Message.ts`, `markdownSlashCommandDiscovery.ts`, `claudeSlashCommandDiscovery.ts`, `codexSlashCommandDiscovery.ts`, `cursorSlashCommandDiscovery.ts`, `projectSlashCommandDiscovery.ts`, `slashCommandPromptExpansion.ts`, `cursorSdk*` (`cursorSdkPool.ts`, `cursorSdkWorker.ts`, `cursorSdkProtocol.ts`, `cursorSdkPolicy.ts`, `cursorSdkSystemPrompt.ts`, `cursorSdkEventMapper.ts`, `cursorSdkErrors.ts`), `droidSdkEventMapper.ts`, `sessionRecovery.ts` | Agent chat sessions (lane-scoped + orchestration worker/coordinator). Builds Claude messages, hosts the Cursor SDK in a Node worker pool with official local-store persistence, formalizes the cross-runtime event vocabulary, normalizes provider-native web/MCP/image activity into compact shared events, handles Codex app-server MCP elicitations and stalled-turn recovery, recovers sessions on restart, derives prompt-based lane names for parallel model launches, keeps Claude Agent SDK streams alive for scheduled wake/cron/background work after visible turns, emits transcript retractions for provider-superseded assistant rows, and manages Codex app-server goals with persisted, unlimited-budget session state. `chat.createScheduledWork` validates a five-field cron plus a bounded prompt and writes an ADE-owned recurring or one-shot row that works for every provider runtime. Claude remains authoritative for provider schedule tool success and canonical ids, while ADE's store is authoritative for delivery: successful `PostToolUse` mirrors `ScheduleWakeup`, every successful `CronCreate` (always `durable: true` regardless of Claude's ineffective flag), and `/loop` records in `kv`, and scopes Stop/SubagentStop reconciliation to the exact provider-session owner so a new session's empty snapshot preserves prior-owner rows. The SDK gets the native fire opportunity at `fireAt`; ADE's timer waits through a 90-second grace window, then backstops a skipped, busy, or dead provider without marking that designed delay late. Provider-neutral rows fire at their exact time through `messageSession(kind: "wake")`. The scheduler restores timers, coalesces missed occurrences to one late fire, applies chat/global pause state, queues busy Claude/OpenCode scheduled wakes as their own message and starts a new turn at the active turn boundary rather than steering mid-turn, cold-starts idle sessions when necessary, expires recurring crons after seven days, and emits lifecycle rows while summaries expose management state. Cancellation of Claude-owned jobs routes through `CronDelete` and remains visible until provider confirmation; ADE-owned rows cancel directly. There is no scheduled-work-specific spend cap. |
+| `chat/` | `agentChatService.ts`, `chatScheduledWorkScheduler.ts`, `runtimeEvents.ts`, `claudeStructuredActivity.ts`, `openCodeStructuredActivity.ts`, `codexMcpElicitation.ts`, `buildClaudeV2Message.ts`, `markdownSlashCommandDiscovery.ts`, `claudeSlashCommandDiscovery.ts`, `codexSlashCommandDiscovery.ts`, `cursorSlashCommandDiscovery.ts`, `projectSlashCommandDiscovery.ts`, `slashCommandPromptExpansion.ts`, `cursorSdk*` (`cursorSdkPool.ts`, `cursorSdkWorker.ts`, `cursorSdkProtocol.ts`, `cursorSdkPolicy.ts`, `cursorSdkSystemPrompt.ts`, `cursorSdkEventMapper.ts`, `cursorSdkErrors.ts`), `droidSdkEventMapper.ts`, `sessionRecovery.ts` | Agent chat sessions (lane-scoped + orchestration worker/coordinator). Builds Claude messages, hosts the Cursor SDK in a Node worker pool with official local-store persistence, formalizes the cross-runtime event vocabulary, normalizes provider-native web/MCP/image activity into compact shared events, handles Codex app-server MCP elicitations and stalled-turn recovery, recovers sessions on restart, derives prompt-based lane names for parallel model launches, keeps Claude Agent SDK streams alive for scheduled wake/cron/background work after visible turns, emits transcript retractions for provider-superseded assistant rows, and manages Codex app-server goals with persisted, unlimited-budget session state. `chat.createScheduledWork` validates a five-field cron plus a bounded prompt and writes an ADE-owned recurring or one-shot row that works for every provider runtime. Claude remains authoritative for provider schedule tool success and canonical ids, while ADE's store is authoritative for delivery: successful `PostToolUse` mirrors `ScheduleWakeup`, every successful `CronCreate` (always `durable: true` regardless of Claude's ineffective flag), and `/loop` records in `kv`, and scopes Stop/SubagentStop reconciliation to the exact provider-session owner so a new session's empty snapshot preserves prior-owner rows. The SDK's schedule view is advisory; ADE state wins. The SDK gets the native fire opportunity at `fireAt`; ADE's timer waits through a 90-second grace window, then backstops a skipped, busy, or dead provider without marking that designed delay late. Provider-neutral rows fire at their exact time through `messageSession(kind: "wake")`. The scheduler restores timers, coalesces missed occurrences to one late fire, applies chat/global pause state, queues busy scheduled wakes as their own message and starts a new turn at the active turn boundary rather than steering mid-turn, cold-starts idle sessions when necessary, expires recurring crons after seven days, and emits lifecycle rows while summaries expose management state. Cancellation of Claude-owned jobs routes through `CronDelete` and remains visible until provider confirmation; ADE-owned rows cancel directly. There is no scheduled-work-specific spend cap. |
 | `computerUse/` | `computerUseArtifactBrokerService.ts`, `controlPlane.ts`, `localComputerUse.ts`, `syntheticToolResult.ts` | Proof-artifact broker (ingests, owner links, review state, routing), control-plane snapshot helpers, macOS capture capability descriptor, and the synthetic-tool-result helper used by the Claude compaction path. `proofObserver.ts` was removed in the rebuild — there is no passive auto-ingest. Direct Codex Computer Use executable resolution lives outside this folder in `main/utils/codexComputerUse.ts` because it configures provider runtimes rather than ingesting proof. |
 | `proof/` | `agentBrowserArtifactAdapter.ts` | Parses agent-browser payloads into broker inputs. |
 | `config/` | `projectConfigService.ts`, `laneOverlayMatcher.ts` | Load/save `.ade/ade.yaml` + `local.yaml`; trust enforcement; lane overlays. |
diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md
index 49362b26a..9b4befe2f 100644
--- a/docs/features/chat/README.md
+++ b/docs/features/chat/README.md
@@ -152,7 +152,8 @@ schedules use the same seven-day expiry as recurring Claude cron rows.
 Claude SDK chats can arm `ScheduleWakeup` one-shots, `CronCreate` jobs, and
 `/loop` self-pacing work. Claude remains the authority for whether the provider
 tool succeeded and for its canonical job id, but ADE's durable mirror is the
-source of truth for delivery. ADE records a job only after the SDK's successful
+source of truth for delivery: the SDK's `CronList` view is advisory and ADE
+state wins. ADE records a job only after the SDK's successful
 `PostToolUse` hook returns its canonical id, clamped fire time, and recurrence.
 A failed tool call or a tool-use intent never creates an ADE schedule. Every
 successful `CronCreate` enters ADE's management store with `durable: true`,
@@ -228,8 +229,8 @@ is cancelled, while durable provider-owned work is quarantined as paused.
 Delivery reuses the session peer-message path with `kind: "wake"`. A live,
 idle Claude query is resumed through its existing idle reader; otherwise the
 service resumes/cold-starts the session and sends a synthetic turn carrying
-`metadata.scheduledWake`. If a Claude or OpenCode turn is busy, the scheduled
-wake is queued as a new message and starts its own turn at the next boundary;
+`metadata.scheduledWake`. If a turn is busy, the scheduled wake is queued as a
+new message and starts its own turn at the next boundary;
 it is never steered into or dispatched during the running turn. One-shot
 lifecycle is `scheduled` -> `fired` -> `completed`; completed
 wakeups move into Chat Info history. Recurring cron rows briefly record the
@@ -260,7 +261,9 @@ Controls and summaries project this runtime state rather than owning it:
   `ade chat scheduled-work cancel  ` routes through the same
   provider-aware cancellation path. The equivalent generic actions are
   `chat.createScheduledWork`, `chat.listScheduledWork`, and
-  `chat.cancelScheduledWork`. In an ADE-launched agent, create/list/cancel
+  `chat.cancelScheduledWork`; pause/resume is
+  `ade chat schedules  --pause|--resume` or
+  `chat.setScheduledWorkPaused`. In an ADE-launched agent, create/list/cancel
   default an omitted `sessionId` to `ADE_CHAT_SESSION_ID`; the daemon rejects a
   bound agent that names another chat and rejects an external caller with no
   bound session.
diff --git a/docs/features/chat/transcript-and-turns.md b/docs/features/chat/transcript-and-turns.md
index 27b1e87ae..a64f0ff08 100644
--- a/docs/features/chat/transcript-and-turns.md
+++ b/docs/features/chat/transcript-and-turns.md
@@ -88,7 +88,7 @@ Two helpers summarise a parsed stream:
 | `activity` | Ephemeral UI hint (thinking, searching, running_command). Hidden from the transcript. |
 | `todo_update` | Task-list snapshot; consumed by `ChatTasksPanel`. |
 | `subagent_started` / `subagent_progress` / `subagent_result` | Legacy Claude background subagent lifecycle. Each envelope carries `taskId`, `parentToolUseId`, `description`, and optional `agentId`, `parentAgentId`, `agentType`, and `providerSessionId`; Claude start rows bind the native child to the owning Claude session so transcript drill-in never mistakes the ADE chat id for a provider session id. For Claude / ade-code `agentType` is the Task tool's `subagent_type` (stashed at the `tool_use` boundary and joined on `parentToolUseId`); for Codex parallel agents it is a per-turn `Agent #N` label assigned at first announcement and the raw threadId is mirrored as `agentId`; for OpenCode subagents `agentType` is omitted so the row falls back to the `description` (taken from `session.title`). Codex app-server `subAgentActivity` items also flow into these rows and may carry `label`, `model`, and `reasoningEffort` for richer roster labels. Claude SDK runs also stash `taskType` (`subagent` / `background` / `local_workflow` / `cron` / `other`) and `workflowName` at spawn so the renderer can label rows by workflow without re-deriving them per event; ambient/housekeeping tasks (the SDK's `skip_transcript=true` flag — e.g. session-title generation) and plain Claude Code task runs (`task_type` `other` with no agent metadata, e.g. "Re-run affected test files") are both tracked only for cleanup and filtered out symmetrically across spawn, progress, and completion notifications so the subagent panel never flashes them, while a backgrounded `Bash` shell (`task_type` `local_bash`/`background`) is routed to the background pane rather than the roster. Every `subagent_result` is gated on a recorded `subagent_started` (`emittedSubagentStartIds`), so an interrupt cannot emit a phantom stopped card for a subagent that never announced; terminal events clear both the taskId and agentId aliases. The service also emits canonical `subagent.started` / `subagent.progress` / `subagent.completed` rows from `runtimeEvents.ts` so all runtimes can converge on the same envelope. Two additional producers fan into the same three event types: **Claude Workflow runs** — the SDK's undocumented `workflow_progress` snapshot on `system:task_progress` is normalized by `claudeWorkflowProgress.ts` (defensive: malformed entries dropped, previews clipped, counts capped, unknown states degrade to queued/running; an unparseable snapshot leaves the generic task rendering untouched) and diffed per tick into started/progress/result transitions under a stable `::a` / latched-agentId identity, so each workflow agent renders as its own row with phase, tokens, and duration, reconnects upsert instead of duplicating, and agents left running when the workflow ends are closed out as `stopped`; and **child chat spawns** — a session created with `orchestrationParentSessionId` outside an orchestration run (e.g. `ade chat create` from a tracked agent shell) emits synthetic `subagent_started`/`subagent_result` events keyed `chat:` into the parent so the child lists in the parent's subagents panel, its first finished turn reporting completed/failed/stopped. |
-| `scheduled_work_update` | Scheduled/background-work lifecycle snapshot. Claude emits it from `ScheduleWakeup`, `CronCreate`, `CronDelete`, `/loop`/hook snapshots, remote triggers, cron/background task lifecycle messages, and durable scheduler transitions. It carries `kind` (`wakeup`, `cron`, `loop`, `remote_trigger`, `background_task`), `status` (`scheduled`, `paused`, `running`, `fired`, `missed`, `completed`, `cancelled`, `failed`, `stopped`), provenance ids, optional cron/prompt/reason/timestamps, `firedAt`, `late`, and `durable`; `shared/chatScheduledWork.ts` folds it into active/history Chat Info rows on desktop, ADE Code, and iOS. Parent turn completion does not imply background completion, and `background_task` snapshots whose `sourceTaskId` belongs to a real subagent are omitted from the Background roster to avoid duplicate Agent rows. One-shots progress through `scheduled` -> `fired` -> `completed`; crons record the fire and return to `scheduled` with `lastRunAt` plus their next occurrence. |
+| `scheduled_work_update` | Scheduled/background-work lifecycle snapshot. ADE emits it for provider-neutral action schedules and Claude `ScheduleWakeup`, `CronCreate`, `CronDelete`, `/loop`/hook snapshots, remote triggers, cron/background task lifecycle messages, and durable scheduler transitions. It carries `kind` (`wakeup`, `cron`, `loop`, `remote_trigger`, `background_task`), `status` (`scheduled`, `paused`, `running`, `fired`, `missed`, `completed`, `cancelled`, `failed`, `stopped`), provenance ids, optional cron/prompt/reason/timestamps, `firedAt`, `late`, and `durable`; `shared/chatScheduledWork.ts` folds it into active/history Chat Info rows on desktop, ADE Code, and iOS. Parent turn completion does not imply background completion, and `background_task` snapshots whose `sourceTaskId` belongs to a real subagent are omitted from the Background roster to avoid duplicate Agent rows. One-shots progress through `scheduled` -> `fired` -> `completed`; crons record the fire and return to `scheduled` with `lastRunAt` plus their next occurrence. |
 | `tool_use_start` / `tool_use_complete` / `tool_use_summary` | Claude SDK tool lifecycle tracking (see [Claude tool-use tracking](#claude-tool-use-tracking)). |
 | `step_boundary` | Workflow step boundary marker. |
 | `system_notice` | Non-transcript chrome: auth errors, rate limits, and file persistence hints. Special-cased renders: the "Promoted to Cursor Cloud" pill, and the `status:"subagent_spawned"` chip (emitted into the parent when a child chat session is created with a parent lineage; `detail.spawnedSession` carries the child sessionId/laneId/title and the chip deep-links via `ade:work:select-session`; the TUI shows the message line; iOS renders it through its existing system_notice mapping). |

From b12cb82dbd58c5100a448ffffbfcde22e9319e3a Mon Sep 17 00:00:00 2001
From: Arul Sharma <31745423+arul28@users.noreply.github.com>
Date: Thu, 16 Jul 2026 23:34:55 -0400
Subject: [PATCH 4/5] feat(sched): make durable scheduling cross-runtime

---
 apps/ade-cli/README.md                        |   4 +-
 apps/ade-cli/src/adeRpcServer.test.ts         |  73 +++
 apps/ade-cli/src/adeRpcServer.ts              |   2 +
 apps/ade-cli/src/cli.test.ts                  |  11 +-
 apps/ade-cli/src/cli.ts                       |  14 +-
 .../personalChats/personalChatScope.test.ts   |  47 ++
 .../personalChats/personalChatScope.ts        |  20 +
 .../sync/syncRemoteCommandService.test.ts     |  51 +-
 .../services/sync/syncRemoteCommandService.ts |  20 +-
 .../src/tuiClient/__tests__/Drawer.test.tsx   |  17 +
 apps/ade-cli/src/tuiClient/adeApi.ts          |  10 +
 apps/ade-cli/src/tuiClient/app.tsx            |  39 +-
 .../src/tuiClient/closedCliSessions.ts        |  18 +-
 .../ade-cli-control-plane/SKILL.md            |  29 +-
 .../src/main/services/adeActions/registry.ts  |  16 +-
 .../services/ai/tools/systemPrompt.test.ts    |   8 +-
 .../main/services/ai/tools/systemPrompt.ts    |   4 +-
 .../services/chat/agentChatService.test.ts    | 606 +++++++++++++++++-
 .../main/services/chat/agentChatService.ts    | 367 +++++++----
 .../chat/chatScheduledWorkScheduler.test.ts   | 277 +++++++-
 .../chat/chatScheduledWorkScheduler.ts        |  64 +-
 .../src/main/services/pty/ptyService.test.ts  |  43 +-
 .../src/main/services/pty/ptyService.ts       | 106 +--
 .../components/settings/AiFeaturesSection.tsx |   2 +-
 .../adapter/__tests__/adapter.test.ts         |  58 ++
 .../renderer/webclient/adapter/agentChat.ts   |  25 +-
 .../desktop/src/shared/adeCliGuidance.test.ts |   2 +-
 apps/desktop/src/shared/adeCliGuidance.ts     |   2 +-
 apps/desktop/src/shared/types/chat.ts         |  11 +
 .../desktop/src/shared/types/personalChats.ts |  16 +
 apps/desktop/src/shared/types/sessions.ts     |  33 +
 apps/desktop/src/shared/types/sync.ts         |   1 +
 apps/ios/ADE/Services/SyncService.swift       |   8 +
 .../Views/Work/WorkChatRichCardViews.swift    |   2 +-
 apps/ios/ADETests/ADETests.swift              |  41 ++
 docs/ARCHITECTURE.md                          |  12 +-
 docs/features/ade-code/README.md              |   6 +-
 docs/features/chat/README.md                  | 102 ++-
 docs/features/personal-chats/README.md        |  22 +-
 .../sync-and-multi-device/ios-companion.md    |  35 +-
 .../sync-and-multi-device/remote-commands.md  |  10 +-
 .../features/terminals-and-sessions/README.md |   6 +-
 .../pty-and-processes.md                      |  10 +
 docs/features/web-client/README.md            |   5 +-
 44 files changed, 1910 insertions(+), 345 deletions(-)

diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md
index 5f7b068de..36e487017 100644
--- a/apps/ade-cli/README.md
+++ b/apps/ade-cli/README.md
@@ -389,9 +389,9 @@ ade chat create --lane lane-id --provider codex --no-parent   # spawned chats de
 ade chat read session-id --limit 20 --text
 ade chat message session-id --kind auto --text "status/context"
 ade chat steer session-id --text "active-turn context"
-ade chat schedules session-id --pause              # pause this chat's durable wakeups/cron/loops (omit flag to inspect, --resume to re-arm)
+ade chat schedules session-id --pause              # pause this agent session's durable wakeups/cron/loops (omit flag to inspect, --resume to re-arm)
 ade chat scheduled-work list [session-id] --all     # list durable jobs; --all includes recent terminal history
-ade chat scheduled-work create --cron "9,29,49 * * * *" --prompt "Check CI and report" --once --reason "CI check" --session session-id
+ade chat scheduled-work create --cron "9,29,49 * * * *" --prompt "Check CI and report" --once --reason "CI check" --session session-id  # chats and tracked provider CLIs; omit --session inside the bound agent
 ade chat scheduled-work cancel session-id job-id    # cancel one job; Claude-native jobs request CronDelete in the owning chat
 ade chat wait session-id --for idle --timeout-ms 600000
 ade chat recover session-id --turn turn-id --action nudge        # wait | nudge | retry | resume
diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts
index 90e1c0d30..14ca2a6d4 100644
--- a/apps/ade-cli/src/adeRpcServer.test.ts
+++ b/apps/ade-cli/src/adeRpcServer.test.ts
@@ -513,6 +513,12 @@ function createRuntime() {
         kind: "cron",
         status: includeTerminal ? "cancelled" : "scheduled",
       }]),
+      getScheduledWorkState: vi.fn(async ({ sessionId }: { sessionId: string }) => ({
+        sessionId,
+        paused: false,
+        nextWakeAt: "2026-03-17T20:00:00.000Z",
+        items: [],
+      })),
       cancelScheduledWork: vi.fn(async ({ sessionId, scheduleId }: {
         sessionId: string;
         scheduleId: string;
@@ -521,6 +527,10 @@ function createRuntime() {
         providerCancellationRequested: false,
         providerCancellationConfirmed: true,
       })),
+      setScheduledWorkPaused: vi.fn(async ({ sessionId, paused }: {
+        sessionId: string;
+        paused: boolean;
+      }) => ({ sessionId, paused, nextWakeAt: null })),
       getChatTranscript: vi.fn(async ({ sessionId }: { sessionId: string }) => ({
         sessionId,
         entries: [{ role: "assistant", text: "hello", timestamp: "2026-03-17T19:00:00.000Z" }],
@@ -2826,6 +2836,16 @@ describe("adeRpcServer", () => {
     expect(allScheduledWork?.isError).toBeUndefined();
     expect(fixture.runtime.agentChatService.listScheduledWork).toHaveBeenCalledWith({});
 
+    const scheduledWorkState = await callTool(handler, "run_ade_action", {
+      domain: "chat",
+      action: "getScheduledWorkState",
+      args: { sessionId: " chat-1 " },
+    });
+    expect(scheduledWorkState?.isError).toBeUndefined();
+    expect(fixture.runtime.agentChatService.getScheduledWorkState).toHaveBeenCalledWith({
+      sessionId: "chat-1",
+    });
+
     const cancelledWork = await callTool(handler, "run_ade_action", {
       domain: "chat",
       action: "cancelScheduledWork",
@@ -3229,6 +3249,43 @@ describe("adeRpcServer", () => {
     expect(deniedScheduledWorkCreate.isError).toBe(true);
     expect(fixture.runtime.agentChatService.createScheduledWork).toHaveBeenCalledTimes(1);
 
+    const ownScheduledWorkState = await callTool(handler, "run_ade_action", {
+      domain: "chat",
+      action: "getScheduledWorkState",
+      args: {},
+    });
+    expect(ownScheduledWorkState?.isError).toBeUndefined();
+    expect(fixture.runtime.agentChatService.getScheduledWorkState).toHaveBeenCalledWith({
+      sessionId: "chat-1",
+    });
+
+    const deniedScheduledWorkState = await callTool(handler, "run_ade_action", {
+      domain: "chat",
+      action: "getScheduledWorkState",
+      args: { sessionId: "chat-2" },
+    });
+    expect(deniedScheduledWorkState.isError).toBe(true);
+    expect(fixture.runtime.agentChatService.getScheduledWorkState).toHaveBeenCalledTimes(1);
+
+    const ownScheduledWorkPause = await callTool(handler, "run_ade_action", {
+      domain: "chat",
+      action: "setScheduledWorkPaused",
+      args: { paused: true },
+    });
+    expect(ownScheduledWorkPause?.isError).toBeUndefined();
+    expect(fixture.runtime.agentChatService.setScheduledWorkPaused).toHaveBeenCalledWith({
+      sessionId: "chat-1",
+      paused: true,
+    });
+
+    const deniedScheduledWorkPause = await callTool(handler, "run_ade_action", {
+      domain: "chat",
+      action: "setScheduledWorkPaused",
+      args: { sessionId: "chat-2", paused: true },
+    });
+    expect(deniedScheduledWorkPause.isError).toBe(true);
+    expect(fixture.runtime.agentChatService.setScheduledWorkPaused).toHaveBeenCalledTimes(1);
+
     const deniedScheduledWorkList = await callTool(handler, "run_ade_action", {
       domain: "chat",
       action: "listScheduledWork",
@@ -3868,6 +3925,22 @@ describe("adeRpcServer", () => {
     expect(scheduledWorkCreate.isError).toBe(true);
     expect(fixture.runtime.agentChatService.createScheduledWork).not.toHaveBeenCalled();
 
+    const scheduledWorkState = await callTool(handler, "run_ade_action", {
+      domain: "chat",
+      action: "getScheduledWorkState",
+      args: { sessionId: "chat-2" },
+    });
+    expect(scheduledWorkState.isError).toBe(true);
+    expect(fixture.runtime.agentChatService.getScheduledWorkState).not.toHaveBeenCalled();
+
+    const scheduledWorkPause = await callTool(handler, "run_ade_action", {
+      domain: "chat",
+      action: "setScheduledWorkPaused",
+      args: { sessionId: "chat-2", paused: true },
+    });
+    expect(scheduledWorkPause.isError).toBe(true);
+    expect(fixture.runtime.agentChatService.setScheduledWorkPaused).not.toHaveBeenCalled();
+
     const scheduledWorkCancel = await callTool(handler, "run_ade_action", {
       domain: "chat",
       action: "cancelScheduledWork",
diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts
index 5089ee74c..dcd468ff2 100644
--- a/apps/ade-cli/src/adeRpcServer.ts
+++ b/apps/ade-cli/src/adeRpcServer.ts
@@ -2387,7 +2387,9 @@ const SCOPED_CHAT_ACTIONS = new Set([
   "sendMessage",
   "createScheduledWork",
   "listScheduledWork",
+  "getScheduledWorkState",
   "cancelScheduledWork",
+  "setScheduledWorkPaused",
 ]);
 
 function scopeChatAdeActionArgs(
diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts
index e93cf90d8..9e89c2e48 100644
--- a/apps/ade-cli/src/cli.test.ts
+++ b/apps/ade-cli/src/cli.test.ts
@@ -4169,8 +4169,8 @@ describe("ADE CLI", () => {
     expect(inspect.steps[0]?.params).toMatchObject({
       arguments: {
         domain: "chat",
-        action: "getSessionSummary",
-        argsList: ["chat-1"],
+        action: "getScheduledWorkState",
+        args: { sessionId: "chat-1" },
       },
     });
 
@@ -4253,6 +4253,13 @@ describe("ADE CLI", () => {
       "--prompt",
       "Missing cron",
     ])).toThrow(/cron is required/);
+    expect(() => buildCliPlan([
+      "chat",
+      "scheduled-work",
+      "create",
+      "--cron",
+      "0 * * * *",
+    ])).toThrow(/prompt is required/);
 
     for (const alias of ["schedule", "schedules"]) {
       expect(expectExecutePlan(buildCliPlan(["chat", alias, "list", "chat-1"])).steps[0]?.params)
diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts
index 629bde341..32a447749 100644
--- a/apps/ade-cli/src/cli.ts
+++ b/apps/ade-cli/src/cli.ts
@@ -1641,7 +1641,7 @@ const HELP_BY_COMMAND: Record = {
     $ ade chat rewind-files  --message  --dry-run
                                                     Preview or apply file/context rewind
     $ ade chat subagents  --text           List child agents for a chat
-    $ ade chat schedules  --pause           Pause this chat's durable wakeups/cron/loops
+    $ ade chat schedules  --pause           Pause this agent session's durable wakeups/cron/loops
     $ ade chat schedules                    Inspect pause state + next armed wake (--resume to re-arm)
     $ ade chat scheduled-work list [session]         List durable jobs (--all includes recent history)
     $ ade chat scheduled-work create --cron "" --prompt "" [--once]
@@ -7341,9 +7341,8 @@ function buildChatPlan(args: string[]): CliPlan {
         ],
       };
     }
-    // Per-chat scheduled-work control: pause/resume this chat's durable
-    // wakeups/cron/loop schedules, or inspect (no flag) the current pause
-    // state + next armed wake via getSessionSummary.
+    // Per-session scheduled-work control. The state query works for both chat
+    // sessions and tracked provider CLI terminals.
     const targetSession = requireValue(sessionId, "sessionId");
     const pause = readFlag(args, ["--pause", "--pause-scheduled-work"]);
     const resume = readFlag(args, ["--resume", "--unpause"]);
@@ -7351,14 +7350,13 @@ function buildChatPlan(args: string[]): CliPlan {
       throw new CliUsageError("Use either --pause or --resume, not both.");
     }
     if (!pause && !resume) {
-      // Inspection mode: summary carries nextWakeAt + scheduledWorkPaused.
       return {
         kind: "execute",
         label: "chat schedules",
         steps: [
-          actionArgsListStep("result", "chat", "getSessionSummary", [
-            targetSession,
-          ]),
+          actionStep("result", "chat", "getScheduledWorkState", {
+            sessionId: targetSession,
+          }),
         ],
       };
     }
diff --git a/apps/ade-cli/src/services/personalChats/personalChatScope.test.ts b/apps/ade-cli/src/services/personalChats/personalChatScope.test.ts
index 64b095a9a..0ac163bac 100644
--- a/apps/ade-cli/src/services/personalChats/personalChatScope.test.ts
+++ b/apps/ade-cli/src/services/personalChats/personalChatScope.test.ts
@@ -52,6 +52,11 @@ describe("PersonalChatScope", () => {
       interrupt: vi.fn(async () => undefined),
       respondToInput: vi.fn(async () => undefined),
       approveToolUse: vi.fn(async () => undefined),
+      createScheduledWork: vi.fn(async ({ sessionId, cron, prompt }: {
+        sessionId: string;
+        cron: string;
+        prompt: string;
+      }) => ({ item: { id: "cron-created", sessionId, cron, prompt, status: "scheduled" } })),
       cancelScheduledWork: vi.fn(async ({ sessionId, scheduleId }: {
         sessionId: string;
         scheduleId: string;
@@ -60,6 +65,10 @@ describe("PersonalChatScope", () => {
         providerCancellationRequested: true,
         providerCancellationConfirmed: true,
       })),
+      setScheduledWorkPaused: vi.fn(async ({ sessionId, paused }: {
+        sessionId: string;
+        paused: boolean;
+      }) => ({ sessionId, paused, nextWakeAt: null })),
       updateSession: vi.fn(async () => summary),
       archiveSession: vi.fn(async () => undefined),
       unarchiveSession: vi.fn(async () => undefined),
@@ -179,6 +188,42 @@ describe("PersonalChatScope", () => {
     expect(service.cancelScheduledWork).toHaveBeenCalledTimes(1);
   });
 
+  it("creates and pauses scheduled work only for an owned personal session", async () => {
+    const { createRuntime, service } = fixture();
+    const scope = new PersonalChatScope({ createRuntime });
+
+    await expect(scope.call("createScheduledWork", {
+      sessionId: "chat-1",
+      cron: "*/20 * * * *",
+      prompt: "Check PR CI",
+    })).resolves.toMatchObject({
+      action: "createScheduledWork",
+      result: { item: { id: "cron-created", sessionId: "chat-1", status: "scheduled" } },
+    });
+    expect(service.createScheduledWork).toHaveBeenCalledWith({
+      sessionId: "chat-1",
+      cron: "*/20 * * * *",
+      prompt: "Check PR CI",
+    });
+
+    await expect(scope.call("setScheduledWorkPaused", {
+      sessionId: "chat-1",
+      paused: true,
+    })).resolves.toEqual({
+      action: "setScheduledWorkPaused",
+      result: { sessionId: "chat-1", paused: true, nextWakeAt: null },
+    });
+    expect(service.setScheduledWorkPaused).toHaveBeenCalledWith({
+      sessionId: "chat-1",
+      paused: true,
+    });
+    await expect(scope.call("setScheduledWorkPaused", {
+      sessionId: "chat-1",
+      paused: "yes",
+    })).rejects.toThrow("paused must be a boolean");
+    expect(service.setScheduledWorkPaused).toHaveBeenCalledTimes(1);
+  });
+
   it("exposes only the hard allowlisted personal-chat actions", () => {
     const scope = new PersonalChatScope();
     const capabilities = scope.capabilities();
@@ -187,7 +232,9 @@ describe("PersonalChatScope", () => {
       "list",
       "create",
       "send",
+      "createScheduledWork",
       "cancelScheduledWork",
+      "setScheduledWorkPaused",
       "updateSession",
       "delete",
     ]));
diff --git a/apps/ade-cli/src/services/personalChats/personalChatScope.ts b/apps/ade-cli/src/services/personalChats/personalChatScope.ts
index 0185c5c83..1706c7fb9 100644
--- a/apps/ade-cli/src/services/personalChats/personalChatScope.ts
+++ b/apps/ade-cli/src/services/personalChats/personalChatScope.ts
@@ -31,6 +31,11 @@ function requiredString(value: unknown, label: string): string {
   return normalized;
 }
 
+function requiredBoolean(value: unknown, label: string): boolean {
+  if (typeof value !== "boolean") throw new Error(`${label} must be a boolean.`);
+  return value;
+}
+
 function readSessionId(args: ObjectArgs): string {
   return requiredString(args.sessionId, "sessionId");
 }
@@ -174,6 +179,12 @@ export class PersonalChatScope {
         await this.requirePersonalSession(service, readSessionId(args));
         result = await service.approveToolUse(args as never);
         break;
+      case "createScheduledWork": {
+        const sessionId = readSessionId(args);
+        await this.requirePersonalSession(service, sessionId);
+        result = await service.createScheduledWork({ ...args, sessionId } as never);
+        break;
+      }
       case "cancelScheduledWork": {
         const sessionId = readSessionId(args);
         await this.requirePersonalSession(service, sessionId);
@@ -183,6 +194,15 @@ export class PersonalChatScope {
         });
         break;
       }
+      case "setScheduledWorkPaused": {
+        const sessionId = readSessionId(args);
+        await this.requirePersonalSession(service, sessionId);
+        result = await service.setScheduledWorkPaused({
+          sessionId,
+          paused: requiredBoolean(args.paused, "paused"),
+        });
+        break;
+      }
       case "updateSession": {
         const sessionId = readSessionId(args);
         await this.requirePersonalSession(service, sessionId);
diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts
index e60f80120..f6a75ca2b 100644
--- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts
+++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts
@@ -229,6 +229,16 @@ describe("createSyncRemoteCommandService", () => {
       scope: "runtime",
       policy: { viewerAllowed: true, queueable: false },
     });
+    expect(service.getDescriptor("personalChats.createScheduledWork")).toEqual({
+      action: "personalChats.createScheduledWork",
+      scope: "runtime",
+      policy: { viewerAllowed: false, queueable: false },
+    });
+    expect(service.getDescriptor("personalChats.setScheduledWorkPaused")).toEqual({
+      action: "personalChats.setScheduledWorkPaused",
+      scope: "runtime",
+      policy: { viewerAllowed: true, queueable: false },
+    });
     expect(service.getDescriptor("personalChats.streamEvents")).toEqual({
       action: "personalChats.streamEvents",
       scope: "runtime",
@@ -261,6 +271,30 @@ describe("createSyncRemoteCommandService", () => {
       sessionId: "personal-1",
       scheduleId: "cron-1",
     });
+    await expect(service.execute(makePayload("personalChats.createScheduledWork", {
+      sessionId: "personal-1",
+      cron: "*/20 * * * *",
+      prompt: "Check PR CI",
+    }))).resolves.toEqual({
+      action: "createScheduledWork",
+      args: { sessionId: "personal-1", cron: "*/20 * * * *", prompt: "Check PR CI" },
+    });
+    expect(personalChatScope.call).toHaveBeenCalledWith("createScheduledWork", {
+      sessionId: "personal-1",
+      cron: "*/20 * * * *",
+      prompt: "Check PR CI",
+    });
+    await expect(service.execute(makePayload("personalChats.setScheduledWorkPaused", {
+      sessionId: "personal-1",
+      paused: true,
+    }))).resolves.toEqual({
+      action: "setScheduledWorkPaused",
+      args: { sessionId: "personal-1", paused: true },
+    });
+    expect(personalChatScope.call).toHaveBeenCalledWith("setScheduledWorkPaused", {
+      sessionId: "personal-1",
+      paused: true,
+    });
   });
 
   it("registers sync.getWebPairingInfo and returns the configured browser pairing info", async () => {
@@ -478,14 +512,15 @@ describe("createSyncRemoteCommandService", () => {
     expect(cancelScheduledWork).toHaveBeenCalledTimes(1);
   });
 
-  it("routes scheduled-work creation and pause control through owner-only mobile commands", async () => {
+  it("keeps scheduled-work creation owner-only while allowing mobile pause control", async () => {
     const createScheduledWork = vi.fn(async (args: Record) => ({ item: args }));
+    const listScheduledWork = vi.fn(async (args: Record) => [{ id: "cron-1", ...args }]);
     const setScheduledWorkPaused = vi.fn(async (args: { sessionId: string; paused: boolean }) => ({
       ...args,
       nextWakeAt: null,
     }));
     const { service } = createService({
-      agentChatService: { createScheduledWork, setScheduledWorkPaused },
+      agentChatService: { createScheduledWork, listScheduledWork, setScheduledWorkPaused },
     });
 
     expect(service.getDescriptor("chat.createScheduledWork")).toEqual({
@@ -493,10 +528,15 @@ describe("createSyncRemoteCommandService", () => {
       scope: "project",
       policy: { viewerAllowed: false, queueable: false },
     });
+    expect(service.getDescriptor("chat.listScheduledWork")).toEqual({
+      action: "chat.listScheduledWork",
+      scope: "project",
+      policy: { viewerAllowed: true, queueable: false },
+    });
     expect(service.getDescriptor("chat.setScheduledWorkPaused")).toEqual({
       action: "chat.setScheduledWorkPaused",
       scope: "project",
-      policy: { viewerAllowed: false, queueable: false },
+      policy: { viewerAllowed: true, queueable: false },
     });
     await service.execute(makePayload("chat.createScheduledWork", {
       sessionId: "chat-1",
@@ -512,6 +552,10 @@ describe("createSyncRemoteCommandService", () => {
       recurring: false,
       reason: "CI watcher",
     });
+    await expect(service.execute(makePayload("chat.listScheduledWork", {
+      sessionId: "chat-1",
+    }))).resolves.toEqual([{ id: "cron-1", sessionId: "chat-1" }]);
+    expect(listScheduledWork).toHaveBeenCalledWith({ sessionId: "chat-1" });
     await service.execute(makePayload("chat.setScheduledWorkPaused", {
       sessionId: "chat-1",
       paused: true,
@@ -1158,6 +1202,7 @@ describe("createSyncRemoteCommandService", () => {
       "chat.getSlashCommands",
       "chat.resolveSmartLinkPreview",
       "chat.createScheduledWork",
+      "chat.listScheduledWork",
       "chat.cancelScheduledWork",
       "chat.setScheduledWorkPaused",
       "chat.getParallelLaunchState",
diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts
index 78776579a..a965db757 100644
--- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts
+++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts
@@ -183,7 +183,11 @@ import type {
 } from "../../../../desktop/src/shared/types";
 import { isAdeUsageRangePreset, isAdeUsageScope } from "../../../../desktop/src/shared/types";
 import type { OrchestrationRunCreateRequest } from "../../../../desktop/src/shared/types/orchestration";
-import { PERSONAL_CHAT_ACTIONS, isPersonalChatActionQueueable } from "../../../../desktop/src/shared/types/personalChats";
+import {
+  PERSONAL_CHAT_ACTIONS,
+  isPersonalChatActionQueueable,
+  isPersonalChatActionViewerAllowed,
+} from "../../../../desktop/src/shared/types/personalChats";
 import {
   buildTrackedCliLaunchCommand,
   deriveTrackedCliInitialInputSessionMeta,
@@ -3829,12 +3833,19 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio
       ...(reason ? { reason } : {}),
     });
   });
+  register("chat.listScheduledWork", { viewerAllowed: true, queueable: false }, async (payload) => {
+    const sessionId = asTrimmedString(payload.sessionId);
+    return requireService(args.agentChatService, "Agent chat service not available.").listScheduledWork({
+      ...(sessionId ? { sessionId } : {}),
+      ...(payload.includeTerminal === true ? { includeTerminal: true } : {}),
+    });
+  });
   register("chat.cancelScheduledWork", { viewerAllowed: true, queueable: false }, async (payload) =>
     requireService(args.agentChatService, "Agent chat service not available.").cancelScheduledWork({
       sessionId: requireString(payload.sessionId, "chat.cancelScheduledWork requires sessionId."),
       scheduleId: requireString(payload.scheduleId, "chat.cancelScheduledWork requires scheduleId."),
     }));
-  register("chat.setScheduledWorkPaused", { viewerAllowed: false, queueable: false }, async (payload) => {
+  register("chat.setScheduledWorkPaused", { viewerAllowed: true, queueable: false }, async (payload) => {
     const paused = asOptionalBoolean(payload.paused);
     if (paused === undefined) throw new Error("chat.setScheduledWorkPaused requires paused.");
     return requireService(args.agentChatService, "Agent chat service not available.").setScheduledWorkPaused({
@@ -4003,7 +4014,10 @@ function registerPersonalChatRemoteCommands({ args, register }: RemoteCommandReg
   for (const action of PERSONAL_CHAT_ACTIONS) {
     register(
       `personalChats.${action}`,
-      { viewerAllowed: true, queueable: isPersonalChatActionQueueable(action) },
+      {
+        viewerAllowed: isPersonalChatActionViewerAllowed(action),
+        queueable: isPersonalChatActionQueueable(action),
+      },
       async (payload) => (await scope.call(action, payload)).result,
       "runtime",
     );
diff --git a/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx
index 7eaa44866..8cb0b914b 100644
--- a/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx
+++ b/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx
@@ -10,6 +10,7 @@ import {
   closedCliSessionStatusKind,
   deriveClosedCliSessions,
   deriveOpenDrawerSessions,
+  terminalSessionToChatSummary,
   type ClosedCliSessionSummary,
 } from "../closedCliSessions";
 
@@ -117,6 +118,22 @@ describe("Drawer diff stats", () => {
 });
 
 describe("Drawer closed CLI sessions", () => {
+  it("projects durable schedule state onto tracked CLI summaries", () => {
+    const summary = terminalSessionToChatSummary(terminal({ terminalId: "cli-scheduled" }), {
+      sessionId: "cli-scheduled",
+      paused: true,
+      nextWakeAt: "2026-07-16T21:00:00.000Z",
+      items: [],
+    });
+
+    expect(summary).toMatchObject({
+      sessionId: "cli-scheduled",
+      scheduledWorkPaused: true,
+      nextWakeAt: "2026-07-16T21:00:00.000Z",
+      scheduledWork: [],
+    });
+  });
+
   it.each([
     ["failed status", { status: "failed", exitCode: 1, runtimeState: "killed" }, "failed"],
     ["non-zero exit", { status: "completed", exitCode: 2, runtimeState: "exited" }, "failed"],
diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts
index 9aea161c2..14f46a63e 100644
--- a/apps/ade-cli/src/tuiClient/adeApi.ts
+++ b/apps/ade-cli/src/tuiClient/adeApi.ts
@@ -34,6 +34,7 @@ import type {
   AgentChatOpenCodePermissionMode,
   AgentChatPermissionMode,
   AgentChatProvider,
+  AgentChatScheduledWorkState,
   AgentChatSession,
   AgentChatSessionSummary,
   AgentChatSlashCommand,
@@ -136,6 +137,15 @@ export async function listChatSessions(
   });
 }
 
+export async function getScheduledWorkState(
+  connection: AdeCodeConnection,
+  sessionId: string,
+): Promise {
+  return await connection.action("chat", "getScheduledWorkState", {
+    sessionId,
+  });
+}
+
 export async function archiveChatSession(
   connection: AdeCodeConnection,
   sessionId: string,
diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx
index 146f6c53f..d23a7a01a 100644
--- a/apps/ade-cli/src/tuiClient/app.tsx
+++ b/apps/ade-cli/src/tuiClient/app.tsx
@@ -31,6 +31,7 @@ import type {
   AgentChatModelCatalogModel,
   AgentChatModelCatalogRefreshProvider,
   AgentChatModelInfo,
+  AgentChatScheduledWorkState,
   AgentChatSession,
   AgentChatSessionSummary,
   AgentChatSlashCommand,
@@ -68,6 +69,7 @@ import {
   toggleModelPickerFavorite,
   getOpenCodeRuntimeDiagnostics,
   getSlashCommands,
+  getScheduledWorkState,
   getStoredApiKeyProviders,
   getSubagentTranscript,
   interruptChat,
@@ -2857,6 +2859,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
   const [diffByLaneId, setDiffByLaneId] = useState>({});
   const [sessions, setSessions] = useState([]);
   const [terminalSessions, setTerminalSessions] = useState([]);
+  const [terminalScheduledWorkById, setTerminalScheduledWorkById] = useState>({});
   const [terminalPreview, setTerminalPreview] = useState(null);
   // Per-terminal seed snapshots for grid tiles (the single-view pane uses
   // `terminalPreview`). Live updates after the seed arrive via terminalLiveChunks,
@@ -3809,25 +3812,45 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
     () => terminalSessions.find((session) => session.terminalId === activeSessionId) ?? null,
     [activeSessionId, terminalSessions],
   );
+  useEffect(() => {
+    if (!connection || !activeTerminalSession) return;
+    let cancelled = false;
+    void getScheduledWorkState(connection, activeTerminalSession.terminalId)
+      .then((state) => {
+        if (cancelled) return;
+        setTerminalScheduledWorkById((current) => ({
+          ...current,
+          [activeTerminalSession.terminalId]: state,
+        }));
+      })
+      .catch(() => undefined);
+    return () => { cancelled = true; };
+  }, [activeTerminalSession, connection]);
   // Chat-shaped view of the active session that ALSO covers Claude terminal
   // sessions (which never appear in `sessions`). Drives chat-info availability
   // and the context resolver so terminal chats get the same chat-info default
   // pane as SDK chats instead of falling back to lane-details.
   const activeDisplaySession = useMemo(
-    () => activeSession ?? (activeTerminalSession ? terminalSessionToChatSummary(activeTerminalSession) : null),
-    [activeSession, activeTerminalSession],
+    () => activeSession ?? (activeTerminalSession
+      ? terminalSessionToChatSummary(
+          activeTerminalSession,
+          terminalScheduledWorkById[activeTerminalSession.terminalId],
+        )
+      : null),
+    [activeSession, activeTerminalSession, terminalScheduledWorkById],
   );
   const activeTerminalProvider = terminalSessionProvider(activeTerminalSession);
   const displaySessions = useMemo(
     () => sortSessionsByRecentActivity([
       ...sessions.filter((session) => !session.archivedAt),
-      ...terminalSessions.map(terminalSessionToChatSummary),
+      ...terminalSessions.map((session) =>
+        terminalSessionToChatSummary(session, terminalScheduledWorkById[session.terminalId])),
     ]),
-    [sessions, terminalSessions],
+    [sessions, terminalScheduledWorkById, terminalSessions],
   );
   const closedCliSessions = useMemo(
-    () => deriveClosedCliSessions(terminalSessions),
-    [terminalSessions],
+    () => deriveClosedCliSessions(terminalSessions, terminalScheduledWorkById),
+    [terminalScheduledWorkById, terminalSessions],
   );
   const openDrawerSessions = useMemo(
     () => deriveOpenDrawerSessions(displaySessions, closedCliSessions),
@@ -6946,7 +6969,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
     const nextTerminalSessions = mergeOptimisticTerminalSessions(listedTerminalSessions, optimisticTerminalSessionsRef.current);
     const nextDisplaySessions = sortSessionsByRecentActivity([
       ...nextSessions,
-      ...nextTerminalSessions.map(terminalSessionToChatSummary),
+      ...nextTerminalSessions.map((session) => terminalSessionToChatSummary(session)),
     ]);
     const draftMode = draftChatActiveRef.current;
     const target = resolveTuiChatRefreshTarget({
@@ -10827,7 +10850,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
         session =
           freshChatSessions.find((entry) => entry.sessionId === sessionId)
           ?? (freshTerminalSessions
-            .map(terminalSessionToChatSummary)
+            .map((terminal) => terminalSessionToChatSummary(terminal))
             .find((entry) => entry.sessionId === sessionId) ?? null);
       }
     }
diff --git a/apps/ade-cli/src/tuiClient/closedCliSessions.ts b/apps/ade-cli/src/tuiClient/closedCliSessions.ts
index 4b86311b0..7c55dfba2 100644
--- a/apps/ade-cli/src/tuiClient/closedCliSessions.ts
+++ b/apps/ade-cli/src/tuiClient/closedCliSessions.ts
@@ -1,4 +1,4 @@
-import type { AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat";
+import type { AgentChatScheduledWorkState, AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat";
 import type { ChatTerminalSession } from "../../../desktop/src/shared/types/sessions";
 import { formatRelativePastTime } from "./relativeTime";
 import { theme } from "./theme";
@@ -54,7 +54,10 @@ function terminalSummaryProvider(session: ChatTerminalSession): AgentChatSession
     : "claude";
 }
 
-export function terminalSessionToChatSummary(session: ChatTerminalSession): ClosedCliSessionSummary {
+export function terminalSessionToChatSummary(
+  session: ChatTerminalSession,
+  scheduledWorkState?: AgentChatScheduledWorkState | null,
+): ClosedCliSessionSummary {
   const status: AgentChatSessionSummary["status"] = session.status === "running"
     ? session.runtimeState === "idle" ? "idle" : "active"
     : "ended";
@@ -73,7 +76,9 @@ export function terminalSessionToChatSummary(session: ChatTerminalSession): Clos
     lastActivityAt: session.endedAt ?? session.startedAt,
     lastOutputPreview: session.lastOutputPreview,
     summary: session.summary,
-    nextWakeAt: null,
+    nextWakeAt: scheduledWorkState?.nextWakeAt ?? null,
+    scheduledWorkPaused: scheduledWorkState?.paused === true,
+    scheduledWork: scheduledWorkState?.items ?? [],
     surface: "work",
     terminalStatus: session.status,
     terminalExitCode: session.exitCode,
@@ -89,11 +94,14 @@ export function sortSessionsByRecentActivity = {},
+): ClosedCliSessionSummary[] {
   return sortSessionsByRecentActivity(
     terminalSessions
       .filter((session) => session.status !== "running" && terminalSessionProvider(session) != null)
-      .map(terminalSessionToChatSummary),
+      .map((session) => terminalSessionToChatSummary(session, scheduledWorkStateById[session.terminalId])),
   );
 }
 
diff --git a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md
index 75a6e5298..f04eb4fc6 100644
--- a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md
+++ b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md
@@ -150,19 +150,24 @@ Use `ade chat show  --text` before messaging a chat you do not own:
 
 ### Scheduled work
 
-Persistent ADE chats can schedule their own durable wakeups from every provider
-runtime. Use the typed `ade chat scheduled-work create|list|cancel` commands or
-the generic `chat.createScheduledWork`, `chat.listScheduledWork`, and
-`chat.cancelScheduledWork` actions. Pause or resume a chat with
+Persistent ADE chats and tracked provider CLI sessions can schedule their own
+durable wakeups. Use the typed `ade chat scheduled-work create|list|cancel`
+commands or the generic `chat.createScheduledWork`, `chat.listScheduledWork`,
+and `chat.cancelScheduledWork` actions. Pause or resume that session with
 `ade chat schedules  --pause|--resume` or
-`chat.setScheduledWorkPaused`.
-
-Omitting the target in a bound agent session defaults create/list to
-`ADE_CHAT_SESSION_ID`; a caller cannot schedule another chat, and an unbound CLI
-session fails instead of creating orphaned work. Delivery starts a new turn at
-the next safe turn boundary, survives brain restarts, and recurring schedules
-expire after seven days. Users can also pause one chat in Chat Info or all
-scheduled work in Settings.
+`chat.setScheduledWorkPaused`. Omit the pause/resume flag, or call
+`chat.getScheduledWorkState`, to inspect pause state, the next wake, and active
+jobs for either a chat or tracked provider CLI session.
+
+Omitting the target in an ADE-bound agent defaults create/list to
+`ADE_CHAT_SESSION_ID`; an agent cannot schedule another session, and an
+ordinary untracked shell fails instead of creating orphaned work. Chat delivery
+starts a new turn at the next safe turn boundary. Tracked provider CLI delivery
+waits for the provider's visible composer boundary, or resumes the same ended
+CLI session before sending the prompt. Both survive brain restarts, and
+recurring schedules expire seven days after creation. Users can pause chat jobs
+in Chat Info or all scheduled work in Settings; CLI-owned jobs remain
+manageable through these commands and the Settings recovery list.
 
 ```
 ade actions run chat.createScheduledWork --input-json '{"cron":"9,29,49 * * * *","prompt":"Check CI and report"}' --text
diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts
index a80d0a0db..3dd9131d9 100644
--- a/apps/desktop/src/main/services/adeActions/registry.ts
+++ b/apps/desktop/src/main/services/adeActions/registry.ts
@@ -508,6 +508,7 @@ export const ADE_ACTION_ALLOWLIST: Partial {
+      const record = readObjectActionArg(args, "chat.getScheduledWorkState");
+      return agentChatService.getScheduledWorkState({
+        sessionId: requireNonEmptyString(record.sessionId, "sessionId"),
+      });
+    };
+  }
   if (typeof base.cancelScheduledWork === "function") {
     service.cancelScheduledWork = (args?: unknown) => {
       const record = readObjectActionArg(args, "chat.cancelScheduledWork");
diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts
index 0267c6d4b..32df62669 100644
--- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts
+++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts
@@ -109,7 +109,7 @@ describe("buildCodingAgentSystemPrompt", () => {
       expect(result).toContain("## Runtime Environment");
       expect(result).toContain("Codex CLI");
       expect(result).toContain("chat.createScheduledWork");
-      expect(result).toContain("targets your own chat session automatically");
+      expect(result).toContain("targets your own tracked agent session automatically");
     });
 
     it("describes the Codex app-server runtime", () => {
@@ -127,13 +127,13 @@ describe("buildCodingAgentSystemPrompt", () => {
       expect(result).toContain("Claude Agent SDK stable `query()`");
       expect(result).toContain("ScheduleWakeup");
       expect(result).toContain("CronCreate");
-      expect(result).toContain("automatically backed by ADE's durable scheduler");
-      expect(result).toContain("the `durable` flag is not needed");
+      expect(result).toContain("automatically mirrored into ADE's durable scheduler");
+      expect(result).toContain("`durable: true` also persists Claude's provider copy");
       expect(result).toContain("next turn boundary even if the chat was busy");
       expect(result).toContain("`CronList` view is advisory; ADE state wins");
       expect(result).toContain("CronCreate` always creates a new job");
       expect(result).toContain("`CronList` + `CronDelete`");
-      expect(result).toContain("expire after seven days");
+      expect(result).toContain("expire seven days after creation");
       expect(result).toContain("project-wide in Settings");
       expect(result).not.toContain("unavailable in this ADE chat");
       expect(result).not.toContain("will not start a later turn by itself");
diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts
index 2dfb68ed0..9ce6fbcf2 100644
--- a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts
+++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts
@@ -17,14 +17,14 @@ export type AdeRuntimeKind =
   | "droid-sdk"
   | "opencode";
 
-const adeScheduledWorkGuidance = "**Wake-up semantics:** Autonomous wake is available via `ade actions run chat.createScheduledWork --input-json '{\"cron\":\"<5-field>\",\"prompt\":\"\"}' --text`. Jobs recur by default; pass `{\"recurring\":false}` for a one-shot, and the action targets your own chat session automatically. List, cancel, or pause with `chat.listScheduledWork`, `chat.cancelScheduledWork`, and `chat.setScheduledWorkPaused`, or the typed `ade chat scheduled-work ...` / `ade chat schedules ...` commands. Delivery starts a new turn at the next turn boundary and survives brain restarts; recurring jobs expire after seven days. Keep shell `sleep` for short waits inside the current turn.";
+const adeScheduledWorkGuidance = "**Wake-up semantics:** Autonomous wake is available via `ade actions run chat.createScheduledWork --input-json '{\"cron\":\"<5-field>\",\"prompt\":\"\"}' --text`. Jobs recur by default; pass `{\"recurring\":false}` for a one-shot, and the action targets your own tracked agent session automatically. List, cancel, or pause with `chat.listScheduledWork`, `chat.cancelScheduledWork`, and `chat.setScheduledWorkPaused`, or the typed `ade chat scheduled-work ...` / `ade chat schedules ...` commands. Delivery starts a new turn at the next turn boundary, resumes an ended tracked provider CLI when necessary, and survives brain restarts; recurring jobs expire after seven days. Keep shell `sleep` for short waits inside the current turn.";
 
 function describeRuntime(runtime: AdeRuntimeKind): string[] {
   switch (runtime) {
     case "claude-agent-sdk-query":
       return [
         "**Runtime:** ADE Work chat hosted on the Claude Agent SDK stable `query()` streaming-input API.",
-        "**Wake-up semantics:** Native `ScheduleWakeup`, `CronCreate`, and `/loop` are automatically backed by ADE's durable scheduler; the `durable` flag is not needed. They survive brain restarts and start a new turn at the next turn boundary even if the chat was busy when they became due. The SDK's own `CronList` view is advisory; ADE state wins. Pause schedules in Chat Info or project-wide in Settings. Recurring jobs expire after seven days. `CronCreate` always creates a new job, so replace one with `CronList` + `CronDelete` before creating another.",
+        "**Wake-up semantics:** Native `ScheduleWakeup`, `CronCreate`, and `/loop` are automatically mirrored into ADE's durable scheduler. `durable: true` also persists Claude's provider copy, while ADE's delivery guarantee does not depend on that flag. Jobs survive brain restarts and start a new turn at the next turn boundary even if the chat was busy when they became due. The SDK's own `CronList` view is advisory; ADE state wins. Pause schedules in Chat Info or project-wide in Settings. Recurring jobs expire seven days after creation. `CronCreate` always creates a new job, so replace one with `CronList` + `CronDelete` before creating another.",
         "**To wait:** For short bounded waits inside the current turn, a foreground command such as `sleep ... && ` is fine. For longer waits or autonomous follow-up, prefer `ScheduleWakeup`, `CronCreate`, or `/loop` and include a concise reason/prompt so ADE can show the pending work clearly.",
       ];
     case "codex-cli":
diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts
index 1efd532bc..e160bdb27 100644
--- a/apps/desktop/src/main/services/chat/agentChatService.test.ts
+++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts
@@ -853,6 +853,7 @@ import { acquireCursorSdkConnection, releaseCursorSdkConnection } from "./cursor
 import { acquireDroidSdkConnection } from "./droidSdkPool";
 import { clearCursorCliModelsCache } from "./cursorModelsDiscovery";
 import type { AgentChatCrossMachineHandoffCapsule, AgentChatEventEnvelope, ComputerUseBackendStatus, LaneLinearIssue, PendingInputRequest } from "../../../shared/types";
+import { PTY_SEND_PRE_DELIVERY_ERROR_CODE } from "../../../shared/types";
 import { makeLinearIssueContextAttachment } from "../../../shared/chatContextAttachments";
 import { stableStringify } from "../shared/utils";
 import {
@@ -1252,6 +1253,7 @@ function createMockSessionService() {
         id: args.sessionId,
         laneId: args.laneId,
         ptyId: args.ptyId ?? null,
+        tracked: args.tracked ?? true,
         title: args.title ?? "Chat",
         toolType: args.toolType ?? "opencode-chat",
         status: "running",
@@ -1925,6 +1927,7 @@ beforeEach(() => {
 });
 
 afterEach(() => {
+  vi.useRealTimers();
   vi.restoreAllMocks();
   if (ORIGINAL_CURSOR_API_KEY === undefined) {
     delete process.env.CURSOR_API_KEY;
@@ -12782,6 +12785,18 @@ describe("createAgentChatService", () => {
         cron: "0 * * * *",
         prompt: "This must not be scheduled.",
       })).rejects.toThrow(/ended or archived/i);
+
+      sessionService.create({
+        sessionId: "untracked-cli",
+        laneId: "lane-1",
+        toolType: "codex",
+        tracked: false,
+      });
+      await expect(service.createScheduledWork({
+        sessionId: "untracked-cli",
+        cron: "0 * * * *",
+        prompt: "This must not be scheduled.",
+      })).rejects.toThrow(/not found/i);
     });
 
     it("delivers a provider-neutral action schedule through messageSession for an idle live Claude chat", async () => {
@@ -12819,7 +12834,6 @@ describe("createAgentChatService", () => {
       await vi.advanceTimersByTimeAsync(60_000);
       vi.useRealTimers();
 
-      expect(service.pendingNativeScheduledWakeCountForTesting(session.id)).toBe(0);
       expect(events).toEqual(expect.arrayContaining([
         expect.objectContaining({
           sessionId: session.id,
@@ -12834,6 +12848,287 @@ describe("createAgentChatService", () => {
       service.forceDisposeAll();
     });
 
+    it("resumes an ended tracked CLI session when its durable one-shot becomes due", async () => {
+      vi.useFakeTimers();
+      vi.setSystemTime(SCHEDULE_TEST_START);
+      const scheduledWork = createScheduledWorkDb();
+      const sendToSession = vi.fn(async () => ({
+        ptyId: "pty-cli-scheduled",
+        sessionId: "cli-scheduled",
+        pid: 42,
+        session: null,
+        resumed: true,
+        reusedExistingRuntime: false,
+      }));
+      const { service, sessionService } = createService({
+        db: scheduledWork.db,
+        ptyService: {
+          create: vi.fn(),
+          canAcceptScheduledTurn: () => true,
+          enrichSessions: (rows: Array>) => rows.map((row) => ({
+            ...row,
+            runtimeState: "exited",
+          })),
+          sendToSession,
+        },
+      });
+      sessionService.create({
+        sessionId: "cli-scheduled",
+        laneId: "lane-1",
+        toolType: "codex",
+      });
+      sessionService.end({ sessionId: "cli-scheduled", status: "completed" });
+
+      const created = await service.createScheduledWork({
+        sessionId: "cli-scheduled",
+        cron: "1 * * * *",
+        prompt: "Check CI and report the result.",
+        recurring: false,
+      });
+      await vi.advanceTimersByTimeAsync(60_000);
+
+      expect(sendToSession).toHaveBeenCalledWith({
+        sessionId: "cli-scheduled",
+        text: "Check CI and report the result.",
+      });
+      expect(await service.listScheduledWork({
+        sessionId: "cli-scheduled",
+        includeTerminal: true,
+      })).toEqual([
+        expect.objectContaining({
+          id: created.item.id,
+          status: "completed",
+        }),
+      ]);
+      service.forceDisposeAll();
+    });
+
+    it("reads durable schedule state for a tracked CLI session", async () => {
+      vi.useFakeTimers();
+      vi.setSystemTime(SCHEDULE_TEST_START);
+      const scheduledWork = createScheduledWorkDb();
+      const { service, sessionService } = createService({ db: scheduledWork.db });
+      sessionService.create({
+        sessionId: "cli-state",
+        laneId: "lane-1",
+        toolType: "codex",
+      });
+      const created = await service.createScheduledWork({
+        sessionId: "cli-state",
+        cron: "1 * * * *",
+        prompt: "Inspect this from ADE Code.",
+        recurring: false,
+      });
+
+      expect(await service.getScheduledWorkState({ sessionId: "cli-state" })).toEqual({
+        sessionId: "cli-state",
+        paused: false,
+        nextWakeAt: new Date(SCHEDULE_TEST_START + 60_000).toISOString(),
+        items: [expect.objectContaining({ id: created.item.id, status: "scheduled" })],
+      });
+      await service.setScheduledWorkPaused({ sessionId: "cli-state", paused: true });
+      expect(await service.getScheduledWorkState({ sessionId: "cli-state" })).toMatchObject({
+        sessionId: "cli-state",
+        paused: true,
+        nextWakeAt: null,
+      });
+      service.forceDisposeAll();
+    });
+
+    it("waits for a tracked CLI turn boundary before delivering scheduled work", async () => {
+      vi.useFakeTimers();
+      vi.setSystemTime(SCHEDULE_TEST_START);
+      const scheduledWork = createScheduledWorkDb();
+      let canAcceptScheduledTurn = false;
+      const sendToSession = vi.fn(async () => ({
+        ptyId: "pty-cli-busy",
+        sessionId: "cli-busy",
+        pid: 42,
+        session: null,
+        resumed: false,
+        reusedExistingRuntime: true,
+      }));
+      const { service, sessionService } = createService({
+        db: scheduledWork.db,
+        ptyService: {
+          create: vi.fn(),
+          canAcceptScheduledTurn: () => canAcceptScheduledTurn,
+          enrichSessions: (rows: Array>) => rows.map((row) => ({
+            ...row,
+            runtimeState: canAcceptScheduledTurn ? "waiting-input" : "running",
+          })),
+          sendToSession,
+        },
+      });
+      sessionService.create({
+        sessionId: "cli-busy",
+        laneId: "lane-1",
+        toolType: "claude",
+      });
+      await service.createScheduledWork({
+        sessionId: "cli-busy",
+        cron: "1 * * * *",
+        prompt: "Continue after the foreground CLI turn.",
+        recurring: false,
+      });
+
+      await vi.advanceTimersByTimeAsync(60_000);
+      expect(sendToSession).not.toHaveBeenCalled();
+      expect(scheduledWork.readState()?.schedules[0]?.status).toBe("scheduled");
+
+      canAcceptScheduledTurn = true;
+      await vi.advanceTimersByTimeAsync(20_000);
+      expect(sendToSession).toHaveBeenCalledOnce();
+      expect(scheduledWork.readState()?.schedules[0]?.status).toBe("done");
+      service.forceDisposeAll();
+    });
+
+    it("retries a tracked CLI occurrence when resume fails before delivery", async () => {
+      vi.useFakeTimers();
+      vi.setSystemTime(SCHEDULE_TEST_START);
+      const scheduledWork = createScheduledWorkDb();
+      const sendToSession = vi.fn(async () => {
+        throw Object.assign(
+          new Error("Terminal session 'cli-no-resume' does not have a resume command."),
+          { code: PTY_SEND_PRE_DELIVERY_ERROR_CODE },
+        );
+      });
+      const { service, sessionService } = createService({
+        db: scheduledWork.db,
+        ptyService: {
+          create: vi.fn(),
+          canAcceptScheduledTurn: () => true,
+          enrichSessions: (rows: Array>) => rows.map((row) => ({
+            ...row,
+            runtimeState: "exited",
+          })),
+          sendToSession,
+        },
+      });
+      sessionService.create({
+        sessionId: "cli-no-resume",
+        laneId: "lane-1",
+        toolType: "codex",
+      });
+      sessionService.end({ sessionId: "cli-no-resume", status: "completed" });
+      await service.createScheduledWork({
+        sessionId: "cli-no-resume",
+        cron: "1 * * * *",
+        prompt: "Retry this only after a resumable target exists.",
+        recurring: false,
+      });
+
+      await vi.advanceTimersByTimeAsync(60_000);
+      expect(sendToSession).toHaveBeenCalledOnce();
+      expect(scheduledWork.readState()?.schedules[0]).toEqual(expect.objectContaining({
+        status: "scheduled",
+        fireAt: SCHEDULE_TEST_START + 60_000,
+      }));
+      expect(scheduledWork.readState()?.schedules[0]?.lastFiredAt).toBeUndefined();
+
+      await vi.advanceTimersByTimeAsync(20_000);
+      expect(sendToSession).toHaveBeenCalledTimes(2);
+      expect(scheduledWork.readState()?.schedules[0]?.status).toBe("scheduled");
+      service.forceDisposeAll();
+    });
+
+    it("defers provider and ADE-action schedules while their live session is busy", async () => {
+      vi.useFakeTimers();
+      vi.setSystemTime(SCHEDULE_TEST_START);
+      const scheduledWork = createScheduledWorkDb();
+      const events: AgentChatEventEnvelope[] = [];
+      let streamCall = 0;
+      let releaseBusySend!: () => void;
+      const busySendGate = new Promise((resolve) => { releaseBusySend = resolve; });
+      const send = vi.fn(async () => { await busySendGate; });
+      const stream = vi.fn(() => (async function* () {
+        streamCall += 1;
+        if (streamCall === 1) {
+          yield { type: "system", subtype: "init", session_id: "sdk-busy-backstop", slash_commands: [] };
+          return;
+        }
+        yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } };
+      })());
+      vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({
+        send,
+        stream,
+        close: vi.fn(),
+        sessionId: "sdk-busy-backstop",
+        setPermissionMode: vi.fn().mockResolvedValue(undefined),
+      } as any);
+      const { service } = createService({
+        db: scheduledWork.db,
+        onEvent: (event: AgentChatEventEnvelope) => events.push(event),
+      });
+      const session = await service.createSession({
+        laneId: "lane-1",
+        provider: "claude",
+        model: "sonnet",
+      });
+      const foregroundTurn = service.runSessionTurn({
+        sessionId: session.id,
+        text: "Keep working through the backstop deadline.",
+      });
+      for (let index = 0; index < 100 && !send.mock.calls.length; index += 1) {
+        await vi.advanceTimersByTimeAsync(1);
+      }
+      expect(send).toHaveBeenCalled();
+      expect(service.hasActiveWorkloads()).toBe(true);
+      const options = vi.mocked(claudeSdkCreateSessionCompat).mock.calls.at(-1)?.[0] as {
+        hooks?: Record Promise> }>>;
+      } | undefined;
+      await options?.hooks?.PostToolUse?.[0]?.hooks[0]?.({
+        hook_event_name: "PostToolUse",
+        session_id: "sdk-busy-backstop",
+        tool_name: "ScheduleWakeup",
+        tool_use_id: "tool-busy-backstop",
+        tool_input: {
+          delaySeconds: 60,
+          reason: "Check PR CI",
+          prompt: "Check PR CI after the foreground turn.",
+        },
+        tool_response: {
+          scheduledFor: SCHEDULE_TEST_START,
+          clampedDelaySeconds: 60,
+          wasClamped: false,
+        },
+      });
+      const actionSchedule = await service.createScheduledWork({
+        sessionId: session.id,
+        cron: "1 * * * *",
+        prompt: "Run the ADE-owned follow-up after the foreground turn.",
+        recurring: false,
+      });
+      await vi.advanceTimersByTimeAsync(90_000);
+
+      expect(scheduledWork.readState()?.schedules.find((item) => item.id === `wakeup:${session.id}`)).toEqual(expect.objectContaining({
+        status: "scheduled",
+        fireAt: SCHEDULE_TEST_START,
+        lateFlag: false,
+      }));
+      expect(scheduledWork.readState()?.schedules.find((item) => item.id === actionSchedule.item.id)).toEqual(expect.objectContaining({
+        status: "scheduled",
+        fireAt: SCHEDULE_TEST_START + 60_000,
+      }));
+      expect(scheduledWork.readState()?.schedules.every((item) => item.lastFiredAt == null)).toBe(true);
+      expect(events.some((event) =>
+        event.sessionId === session.id
+        && event.event.type === "user_message"
+        && event.event.metadata?.scheduledWake != null
+      )).toBe(false);
+
+      releaseBusySend();
+      await vi.advanceTimersByTimeAsync(0);
+      await foregroundTurn;
+      await vi.advanceTimersByTimeAsync(20_000);
+      expect(events.some((event) =>
+        event.sessionId === session.id
+        && event.event.type === "user_message"
+        && event.event.metadata?.scheduledWake?.scheduleId === actionSchedule.item.id
+      )).toBe(true);
+      service.forceDisposeAll();
+    });
+
     it("projects the next durable wake onto the chat session summary", async () => {
       const before = Date.now();
       let streamCall = 0;
@@ -14076,7 +14371,7 @@ describe("createAgentChatService", () => {
       } finally {
         dateNow.mockRestore();
       }
-      expect(scheduledWork.readState()?.schedules[0]?.expiresAt).toBeGreaterThan(initialExpiresAt ?? 0);
+      expect(scheduledWork.readState()?.schedules[0]?.expiresAt).toBe(initialExpiresAt);
 
       await service.runSessionTurn({
         sessionId: session.id,
@@ -14832,6 +15127,7 @@ describe("createAgentChatService", () => {
     });
 
     it("does not create task-id scheduled rows for ambiguous parentless cron runs", async () => {
+      const scheduledWork = createScheduledWorkDb();
       const events: AgentChatEventEnvelope[] = [];
       const setPermissionMode = vi.fn().mockResolvedValue(undefined);
       const send = vi.fn().mockResolvedValue(undefined);
@@ -14895,6 +15191,7 @@ describe("createAgentChatService", () => {
       } as any);
 
       const { service } = createService({
+        db: scheduledWork.db,
         onEvent: (event: AgentChatEventEnvelope) => events.push(event),
       });
       const session = await service.createSession({
@@ -14912,6 +15209,25 @@ describe("createAgentChatService", () => {
         text: "Schedule two recurring crons.",
       });
 
+      vi.useFakeTimers();
+      vi.setSystemTime(SCHEDULE_TEST_START);
+      const postToolUseHook = opts?.hooks?.PostToolUse?.[0]?.hooks[0];
+      await postToolUseHook?.({
+        hook_event_name: "PostToolUse",
+        session_id: "sdk-cron-parentless-ambiguous",
+        tool_name: "CronCreate",
+        tool_use_id: "tool-cron-ambiguous-ci",
+        tool_input: { cron: "*/15 * * * *", prompt: "Check CI status." },
+        tool_response: { id: "cron-provider-ambiguous-ci", recurring: true },
+      });
+      await postToolUseHook?.({
+        hook_event_name: "PostToolUse",
+        session_id: "sdk-cron-parentless-ambiguous",
+        tool_name: "CronCreate",
+        tool_use_id: "tool-cron-ambiguous-review",
+        tool_input: { cron: "*/20 * * * *", prompt: "Review issue comments." },
+        tool_response: { id: "cron-provider-ambiguous-review", recurring: true },
+      });
       await stopHook?.({
         hook_event_name: "Stop",
         session_crons: [
@@ -14930,14 +15246,20 @@ describe("createAgentChatService", () => {
         ],
       });
 
+      await vi.advanceTimersByTimeAsync(15 * 60_000);
       startCronRun();
-      await waitForEvent(
-        events,
-        (event): event is AgentChatEventEnvelope =>
-          event.sessionId === session.id
-          && event.event.type === "subagent_result"
-          && event.event.taskId === "cron-run-task-ambiguous",
-      );
+      for (let index = 0; index < 100 && !events.some((event) =>
+        event.sessionId === session.id
+        && event.event.type === "subagent_result"
+        && event.event.taskId === "cron-run-task-ambiguous"
+      ); index += 1) {
+        await vi.advanceTimersByTimeAsync(1);
+      }
+      expect(events.some((event) =>
+        event.sessionId === session.id
+        && event.event.type === "subagent_result"
+        && event.event.taskId === "cron-run-task-ambiguous"
+      )).toBe(true);
 
       const scheduledEvents = events
         .filter((event): event is AgentChatEventEnvelope & {
@@ -14946,15 +15268,178 @@ describe("createAgentChatService", () => {
           event.sessionId === session.id
           && event.event.type === "scheduled_work_update"
           && event.event.kind === "cron");
-      expect(scheduledEvents.map((event) => event.event.id)).toEqual([
-        "cron-provider-ambiguous-ci",
-        "cron-provider-ambiguous-review",
-      ]);
       expect(scheduledEvents.map((event) => event.event.id)).not.toContain("cron-run-task-ambiguous");
+      expect(scheduledEvents.every((event) =>
+        event.event.id === "cron-provider-ambiguous-ci"
+        || event.event.id === "cron-provider-ambiguous-review"
+      )).toBe(true);
+
+      const scheduledWakeMessages = events.filter((event) =>
+        event.sessionId === session.id
+        && event.event.type === "user_message"
+        && event.event.metadata?.scheduledWake != null
+      );
+      expect(scheduledWakeMessages).toHaveLength(1);
+      expect(scheduledWakeMessages[0]?.event).toMatchObject({
+        type: "user_message",
+        metadata: {
+          scheduledWake: { scheduleId: "cron-provider-ambiguous-ci" },
+        },
+      });
 
       const snapshots = deriveScheduledWorkSnapshots(events);
       expect(snapshots).toHaveLength(2);
       expect(snapshots.map((snapshot) => snapshot.status).sort()).toEqual(["scheduled", "scheduled"]);
+      expect(scheduledWork.readState()?.schedules.find((item) =>
+        item.id === "cron-provider-ambiguous-ci"
+      )?.lastFiredAt).toEqual(expect.any(Number));
+      service.forceDisposeAll();
+    });
+
+    it("claims a native cron when unrelated idle output already opened the turn", async () => {
+      const scheduledWork = createScheduledWorkDb();
+      const events: AgentChatEventEnvelope[] = [];
+      let streamCall = 0;
+      let releaseIdle!: () => void;
+      const idleGate = new Promise((resolve) => { releaseIdle = resolve; });
+      let releaseCron!: () => void;
+      const cronGate = new Promise((resolve) => { releaseCron = resolve; });
+      const stream = vi.fn(() => (async function* () {
+        streamCall += 1;
+        if (streamCall === 1) {
+          yield {
+            type: "system",
+            subtype: "init",
+            session_id: "sdk-cron-existing-idle-turn",
+            slash_commands: [],
+          };
+          return;
+        }
+
+        yield {
+          type: "result",
+          subtype: "success",
+          is_error: false,
+          session_id: "sdk-cron-existing-idle-turn",
+          usage: { input_tokens: 1, output_tokens: 1 },
+        };
+        await idleGate;
+        yield {
+          type: "tool_progress",
+          tool_name: "BackgroundTask",
+          elapsed_time_seconds: 1,
+        };
+        await cronGate;
+        yield {
+          type: "system",
+          subtype: "task_started",
+          session_id: "sdk-cron-existing-idle-turn",
+          task_id: "cron-existing-idle-turn-task",
+          task_type: "cron",
+          description: "Check CI status.",
+        };
+        yield {
+          type: "system",
+          subtype: "task_updated",
+          session_id: "sdk-cron-existing-idle-turn",
+          task_id: "cron-existing-idle-turn-task",
+          task_type: "cron",
+          patch: { status: "completed" },
+          summary: "CI passed.",
+        };
+        yield {
+          type: "result",
+          subtype: "success",
+          is_error: false,
+          session_id: "sdk-cron-existing-idle-turn",
+          usage: { input_tokens: 2, output_tokens: 2 },
+        };
+      })());
+
+      vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({
+        send: vi.fn().mockResolvedValue(undefined),
+        stream,
+        close: vi.fn(),
+        sessionId: "sdk-cron-existing-idle-turn",
+        setPermissionMode: vi.fn().mockResolvedValue(undefined),
+      } as any);
+
+      const { service } = createService({
+        db: scheduledWork.db,
+        onEvent: (event: AgentChatEventEnvelope) => events.push(event),
+      });
+      const session = await service.createSession({
+        laneId: "lane-1",
+        provider: "claude",
+        model: "sonnet",
+      });
+      const options = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as {
+        hooks?: Record Promise> }>>;
+      } | undefined;
+      const postToolUseHook = options?.hooks?.PostToolUse?.[0]?.hooks[0];
+
+      await service.runSessionTurn({
+        sessionId: session.id,
+        text: "Keep watching CI in the background.",
+      });
+      vi.useFakeTimers();
+      vi.setSystemTime(SCHEDULE_TEST_START);
+      await postToolUseHook?.({
+        hook_event_name: "PostToolUse",
+        session_id: "sdk-cron-existing-idle-turn",
+        tool_name: "CronCreate",
+        tool_use_id: "tool-cron-existing-idle-turn",
+        tool_input: { cron: "*/15 * * * *", prompt: "Check CI status." },
+        tool_response: { id: "cron-provider-existing-idle-turn", recurring: true },
+      });
+      await options?.hooks?.Stop?.[0]?.hooks[0]?.({
+        hook_event_name: "Stop",
+        session_crons: [{
+          id: "cron-provider-existing-idle-turn",
+          schedule: "*/15 * * * *",
+          prompt: "Check CI status.",
+          recurring: true,
+        }],
+      });
+      releaseIdle();
+      for (let index = 0; index < 100 && !events.some((event) =>
+        event.sessionId === session.id
+        && event.event.type === "activity"
+        && event.event.detail === "Tool 'BackgroundTask' running (1s)"
+      ); index += 1) {
+        await vi.advanceTimersByTimeAsync(1);
+      }
+      const idleActivity = events.find((event) =>
+        event.sessionId === session.id
+        && event.event.type === "activity"
+        && event.event.detail === "Tool 'BackgroundTask' running (1s)"
+      );
+      expect(idleActivity?.event.turnId).toBeTruthy();
+
+      await vi.advanceTimersByTimeAsync(15 * 60_000);
+      releaseCron();
+      for (let index = 0; index < 100 && !events.some((event) =>
+        event.sessionId === session.id
+        && event.event.type === "user_message"
+        && event.event.metadata?.scheduledWake?.scheduleId === "cron-provider-existing-idle-turn"
+      ); index += 1) {
+        await vi.advanceTimersByTimeAsync(1);
+      }
+
+      const scheduledWake = events.find((event) =>
+        event.sessionId === session.id
+        && event.event.type === "user_message"
+        && event.event.metadata?.scheduledWake?.scheduleId === "cron-provider-existing-idle-turn"
+      );
+      expect(scheduledWake?.event).toMatchObject({
+        type: "user_message",
+        turnId: idleActivity?.event.turnId,
+      });
+      const startedTurnIds = events
+        .filter((event) => event.sessionId === session.id && event.event.type === "status" && event.event.turnStatus === "started")
+        .map((event) => event.event.turnId);
+      expect(new Set(startedTurnIds).size).toBe(2);
+      service.forceDisposeAll();
     });
 
     it("does not create task-id scheduled rows for parentless cron runs when aliases are empty", async () => {
@@ -15246,18 +15731,17 @@ describe("createAgentChatService", () => {
       service.forceDisposeAll();
     });
 
-    it("finishSession clears pending native scheduled wakes", async () => {
+    it("injects the overdue prompt when the live Claude native scheduler misses its tick", async () => {
       vi.useFakeTimers();
       vi.setSystemTime(SCHEDULE_TEST_START);
       const scheduledWork = createScheduledWorkDb();
       const events: AgentChatEventEnvelope[] = [];
-      installClaudeWakeupFixture({
-        sdkSessionId: "sdk-pending-native-wake",
+      const { send } = installClaudeWakeupFixture({
+        sdkSessionId: "sdk-backstop-input",
         delaySeconds: 60,
-        prompt: "Stale native wake must not survive dispose.",
-        // Keep the stable SDK query alive after the foreground result so the
-        // scheduler exercises the native pending-wake handoff instead of the
-        // cold-query fallback path.
+        prompt: "Deliver the missed native wake through ADE.",
+        // The provider never emits a native cron turn after the foreground
+        // result. ADE must push a real user message into this live query.
         lingerAfterTurn: new Promise(() => undefined),
       });
       const { service } = createService({
@@ -15271,22 +15755,22 @@ describe("createAgentChatService", () => {
       });
       const foregroundTurn = service.runSessionTurn({
         sessionId: session.id,
-        text: "Queue a native wake.",
+        text: "Schedule a native wake.",
       });
       await vi.advanceTimersByTimeAsync(1_000);
       await foregroundTurn;
+      const sendsBeforeBackstop = send.mock.calls.length;
       await vi.advanceTimersByTimeAsync(149_000);
       expect(scheduledWork.readState()?.schedules[0]?.status).toBe("fired");
-      expect(service.pendingNativeScheduledWakeCountForTesting(session.id)).toBe(1);
-
-      await service.dispose({ sessionId: session.id });
-
-      expect(service.pendingNativeScheduledWakeCountForTesting(session.id)).toBe(0);
+      expect(send).toHaveBeenCalledTimes(sendsBeforeBackstop + 1);
       expect(events.some((event) =>
         event.sessionId === session.id
         && event.event.type === "user_message"
-        && event.event.metadata?.scheduledWake?.reason === "Stale native wake must not survive dispose."
-      )).toBe(false);
+        && event.event.metadata?.scheduledWake?.reason === "Check PR CI"
+        && event.event.text === "Deliver the missed native wake through ADE."
+      )).toBe(true);
+
+      await service.dispose({ sessionId: session.id });
       service.forceDisposeAll();
     });
 
@@ -27991,7 +28475,7 @@ describe("createAgentChatService", () => {
         },
       });
       expect(childCompletion).toMatchObject({
-        routedAction: "steer",
+        routedAction: "sendMessage",
         delivery: "queued",
         queued: true,
       });
@@ -28008,6 +28492,70 @@ describe("createAgentChatService", () => {
       expect(send).toHaveBeenCalledWith(expect.stringContaining("Check PR CI"));
     });
 
+    it("queues an active Codex wake and dispatches it as a new turn at the boundary", async () => {
+      const events: AgentChatEventEnvelope[] = [];
+      const { service } = createService({
+        onEvent: (event: AgentChatEventEnvelope) => events.push(event),
+      });
+      const session = await service.createSession({
+        laneId: "lane-1",
+        provider: "codex",
+        model: "gpt-5.4-codex",
+      });
+      await service.sendMessage({
+        sessionId: session.id,
+        text: "Finish the foreground work.",
+      }, { awaitDispatch: true });
+
+      const result = await service.messageSession({
+        sessionId: session.id,
+        text: "Check PR CI after the current turn.",
+        kind: "wake",
+        metadata: {
+          scheduledWake: {
+            scheduleId: "codex-wake-boundary-1",
+            kind: "wakeup",
+            firedAt: "2026-07-09T09:00:00.000Z",
+            reason: "Check PR CI",
+          },
+        },
+      });
+
+      expect(result).toMatchObject({
+        routedAction: "sendMessage",
+        delivery: "queued",
+        queued: true,
+      });
+      expect(mockState.codexRequestPayloads.filter((payload) => payload.method === "turn/start")).toHaveLength(1);
+      expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/steer")).toBe(false);
+
+      mockState.emitCodexPayload({
+        method: "turn/completed",
+        params: { turn: { id: "turn-1", status: "completed" } },
+      });
+      await vi.waitFor(() => {
+        expect(mockState.codexRequestPayloads.filter((payload) => payload.method === "turn/start")).toHaveLength(2);
+      });
+
+      const turnStarts = mockState.codexRequestPayloads.filter((payload) => payload.method === "turn/start");
+      expect(turnStarts[1]?.params).toEqual(expect.objectContaining({
+        input: expect.arrayContaining([
+          expect.objectContaining({ text: expect.stringContaining("Check PR CI after the current turn.") }),
+        ]),
+      }));
+      expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/steer")).toBe(false);
+      expect(events).toEqual(expect.arrayContaining([
+        expect.objectContaining({
+          event: expect.objectContaining({
+            type: "user_message",
+            metadata: expect.objectContaining({
+              scheduledWake: expect.objectContaining({ scheduleId: "codex-wake-boundary-1" }),
+            }),
+          }),
+        }),
+      ]));
+    });
+
     it("throws when steering an unknown session", async () => {
       const { service } = createService();
       await expect(
diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts
index e36f9f716..8e007ef84 100644
--- a/apps/desktop/src/main/services/chat/agentChatService.ts
+++ b/apps/desktop/src/main/services/chat/agentChatService.ts
@@ -75,6 +75,7 @@ import {
   type ChatScheduledWorkRecord,
   type ChatScheduledWorkScheduler,
   type ChatScheduledWorkStatus,
+  type ChatScheduledWorkUpsert,
 } from "./chatScheduledWorkScheduler";
 import {
   drainRunningClaudeWorkflowAgents,
@@ -142,6 +143,7 @@ import {
 } from "./threadPointerLedger";
 import type { createFileService } from "../files/fileService";
 import type { createProcessService } from "../processes/processService";
+import type { createPtyService } from "../pty/ptyService";
 import type { DiskPressureMonitor, DiskPressureState } from "../storage/diskPressure";
 import {
   readHistoryFileSync,
@@ -178,6 +180,7 @@ import type {
   AgentChatCancelSteerArgs,
   AgentChatCreateScheduledWorkArgs,
   AgentChatCreateScheduledWorkResult,
+  AgentChatGetScheduledWorkStateArgs,
   AgentChatClaudeOutputStyle,
   AgentChatClaudeOutputStylesArgs,
   AgentChatClaudePlugin,
@@ -262,6 +265,7 @@ import type {
   AgentChatScheduledWorkItem,
   AgentChatSetScheduledWorkPausedArgs,
   AgentChatSetScheduledWorkPausedResult,
+  AgentChatScheduledWorkState,
   AgentChatSlashCommand,
   AgentChatSlashCommandsArgs,
   AgentChatKillDroidWorkerArgs,
@@ -313,7 +317,11 @@ import type {
   LaneLinearIssue,
   SessionLinearIssueLink,
 } from "../../../shared/types";
-import { providerSupportsHandoffFork } from "../../../shared/types";
+import {
+  isPtySendPreDeliveryError,
+  isTrackedAgentCliToolType,
+  providerSupportsHandoffFork,
+} from "../../../shared/types";
 import { providerDisplayLabel } from "../../../shared/pendingInputLabels";
 import {
   CROSS_MACHINE_FORK_BRIEF_STUB,
@@ -1120,6 +1128,7 @@ type CodexRuntime = {
     turnId: string | null;
     followupText: string;
   }>;
+  pendingSteers: QueuedSteer[];
   request: (method: string, params?: unknown, options?: CodexRequestOptions) => Promise;
   notify: (method: string, params?: unknown) => void;
   sendResponse: (id: string | number, result: unknown) => void;
@@ -3544,6 +3553,16 @@ function isChatToolType(
   return toolType != null && CHAT_SESSION_TOOL_TYPES.includes(toolType as ChatSessionToolType);
 }
 
+function isSchedulableAgentSession(
+  session: {
+    toolType: TerminalToolType | null | undefined;
+    tracked: boolean;
+  },
+): boolean {
+  return isChatToolType(session.toolType)
+    || (session.tracked && isTrackedAgentCliToolType(session.toolType));
+}
+
 function providerFromToolType(toolType: TerminalToolType | null | undefined): AgentChatProvider {
   if (toolType === "opencode-chat") return "opencode";
   if (toolType === "claude-chat") return "claude";
@@ -6345,7 +6364,10 @@ export function createAgentChatService(args: {
   processService?: ReturnType | null;
   diskPressureMonitor?: DiskPressureMonitor | null;
   getTestService?: () => { listSuites: () => any[]; run: (args: any) => Promise; stop: (args: any) => void; listRuns: (args?: any) => any[]; getLogTail: (args: any) => string } | null;
-  ptyService?: { create: (args: any) => Promise<{ ptyId: string; sessionId: string }> } | null;
+  ptyService?: Pick<
+    ReturnType,
+    "create" | "sendToSession" | "enrichSessions" | "canAcceptScheduledTurn"
+  > | null;
   getAutomationService?: () => { list: () => any[]; triggerManually: (args: any) => Promise; listRuns: (args?: any) => any[] } | null;
   getGitService?: () => CtoOperatorToolDeps["gitService"];
   conflictService?: CtoOperatorToolDeps["conflictService"];
@@ -6980,15 +7002,6 @@ export function createAgentChatService(args: {
   let scheduledWorkScheduler: ChatScheduledWorkScheduler | null = null;
   let scheduledWorkReady: Promise = Promise.resolve();
   const durableScheduleUiStatusById = new Map();
-  type PendingNativeScheduledWake = {
-    scheduleId: string;
-    kind: "wakeup" | "cron" | "loop";
-    prompt: string;
-    reason?: string;
-    firedAt: string;
-    late: boolean;
-  };
-  const pendingNativeScheduledWakeBySession = new Map();
 
   const runScheduledWorkMutation = (operation: string, mutation: Promise): void => {
     void mutation.catch((error) => {
@@ -12471,6 +12484,39 @@ export function createAgentChatService(args: {
       || normalized.startsWith("/loop ");
   };
 
+  const buildClaudeScheduleUpsert = (input: {
+    id: string;
+    sessionId: string;
+    kind: "wakeup" | "cron" | "loop";
+    prompt: string;
+    reason?: string;
+    cron?: string;
+    fireAt?: number;
+    createdAt?: number;
+    recurring: boolean;
+    expiresAtBase?: number;
+    providerSessionId?: string;
+    providerScheduleId?: string;
+  }): ChatScheduledWorkUpsert => ({
+    id: input.id,
+    sessionId: input.sessionId,
+    kind: input.kind,
+    prompt: input.prompt,
+    ...(input.reason ? { reason: input.reason } : {}),
+    ...(input.cron ? { cron: input.cron } : {}),
+    ...(Object.prototype.hasOwnProperty.call(input, "fireAt") ? { fireAt: input.fireAt } : {}),
+    ...(input.createdAt != null ? { createdAt: input.createdAt } : {}),
+    ...(input.recurring && input.expiresAtBase != null
+      ? { expiresAt: input.expiresAtBase + claudeRecurringCronTtlMs }
+      : {}),
+    status: "scheduled",
+    lateFlag: false,
+    durable: true,
+    provider: "claude",
+    ...(input.providerSessionId ? { providerSessionId: input.providerSessionId } : {}),
+    ...(input.providerScheduleId ? { providerScheduleId: input.providerScheduleId } : {}),
+  });
+
   const nextClaudeWakeupId = (
     sessionId: string,
     sourceId: string,
@@ -12667,19 +12713,16 @@ export function createAgentChatService(args: {
         await Promise.all(activeWakeups.map((schedule) =>
           scheduledWorkScheduler!.cancel(schedule.id)));
       } else {
-        await scheduledWorkScheduler.upsert({
+        await scheduledWorkScheduler.upsert(buildClaudeScheduleUpsert({
           id: wakeupId,
           sessionId: managed.session.id,
           kind,
           prompt,
           ...(compactString(args.reason) ? { reason: compactString(args.reason) } : {}),
           fireAt,
-          status: "scheduled",
-          lateFlag: false,
-          durable: true,
-          provider: "claude",
+          recurring: false,
           ...(providerSessionId ? { providerSessionId } : {}),
-        });
+        }));
       }
       return;
     }
@@ -12714,11 +12757,11 @@ export function createAgentChatService(args: {
       });
       if (!scheduledWorkScheduler) return;
       // ADE state wins, SDK view is advisory: every successful provider cron
-      // is mirrored durably even though Claude's tool schema says its durable
-      // input has no effect and its in-process CronList may drift.
+      // is mirrored durably whether or not Claude also persists its provider
+      // copy with durable: true; the SDK's CronList may drift after restarts.
       durableScheduleUiStatusById.set(id, "scheduled");
       const createdAt = Date.now();
-      await scheduledWorkScheduler.upsert({
+      await scheduledWorkScheduler.upsert(buildClaudeScheduleUpsert({
         id,
         sessionId: managed.session.id,
         kind,
@@ -12726,14 +12769,11 @@ export function createAgentChatService(args: {
         cron,
         fireAt,
         createdAt,
-        ...(recurring ? { expiresAt: createdAt + claudeRecurringCronTtlMs } : {}),
-        status: "scheduled",
-        lateFlag: false,
-        durable: true,
-        provider: "claude",
+        recurring,
+        expiresAtBase: createdAt,
         ...(providerSessionId ? { providerSessionId } : {}),
         providerScheduleId: id,
-      });
+      }));
       return;
     }
 
@@ -12889,7 +12929,11 @@ export function createAgentChatService(args: {
         && durableSchedule.status !== "done"
         && durableSchedule.status !== "cancelled"
       ) {
-        await scheduledWorkScheduler.upsert({
+        // Stop/SubagentStop is an inventory snapshot, not a reschedule event.
+        // Preserve the original occurrence and seven-day creation TTL so an
+        // overdue tick cannot be skipped and repeated snapshots cannot extend
+        // the provider job indefinitely.
+        await scheduledWorkScheduler.upsert(buildClaudeScheduleUpsert({
           id: scheduledWorkId,
           sessionId: managed.session.id,
           kind,
@@ -12897,17 +12941,10 @@ export function createAgentChatService(args: {
           // the full PostToolUse prompt already stored under the canonical id.
           prompt: durableSchedule.prompt,
           ...(cronSchedule ? { cron: cronSchedule } : {}),
-          ...(cronSchedule
-            ? { fireAt: nextChatScheduledCronFireAt(cronSchedule, Date.now()) ?? undefined }
-            : {}),
-          ...(recurring ? { expiresAt: Date.now() + claudeRecurringCronTtlMs } : {}),
-          status: "scheduled",
-          lateFlag: false,
-          durable: true,
-          provider: "claude",
+          recurring,
           ...(providerSessionId ? { providerSessionId } : {}),
           providerScheduleId: id,
-        });
+        }));
       }
     }
     if (hasSessionCrons && scheduledWorkScheduler) {
@@ -14132,7 +14169,6 @@ export function createAgentChatService(args: {
     status: TerminalSessionStatus,
     options?: { exitCode?: number | null; summary?: string | null }
   ): Promise => {
-    pendingNativeScheduledWakeBySession.delete(managed.session.id);
     if (managed.endedNotified) return;
     managed.endedNotified = true;
     clearSubagentSnapshots(managed.session.id);
@@ -15225,22 +15261,20 @@ export function createAgentChatService(args: {
     runtime: ClaudeRuntime,
     state: ClaudeIdleTurnState,
     detail = "Claude resumed background work",
+    nativeScheduleId?: string,
+    explicitNativeCron = false,
   ): string => {
-    if (state.turnId) return state.turnId;
-    const turnId = `claude-idle-${randomUUID()}`;
-    state.turnId = turnId;
-    captureTurnBeforeSha(managed);
-    runtime.interrupted = false;
-    runtime.interruptEventsEmitted = false;
-    runtime.busy = true;
-    runtime.activeTurnId = turnId;
-    setSessionActive(managed);
-    const pendingScheduledWakes = pendingNativeScheduledWakeBySession.get(managed.session.id) ?? [];
-    const pendingScheduledWake = pendingScheduledWakes[0];
-    const claimedSchedule = !pendingScheduledWake
-      ? scheduledWorkScheduler?.claimNativeFire(managed.session.id, turnId) ?? null
+    const existingTurnId = state.turnId;
+    const turnId = existingTurnId ?? `claude-idle-${randomUUID()}`;
+    // Only an explicit native cron task may claim the provider-first window.
+    // Unrelated background/subagent output also arrives through this idle
+    // reader and must never consume a due schedule by proximity alone. When
+    // older SDK task events omit the provider id, the explicit cron task type
+    // still safely claims the earliest due Claude row.
+    const claimedSchedule = explicitNativeCron
+      ? scheduledWorkScheduler?.claimNativeFire(managed.session.id, turnId, nativeScheduleId) ?? null
       : null;
-    const scheduledWake = pendingScheduledWake ?? (claimedSchedule
+    const scheduledWake = claimedSchedule
       ? {
           scheduleId: claimedSchedule.id,
           kind: claimedSchedule.kind,
@@ -15249,16 +15283,8 @@ export function createAgentChatService(args: {
           firedAt: new Date(claimedSchedule.lastFiredAt ?? Date.now()).toISOString(),
           late: claimedSchedule.lateFlag,
         }
-      : null);
+      : null;
     if (scheduledWake) {
-      if (pendingScheduledWake) {
-        pendingScheduledWakes.shift();
-        if (pendingScheduledWakes.length) {
-          pendingNativeScheduledWakeBySession.set(managed.session.id, pendingScheduledWakes);
-        } else {
-          pendingNativeScheduledWakeBySession.delete(managed.session.id);
-        }
-      }
       state.scheduledWakeAttached = true;
       emitChatEvent(managed, {
         type: "user_message",
@@ -15275,6 +15301,14 @@ export function createAgentChatService(args: {
         turnId,
       });
     }
+    if (existingTurnId) return existingTurnId;
+    state.turnId = turnId;
+    captureTurnBeforeSha(managed);
+    runtime.interrupted = false;
+    runtime.interruptEventsEmitted = false;
+    runtime.busy = true;
+    runtime.activeTurnId = turnId;
+    setSessionActive(managed);
     emitChatEvent(managed, { type: "status", turnStatus: "started", turnId });
     emitChatEvent(managed, {
       type: "activity",
@@ -15411,10 +15445,24 @@ export function createAgentChatService(args: {
     const command = compactString(msg.command) ?? existing?.command;
     const description = compactString(msg.description) ?? existing?.description ?? "Background task";
     const backgroundShell = classification.backgroundShell;
+    const nativeCronScheduleId = taskType === "cron"
+      ? resolveClaudeCronScheduledWorkId(runtime, taskId, parentToolUseId, msg)
+      : null;
+    const explicitNativeCron = subtype === "task_started" && taskType === "cron";
     // Progress from a task that outlives its parent turn must not manufacture a
-    // new main-thread turn. A genuine parent assistant frame (normally the
-    // completion wake-up) will start the next idle turn when it arrives.
-    const turnId = state.turnId ?? undefined;
+    // new main-thread turn. A native cron start is the exception: it is the
+    // provider's explicit ownership signal for a scheduled turn. Generic idle
+    // assistant/tool output is not sufficient evidence to claim a schedule.
+    const turnId = explicitNativeCron
+      ? startClaudeIdleTurn(
+          managed,
+          runtime,
+          state,
+          "Claude resumed scheduled work",
+          nativeCronScheduleId ?? undefined,
+          true,
+        )
+      : state.turnId ?? undefined;
     // Background shell commands are tracked as background scheduled_work rows,
     // not subagents — mirror the main-loop gating in every idle subtype.
     if (backgroundShell) {
@@ -15522,7 +15570,7 @@ export function createAgentChatService(args: {
         ...(workflowName ? { workflowName } : {}),
       });
       if (taskType === "cron") {
-        const scheduledWorkId = resolveClaudeCronScheduledWorkId(runtime, taskId, parentToolUseId, msg);
+        const scheduledWorkId = nativeCronScheduleId;
         if (scheduledWorkId) {
           emitClaudeScheduledWorkUpdate(managed, runtime, {
             type: "scheduled_work_update",
@@ -15568,7 +15616,7 @@ export function createAgentChatService(args: {
       if (notificationAgentId) runtime.activeSubagents.delete(notificationAgentId);
       if (parentToolUseId) runtime.taskToolInputByToolUseId.delete(parentToolUseId);
       if (taskType === "cron") {
-        const scheduledWorkId = resolveClaudeCronScheduledWorkId(runtime, taskId, parentToolUseId, msg);
+        const scheduledWorkId = nativeCronScheduleId;
         if (scheduledWorkId) {
           emitClaudeScheduledWorkUpdate(managed, runtime, {
             type: "scheduled_work_update",
@@ -15633,7 +15681,7 @@ export function createAgentChatService(args: {
       if (parentToolUseId) runtime.taskToolInputByToolUseId.delete(parentToolUseId);
       const finalStatus = status === "completed" ? "completed" : status === "killed" ? "stopped" : "failed";
       if (taskType === "cron") {
-        const scheduledWorkId = resolveClaudeCronScheduledWorkId(runtime, taskId, parentToolUseId, msg);
+        const scheduledWorkId = nativeCronScheduleId;
         if (scheduledWorkId) {
           emitClaudeScheduledWorkUpdate(managed, runtime, {
             type: "scheduled_work_update",
@@ -16203,7 +16251,7 @@ export function createAgentChatService(args: {
   const startClaudeIdleReader = (
     managed: ManagedChatSession,
     runtime: ClaudeRuntime,
-    reason: "turn_completed" | "query_ready" | "durable_schedule_fire" = "turn_completed",
+    reason: "turn_completed" | "query_ready" = "turn_completed",
   ): void => {
     if (managed.closed || managed.deleted) return;
     if (!runtime.query || !runtime.inputPump) return;
@@ -22952,6 +23000,9 @@ export function createAgentChatService(args: {
       sessionService.setHeadShaEnd(managed.session.id, endSha);
     }
     persistChatState(managed);
+    if (runtime.pendingSteers.length && managed.runtime === runtime) {
+      await deliverNextQueuedSteer(managed, runtime);
+    }
   }
 
   function isCodexSilentTurnStillCurrent(
@@ -23399,6 +23450,9 @@ export function createAgentChatService(args: {
       }
 
       persistChatState(managed);
+      if (runtime.pendingSteers.length && managed.runtime === runtime) {
+        await deliverNextQueuedSteer(managed, runtime);
+      }
       return;
     }
 
@@ -23659,6 +23713,9 @@ export function createAgentChatService(args: {
         ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}),
       });
       persistChatState(managed);
+      if (runtime.pendingSteers.length && managed.runtime === runtime) {
+        await deliverNextQueuedSteer(managed, runtime);
+      }
       return;
     }
 
@@ -24005,6 +24062,7 @@ export function createAgentChatService(args: {
       stallReconcileInFlight: new Set(),
       mcpStartupNoticeKeys: new Set(),
       pendingPlanFollowups: [],
+      pendingSteers: [],
       slashCommands: [],
       rateLimits: null,
       collaborationModes: null,
@@ -24750,7 +24808,7 @@ export function createAgentChatService(args: {
         append: [
           "## Runtime Environment",
           "**Runtime:** ADE Work chat hosted on the Claude Agent SDK stable `query()` streaming-input API. The `claude_code` preset above is the same system prompt the Claude Code CLI uses, so you may think you're in the CLI — you are NOT. You are inside an ADE-hosted SDK session.",
-            "**Wake-up semantics:** Native `ScheduleWakeup`, `CronCreate`, and `/loop` are automatically backed by ADE's durable scheduler; the `durable` flag is not needed. They survive brain restarts and start a new turn at the next turn boundary even if the chat was busy when they became due. The SDK's own `CronList` view is advisory; ADE state wins. Pause schedules in Chat Info or project-wide in Settings. Recurring jobs expire after seven days. `CronCreate` always creates a new job, so replace one with `CronList` + `CronDelete` before creating another.",
+            "**Wake-up semantics:** Native `ScheduleWakeup`, `CronCreate`, and `/loop` are automatically mirrored into ADE's durable scheduler. `durable: true` also persists Claude's provider copy, while ADE's delivery guarantee does not depend on that flag. Jobs survive brain restarts and start a new turn at the next turn boundary even if the chat was busy when they became due. The SDK's own `CronList` view is advisory; ADE state wins. Pause schedules in Chat Info or project-wide in Settings. Recurring jobs expire seven days after creation. `CronCreate` always creates a new job, so replace one with `CronList` + `CronDelete` before creating another.",
             "**To wait:** For short bounded waits inside the current turn, a foreground command such as `sleep ... && ` is fine. For longer waits or autonomous follow-up, prefer `ScheduleWakeup`, `CronCreate`, or `/loop` and include a concise reason/prompt so ADE can show the pending work clearly.",
           "",
           "## ADE Workspace",
@@ -25183,7 +25241,7 @@ export function createAgentChatService(args: {
 
   const deliverNextQueuedSteer = async (
     managed: ManagedChatSession,
-    runtime: ClaudeRuntime | OpenCodeRuntime | CursorRuntime | DroidRuntime,
+    runtime: CodexRuntime | ClaudeRuntime | OpenCodeRuntime | CursorRuntime | DroidRuntime,
   ): Promise => {
     if (managed.closed) return false;
     // A user-selected priority dispatch owns the staged queue while its SDK
@@ -25210,7 +25268,7 @@ export function createAgentChatService(args: {
       turnId: runtime.activeTurnId ?? undefined,
     });
 
-    runtime.interrupted = false;
+    if (runtime.kind !== "codex") runtime.interrupted = false;
     persistChatState(managed);
 
     // Re-resolve lane context so that a lane switch that occurred while the
@@ -25253,7 +25311,18 @@ export function createAgentChatService(args: {
       buildChatContextAttachmentPrompt(nextSteer.contextAttachments) || null,
     ]);
 
-    if (runtime.kind === "claude") {
+    if (runtime.kind === "codex") {
+      await sendCodexMessage(managed, {
+        promptText,
+        userText: trimmed,
+        displayText,
+        attachments: nextSteer.attachments,
+        contextAttachments: nextSteer.contextAttachments,
+        resolvedAttachments: nextSteer.resolvedAttachments,
+        metadata: nextSteer.metadata,
+        laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null,
+      });
+    } else if (runtime.kind === "claude") {
       await runClaudeTurn(managed, {
         promptText,
         userText: trimmed,
@@ -25307,7 +25376,7 @@ export function createAgentChatService(args: {
   /** Enqueue a steer or drop it if the queue is full. Returns true if queued. */
   const enqueueSteerOrDrop = (
     managed: ManagedChatSession,
-    runtime: ClaudeRuntime | OpenCodeRuntime,
+    runtime: ChatRuntime,
     sessionId: string,
     steerId: string,
     text: string,
@@ -32851,13 +32920,11 @@ export function createAgentChatService(args: {
     if (awaitingInputBefore && normalizedKind !== "wake") {
       throw new Error(`${PENDING_INPUT_SEND_BLOCKED_MESSAGE} Use chat.respondToInput for the pending input instead.`);
     }
-    const isScheduledWake = normalizedKind === "wake" && metadata?.scheduledWake != null;
-    const wakeNeedsQueue = isScheduledWake
-      && (managed.runtime?.kind === "claude" || managed.runtime?.kind === "opencode")
-      && (
-        statusBefore === "active"
-        || managed.runtime.busy
-      );
+    const runtimeBusy = managed.runtime?.kind === "codex"
+      ? managed.runtime.activeTurnId != null
+      : managed.runtime?.busy === true;
+    const wakeNeedsQueue = normalizedKind === "wake"
+      && (statusBefore === "active" || runtimeBusy);
 
     const steerTarget =
       normalizedKind === "queue" ||
@@ -32888,7 +32955,7 @@ export function createAgentChatService(args: {
 
     if (wakeNeedsQueue) {
       const runtime = managed.runtime;
-      if (!runtime || (runtime.kind !== "claude" && runtime.kind !== "opencode")) {
+      if (!runtime) {
         throw new Error("Scheduled wake delivery requires an active queueable chat runtime.");
       }
       const preparedWake = prepareSendMessage({
@@ -34475,14 +34542,15 @@ export function createAgentChatService(args: {
     }
 
     const normalizedSessionId = typeof args.sessionId === "string" ? args.sessionId.trim() : "";
-    if (!normalizedSessionId) throw new Error("Chat session id is required.");
+    if (!normalizedSessionId) throw new Error("Agent session id is required.");
     const row = sessionService.get(normalizedSessionId);
-    if (!row || !isChatToolType(row.toolType)) {
-      throw new Error(`Chat session '${normalizedSessionId}' was not found.`);
+    if (!row || !isSchedulableAgentSession(row)) {
+      throw new Error(`Agent session '${normalizedSessionId}' was not found.`);
     }
-    const summary = summarizeSessionRow(row);
-    if (summary.status === "ended" || summary.archivedAt) {
-      throw new Error(`Chat session '${normalizedSessionId}' is ended or archived.`);
+    const chatBacked = isChatToolType(row.toolType);
+    const summary = chatBacked ? summarizeSessionRow(row) : null;
+    if (row.archivedAt || summary?.status === "ended") {
+      throw new Error(`Agent session '${normalizedSessionId}' is ended or archived.`);
     }
 
     const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
@@ -34506,8 +34574,7 @@ export function createAgentChatService(args: {
     const recurring = args.recurring !== false;
     const reason = args.reason?.trim();
     const id = `action:${normalizedSessionId}:${randomUUID()}`;
-    const managed = ensureManagedSession(normalizedSessionId);
-    durableScheduleUiStatusById.set(id, "scheduled");
+    if (chatBacked) ensureManagedSession(normalizedSessionId);
     const schedule = await scheduledWorkScheduler.upsert({
       id,
       sessionId: normalizedSessionId,
@@ -34522,22 +34589,6 @@ export function createAgentChatService(args: {
       lateFlag: false,
       durable: true,
     });
-    emitChatEvent(managed, {
-      type: "scheduled_work_update",
-      id,
-      kind: schedule.kind,
-      status: schedule.status === "done" ? "completed" : schedule.status,
-      origin: "action",
-      title: reason || prompt,
-      prompt,
-      ...(reason ? { reason } : {}),
-      cron,
-      ...((schedule.status === "scheduled" || schedule.status === "paused") && schedule.fireAt != null
-        ? { nextRunAt: new Date(schedule.fireAt).toISOString() }
-        : {}),
-      recurring,
-      durable: true,
-    });
     return { item: toScheduledWorkItem(schedule) };
   };
 
@@ -34549,8 +34600,8 @@ export function createAgentChatService(args: {
     const normalizedSessionId = sessionId?.trim();
     if (normalizedSessionId) {
       const row = sessionService.get(normalizedSessionId);
-      if (!row || !isChatToolType(row.toolType)) {
-        throw new Error(`Chat session '${normalizedSessionId}' was not found.`);
+      if (!row || !isSchedulableAgentSession(row)) {
+        throw new Error(`Agent session '${normalizedSessionId}' was not found.`);
       }
     }
     return (scheduledWorkScheduler?.list(normalizedSessionId) ?? [])
@@ -34558,6 +34609,29 @@ export function createAgentChatService(args: {
       .map(toScheduledWorkItem);
   };
 
+  const getScheduledWorkState = async ({
+    sessionId,
+  }: AgentChatGetScheduledWorkStateArgs): Promise => {
+    const normalizedSessionId = sessionId.trim();
+    if (!normalizedSessionId) throw new Error("Agent session id is required.");
+    const row = sessionService.get(normalizedSessionId);
+    if (!row || !isSchedulableAgentSession(row)) {
+      throw new Error(`Agent session '${normalizedSessionId}' was not found.`);
+    }
+    await scheduledWorkReady;
+    const nextWakeAt = scheduledWorkScheduler?.nextWakeAt(normalizedSessionId) ?? null;
+    return {
+      sessionId: normalizedSessionId,
+      paused:
+        scheduledWorkScheduler?.isSessionPaused(normalizedSessionId) === true
+        || projectConfigService.get().effective.ai?.chat?.scheduledWorkPaused === true,
+      nextWakeAt: nextWakeAt == null ? null : new Date(nextWakeAt).toISOString(),
+      items: (scheduledWorkScheduler?.list(normalizedSessionId) ?? [])
+        .filter((schedule) => schedule.status !== "done" && schedule.status !== "cancelled")
+        .map(toScheduledWorkItem),
+    };
+  };
+
   const requestClaudeScheduledWorkCancellation = async (
     schedules: ChatScheduledWorkRecord[],
     options: { awaitConfirmation: boolean },
@@ -34744,10 +34818,10 @@ export function createAgentChatService(args: {
     paused,
   }: AgentChatSetScheduledWorkPausedArgs): Promise => {
     const normalizedSessionId = sessionId.trim();
-    if (!normalizedSessionId) throw new Error("Chat session id is required.");
+    if (!normalizedSessionId) throw new Error("Agent session id is required.");
     const row = sessionService.get(normalizedSessionId);
-    if (!row || !isChatToolType(row.toolType)) {
-      throw new Error(`Chat session '${normalizedSessionId}' was not found.`);
+    if (!row || !isSchedulableAgentSession(row)) {
+      throw new Error(`Agent session '${normalizedSessionId}' was not found.`);
     }
     await scheduledWorkReady;
     if (!scheduledWorkScheduler) {
@@ -34769,9 +34843,6 @@ export function createAgentChatService(args: {
     await scheduledWorkScheduler?.refreshGlobalPause();
   };
 
-  const pendingNativeScheduledWakeCountForTesting = (sessionId: string): number =>
-    pendingNativeScheduledWakeBySession.get(sessionId)?.length ?? 0;
-
   const hasActiveWorkloads = (): boolean => {
     for (const managed of managedSessions.values()) {
       if (managed.closed || managed.deleted) continue;
@@ -36218,7 +36289,6 @@ export function createAgentChatService(args: {
     }
     eventHistoryBySession.delete(trimmedSessionId);
     transcriptHistoryCacheBySession.delete(trimmedSessionId);
-    pendingNativeScheduledWakeBySession.delete(trimmedSessionId);
     lastPersistedPointerFingerprints.delete(trimmedSessionId);
 
     const persistedMetadataPath = metadataPathFor(trimmedSessionId);
@@ -36283,7 +36353,6 @@ export function createAgentChatService(args: {
         // ignore shutdown errors
       }
     }
-    pendingNativeScheduledWakeBySession.clear();
     flushAllQueuedTranscriptWrites();
     claudeSubprocessReaper.reapAll("dispose_all");
   };
@@ -36315,7 +36384,6 @@ export function createAgentChatService(args: {
       revokeBuiltInBrowserActorCapability(sessionId);
     }
     managedSessions.clear();
-    pendingNativeScheduledWakeBySession.clear();
     flushAllQueuedTranscriptWrites();
     claudeSubprocessReaper.reapAll("force_dispose_all");
   };
@@ -38989,32 +39057,57 @@ export function createAgentChatService(args: {
       const row = sessionService.get(sessionId);
       if (!row) return "missing";
       if (row.archivedAt) return "archived";
+      // A tracked agent CLI can be resumed by ptyService.sendToSession after
+      // its process exits, so an ended runtime is still a valid durable target.
+      if (row.tracked && isTrackedAgentCliToolType(row.toolType)) return "active";
       if (row.status === "running" || row.status === "detached") return "active";
       return "ended";
     },
-    fire: async (schedule, context) => {
-      const firedAt = new Date(schedule.lastFiredAt ?? Date.now()).toISOString();
+    shouldDefer: (schedule) => {
+      const row = sessionService.get(schedule.sessionId);
+      if (row?.tracked && isTrackedAgentCliToolType(row.toolType)) {
+        // A quiet-output heuristic is not a turn boundary: a CLI can think for
+        // longer than the idle timer without printing. The PTY service checks
+        // provider-specific visible composer markers plus a short quiet window.
+        return ptyService?.canAcceptScheduledTurn(schedule.sessionId) !== true;
+      }
       const liveManaged = managedSessions.get(schedule.sessionId);
       const liveRuntime = liveManaged?.runtime?.kind === "claude" ? liveManaged.runtime : null;
-      if (
-        schedule.provider === "claude"
-        && liveManaged
-        && liveRuntime?.query
-        && !liveRuntime.busy
-        && liveManaged.session.status !== "active"
-      ) {
-        const pending = pendingNativeScheduledWakeBySession.get(schedule.sessionId) ?? [];
-        pending.push({
-          scheduleId: schedule.id,
-          kind: schedule.kind,
-          prompt: schedule.prompt,
-          ...(schedule.reason ? { reason: schedule.reason } : {}),
-          firedAt,
-          late: context.late,
-        });
-        pendingNativeScheduledWakeBySession.set(schedule.sessionId, pending);
-        startClaudeIdleReader(liveManaged, liveRuntime, "durable_schedule_fire");
-        return;
+      // Never enqueue a scheduler-owned wake behind a foreground turn. Queued
+      // input is owned by that turn's runtime lifecycle and can be discarded
+      // when the turn fails or is interrupted. Keep the durable row armed and
+      // retry at the next boundary instead, regardless of which runtime or API
+      // created it.
+      if (liveManaged?.session.status === "active") return true;
+      return schedule.provider === "claude"
+        && liveRuntime?.query != null
+        && liveRuntime.busy;
+    },
+    fire: async (schedule, context) => {
+      const firedAt = new Date(schedule.lastFiredAt ?? Date.now()).toISOString();
+      const row = sessionService.get(schedule.sessionId);
+      if (row?.tracked && isTrackedAgentCliToolType(row.toolType)) {
+        if (!ptyService) {
+          return { retry: true };
+        }
+        try {
+          await ptyService.sendToSession({
+            sessionId: schedule.sessionId,
+            text: schedule.prompt,
+          });
+        } catch (error) {
+          const message = error instanceof Error ? error.message : String(error);
+          if (isPtySendPreDeliveryError(error)) {
+            logger.warn("agent_chat.scheduled_cli_delivery_retry", {
+              sessionId: schedule.sessionId,
+              scheduleId: schedule.id,
+              error: message,
+            });
+            return { retry: true };
+          }
+          throw error;
+        }
+        return { complete: true };
       }
       await messageSession({
         sessionId: schedule.sessionId,
@@ -39123,10 +39216,10 @@ export function createAgentChatService(args: {
     messageSession,
     createScheduledWork,
     listScheduledWork,
+    getScheduledWorkState,
     cancelScheduledWork,
     setScheduledWorkPaused,
     refreshScheduledWork,
-    pendingNativeScheduledWakeCountForTesting,
     readTranscript,
     setOrchestrationFields,
     getCodexGoal,
diff --git a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts
index b7ab39db6..1af2fe676 100644
--- a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts
+++ b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts
@@ -323,7 +323,7 @@ describe("createChatScheduledWorkScheduler", () => {
     await scheduler.upsert(wakeup({ fireAt: START, provider: "claude" }));
 
     sessionState = "ended";
-    const claimed = scheduler.claimNativeFire("session-1", "native-turn-ended");
+    const claimed = scheduler.claimNativeFire("session-1", "native-turn-ended", "wake-1");
 
     expect(claimed).toBeNull();
     expect(scheduler.list("session-1")[0]).toEqual(expect.objectContaining({
@@ -660,7 +660,7 @@ describe("createChatScheduledWorkScheduler", () => {
     });
     await scheduler.upsert(wakeup({ fireAt: START + 500, provider: "claude" }));
 
-    const claimed = scheduler.claimNativeFire("session-1", "native-turn-1");
+    const claimed = scheduler.claimNativeFire("session-1", "native-turn-1", "wake-1");
     expect(claimed).toMatchObject({
       id: "wake-1",
       status: "fired",
@@ -697,7 +697,7 @@ describe("createChatScheduledWorkScheduler", () => {
 
     await vi.advanceTimersByTimeAsync(5_000);
     expect(fire).not.toHaveBeenCalled();
-    expect(scheduler.claimNativeFire("session-1", "native-turn-grace")).toMatchObject({
+    expect(scheduler.claimNativeFire("session-1", "native-turn-grace", "native-grace-claim")).toMatchObject({
       id: "wake-1",
       status: "fired",
       activeTurnId: "native-turn-grace",
@@ -788,6 +788,143 @@ describe("createChatScheduledWorkScheduler", () => {
     lateScheduler.dispose();
   });
 
+  it("defers a busy Claude cron without advancing it and late-fires exactly once", async () => {
+    vi.useFakeTimers();
+    vi.setSystemTime(START);
+    let state: ChatScheduledWorkState | null = null;
+    let delivered = 0;
+    let deferChecks = 0;
+    const shouldDefer = vi.fn(() => {
+      deferChecks += 1;
+      return deferChecks <= 2;
+    });
+    const fire = vi.fn(async (
+      _schedule: ChatScheduledWorkRecord,
+      _context: { late: boolean },
+    ) => { delivered += 1; });
+    const scheduler = createChatScheduledWorkScheduler({
+      loadState: () => null,
+      saveState: (next) => { state = structuredClone(next); },
+      isGlobalPaused: () => false,
+      sessionState: () => "active",
+      shouldDefer,
+      fire,
+    });
+    await scheduler.upsert(wakeup({
+      id: "cron-deferred",
+      kind: "cron",
+      cron: "* * * * *",
+      fireAt: START,
+      lastFiredAt: START - 60_000,
+      lateFlag: true,
+      durable: true,
+      provider: "claude",
+      providerScheduleId: "cron-deferred",
+    }));
+
+    await vi.advanceTimersByTimeAsync(90_000);
+    expect(requireState(state).schedules[0]).toEqual(expect.objectContaining({
+      status: "scheduled",
+      fireAt: START,
+      lastFiredAt: START - 60_000,
+      lateFlag: true,
+    }));
+    await vi.advanceTimersByTimeAsync(20_000);
+    expect(requireState(state).schedules[0]).toEqual(expect.objectContaining({
+      status: "scheduled",
+      fireAt: START,
+      lastFiredAt: START - 60_000,
+      lateFlag: true,
+    }));
+    await vi.advanceTimersByTimeAsync(20_000);
+
+    expect(shouldDefer).toHaveBeenCalledTimes(3);
+    expect(fire).toHaveBeenCalledOnce();
+    expect(fire.mock.calls[0]?.[1]).toEqual({ late: true });
+    expect(delivered).toBe(1);
+    expect(requireState(state).schedules[0]).toEqual(expect.objectContaining({
+      status: "scheduled",
+      fireAt: START + 180_000,
+      lastFiredAt: START + 130_000,
+      lateFlag: true,
+    }));
+    scheduler.dispose();
+  });
+
+  it("lets a native claim win during the busy-defer retry window", async () => {
+    vi.useFakeTimers();
+    vi.setSystemTime(START);
+    let state: ChatScheduledWorkState | null = null;
+    const fire = vi.fn(async () => undefined);
+    const scheduler = createChatScheduledWorkScheduler({
+      loadState: () => null,
+      saveState: (next) => { state = structuredClone(next); },
+      isGlobalPaused: () => false,
+      sessionState: () => "active",
+      shouldDefer: () => true,
+      fire,
+    });
+    await scheduler.upsert(wakeup({
+      fireAt: START,
+      durable: true,
+      provider: "claude",
+      providerScheduleId: "native-defer-claim",
+    }));
+
+    await vi.advanceTimersByTimeAsync(90_000);
+    expect(requireState(state).schedules[0]).toEqual(expect.objectContaining({
+      status: "scheduled",
+      fireAt: START,
+    }));
+    await vi.advanceTimersByTimeAsync(5_000);
+    expect(scheduler.claimNativeFire("session-1", "native-turn-after-defer", "native-defer-claim")).toMatchObject({
+      id: "wake-1",
+      status: "fired",
+      activeTurnId: "native-turn-after-defer",
+    });
+    await vi.advanceTimersByTimeAsync(20_000);
+
+    expect(fire).not.toHaveBeenCalled();
+    expect(requireState(state).schedules[0]).toEqual(expect.objectContaining({
+      status: "fired",
+      activeTurnId: "native-turn-after-defer",
+    }));
+    scheduler.dispose();
+  });
+
+  it("expires a repeatedly deferred Claude wake without delivering it", async () => {
+    vi.useFakeTimers();
+    vi.setSystemTime(START);
+    let state: ChatScheduledWorkState | null = null;
+    const fire = vi.fn(async () => undefined);
+    const scheduler = createChatScheduledWorkScheduler({
+      loadState: () => null,
+      saveState: (next) => { state = structuredClone(next); },
+      isGlobalPaused: () => false,
+      sessionState: () => "active",
+      shouldDefer: () => true,
+      fire,
+    });
+    await scheduler.upsert(wakeup({
+      fireAt: START,
+      expiresAt: START + 105_000,
+      durable: true,
+      provider: "claude",
+      providerScheduleId: "native-defer-expiry",
+    }));
+
+    await vi.advanceTimersByTimeAsync(90_000);
+    expect(requireState(state).schedules[0]?.status).toBe("scheduled");
+    await vi.advanceTimersByTimeAsync(15_000);
+
+    expect(fire).not.toHaveBeenCalled();
+    expect(requireState(state).schedules[0]).toEqual(expect.objectContaining({
+      status: "cancelled",
+      terminalAt: START + 105_000,
+    }));
+    scheduler.dispose();
+  });
+
   it("fires non-Claude schedules at their exact fire time", async () => {
     vi.useFakeTimers();
     vi.setSystemTime(START);
@@ -811,6 +948,139 @@ describe("createChatScheduledWorkScheduler", () => {
     scheduler.dispose();
   });
 
+  it("completes a one-shot immediately when its delivery owns no tracked turn", async () => {
+    vi.useFakeTimers();
+    vi.setSystemTime(START);
+    let state: ChatScheduledWorkState | null = null;
+    const scheduler = createChatScheduledWorkScheduler({
+      loadState: () => null,
+      saveState: (next) => { state = structuredClone(next); },
+      isGlobalPaused: () => false,
+      sessionState: () => "active",
+      fire: async () => ({ complete: true }),
+    });
+    await scheduler.upsert(wakeup({ fireAt: START }));
+
+    await vi.advanceTimersByTimeAsync(0);
+
+    expect(requireState(state).schedules[0]).toEqual(expect.objectContaining({
+      status: "done",
+      lastFiredAt: START,
+      terminalAt: START,
+    }));
+    scheduler.dispose();
+  });
+
+  it("restores an occurrence when delivery proves it never started", async () => {
+    vi.useFakeTimers();
+    vi.setSystemTime(START);
+    let state: ChatScheduledWorkState | null = null;
+    const fire = vi.fn()
+      .mockResolvedValueOnce({ retry: true })
+      .mockResolvedValueOnce({ complete: true });
+    const scheduler = createChatScheduledWorkScheduler({
+      loadState: () => null,
+      saveState: (next) => { state = structuredClone(next); },
+      isGlobalPaused: () => false,
+      sessionState: () => "active",
+      fire,
+    });
+    await scheduler.upsert(wakeup({
+      fireAt: START,
+      lastFiredAt: START - 60_000,
+      lateFlag: true,
+    }));
+
+    await vi.advanceTimersByTimeAsync(0);
+    expect(requireState(state).schedules[0]).toEqual(expect.objectContaining({
+      status: "scheduled",
+      fireAt: START,
+      lastFiredAt: START - 60_000,
+      lateFlag: true,
+    }));
+
+    await vi.advanceTimersByTimeAsync(20_000);
+    expect(fire).toHaveBeenCalledTimes(2);
+    expect(requireState(state).schedules[0]).toEqual(expect.objectContaining({
+      status: "done",
+      lastFiredAt: START + 20_000,
+    }));
+    scheduler.dispose();
+  });
+
+  it("claims only the provider schedule named by an explicit native signal", async () => {
+    vi.useFakeTimers();
+    vi.setSystemTime(START);
+    const scheduler = createChatScheduledWorkScheduler({
+      loadState: () => null,
+      saveState: () => undefined,
+      isGlobalPaused: () => false,
+      sessionState: () => "active",
+      fire: createFireMock(),
+    });
+    await scheduler.upsert(wakeup({
+      id: "cron-a",
+      kind: "cron",
+      cron: "* * * * *",
+      fireAt: START,
+      provider: "claude",
+      providerScheduleId: "provider-a",
+    }));
+    await scheduler.upsert(wakeup({
+      id: "cron-b",
+      kind: "cron",
+      cron: "* * * * *",
+      fireAt: START,
+      provider: "claude",
+      providerScheduleId: "provider-b",
+    }));
+
+    expect(scheduler.claimNativeFire("session-1", "unrelated-turn", "missing-provider")).toBeNull();
+    expect(scheduler.claimNativeFire("session-1", "native-turn-b", "provider-b")).toMatchObject({
+      id: "cron-b",
+      activeTurnId: "native-turn-b",
+    });
+    expect(scheduler.list().find((item) => item.id === "cron-a")?.status).toBe("scheduled");
+    scheduler.dispose();
+  });
+
+  it("limits an ambiguous native cron signal to CronCreate-owned schedules", async () => {
+    vi.useFakeTimers();
+    vi.setSystemTime(START);
+    const scheduler = createChatScheduledWorkScheduler({
+      loadState: () => null,
+      saveState: () => undefined,
+      isGlobalPaused: () => false,
+      sessionState: () => "active",
+      fire: createFireMock(),
+    });
+    await scheduler.upsert(wakeup({
+      id: "native-wakeup",
+      kind: "wakeup",
+      fireAt: START - 500,
+      provider: "claude",
+    }));
+    await scheduler.upsert(wakeup({
+      id: "native-cron",
+      kind: "cron",
+      cron: "* * * * *",
+      fireAt: START,
+      provider: "claude",
+      providerScheduleId: "provider-cron",
+    }));
+
+    expect(scheduler.claimNativeFire("session-1", "ambiguous-cron-turn")).toMatchObject({
+      id: "native-cron",
+      activeTurnId: "ambiguous-cron-turn",
+    });
+    expect(scheduler.list().find((item) => item.id === "native-wakeup")?.status).toBe("scheduled");
+    expect(scheduler.claimNativeFire("session-1", "explicit-wakeup-turn", "native-wakeup")).toMatchObject({
+      id: "native-wakeup",
+      activeTurnId: "explicit-wakeup-turn",
+    });
+    scheduler.dispose();
+  });
+
   it("clears a native cron claim's active turn before re-arming", async () => {
     vi.useFakeTimers();
     vi.setSystemTime(START);
@@ -830,6 +1100,7 @@ describe("createChatScheduledWorkScheduler", () => {
       cron: "* * * * *",
       fireAt: START + 500,
       provider: "claude",
+      providerScheduleId: "provider-cron-1",
     }));
 
     expect(scheduler.claimNativeFire("session-1", "native-turn-1")).toMatchObject({
diff --git a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts
index c05000a9f..f478af302 100644
--- a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts
+++ b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts
@@ -70,7 +70,11 @@ export type ChatScheduledWorkSchedulerOptions = {
   timers?: ChatScheduledWorkTimerApi;
   isGlobalPaused(): boolean;
   sessionState(sessionId: string): "active" | "ended" | "archived" | "missing";
-  fire(schedule: ChatScheduledWorkRecord, context: { late: boolean }): Promise;
+  shouldDefer?(schedule: ChatScheduledWorkRecord): boolean;
+  fire(
+    schedule: ChatScheduledWorkRecord,
+    context: { late: boolean },
+  ): Promise;
   onTransition?: (
     schedule: ChatScheduledWorkRecord,
     status: ChatScheduledWorkStatus,
@@ -88,7 +92,7 @@ export type ChatScheduledWorkScheduler = {
   list(sessionId?: string): ChatScheduledWorkRecord[];
   isSessionPaused(sessionId: string): boolean;
   nextWakeAt(sessionId: string): number | null;
-  claimNativeFire(sessionId: string, turnId: string): ChatScheduledWorkRecord | null;
+  claimNativeFire(sessionId: string, turnId: string, scheduleId?: string): ChatScheduledWorkRecord | null;
   recordTurnStarted(scheduleId: string, turnId: string): Promise;
   recordTurnFinished(turnId: string, outcomeSummary?: string): Promise;
 };
@@ -96,6 +100,7 @@ export type ChatScheduledWorkScheduler = {
 const MAX_TIMER_DELAY_MS = 2_147_483_647;
 const TIMER_LATE_TOLERANCE_MS = 1_000;
 const NATIVE_FIRE_GRACE_MS = 90_000;
+const BUSY_DEFER_RETRY_MS = 20_000;
 const TERMINAL_HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
 const MAX_TERMINAL_HISTORY_RECORDS = 200;
 const LEGACY_PROVISIONAL_CRON_PREFIX = "cron-tool:";
@@ -328,17 +333,32 @@ export function createChatScheduledWorkScheduler(
       return;
     }
 
+    // Busy targets stay claimable until their next safe turn boundary. This
+    // check must happen before the durable row transitions to `fired`: Claude's
+    // native turn can otherwise arrive in the small persistence window and
+    // miss claimNativeFire, leaving both native and ADE delivery active.
+    if (options.shouldDefer?.(cloneSchedule(schedule)) === true) {
+      armBusyDeferRetry(schedule);
+      return;
+    }
+
     inFlight.add(scheduleId);
     const lateThresholdMs = nativeFireGraceMs(schedule) + TIMER_LATE_TOLERANCE_MS;
     const late = overdueWhenArmed || currentTime - schedule.fireAt > lateThresholdMs;
+    const previousLastFiredAt = schedule.lastFiredAt;
+    const previousLateFlag = schedule.lateFlag;
     schedule.status = "fired";
     schedule.lastFiredAt = currentTime;
     schedule.lateFlag = late;
     await persist();
     await emitTransition(schedule, "fired");
 
+    let complete = false;
+    let retry = false;
     try {
-      await options.fire(cloneSchedule(schedule), { late });
+      const result = await options.fire(cloneSchedule(schedule), { late });
+      complete = result != null && "complete" in result && result.complete === true;
+      retry = result != null && "retry" in result && result.retry === true;
     } catch {
       // A delivery may have reached the session before its caller failed. Keep
       // one-shots fired and advance crons so a retry cannot double-deliver.
@@ -348,7 +368,25 @@ export function createChatScheduledWorkScheduler(
 
     const current = schedules.get(scheduleId);
     if (!current || current.status === "cancelled" || current.status === "done") return;
-    if (current.kind !== "cron") return;
+    if (retry) {
+      current.status = isEffectivelyPaused(current) ? "paused" : "scheduled";
+      if (previousLastFiredAt == null) delete current.lastFiredAt;
+      else current.lastFiredAt = previousLastFiredAt;
+      current.lateFlag = previousLateFlag;
+      delete current.activeTurnId;
+      await persist();
+      await emitTransition(current, current.status);
+      if (current.status === "scheduled") armBusyDeferRetry(current);
+      return;
+    }
+    if (current.kind !== "cron") {
+      if (!complete) return;
+      markTerminal(current, "done");
+      pruneTerminalHistory();
+      await persist();
+      await emitTransition(current, "done");
+      return;
+    }
 
     current.fireAt = current.cron
       ? nextChatScheduledCronFireAt(current.cron, now()) ?? undefined
@@ -359,6 +397,20 @@ export function createChatScheduledWorkScheduler(
     arm(current);
   };
 
+  const armBusyDeferRetry = (schedule: ChatScheduledWorkRecord): void => {
+    clearTimer(schedule.id);
+    if (disposed || inFlight.has(schedule.id) || schedule.status !== "scheduled") return;
+    const retryAt = now() + BUSY_DEFER_RETRY_MS;
+    const nextAt = schedule.expiresAt == null
+      ? retryAt
+      : Math.min(retryAt, schedule.expiresAt);
+    const handle = timers.setTimeout(() => {
+      timerHandles.delete(schedule.id);
+      void processDue(schedule.id, false).catch(() => undefined);
+    }, Math.max(0, nextAt - now()));
+    timerHandles.set(schedule.id, handle);
+  };
+
   const arm = (schedule: ChatScheduledWorkRecord): void => {
     clearTimer(schedule.id);
     if (disposed || inFlight.has(schedule.id) || schedule.status !== "scheduled") return;
@@ -610,12 +662,14 @@ export function createChatScheduledWorkScheduler(
       return next;
     },
 
-    claimNativeFire(sessionId, turnId): ChatScheduledWorkRecord | null {
+    claimNativeFire(sessionId, turnId, scheduleId): ChatScheduledWorkRecord | null {
       if (disposed || options.isGlobalPaused() || pausedSessionIds.has(sessionId)) return null;
       const currentTime = now();
       const schedule = [...schedules.values()]
         .filter((candidate) =>
           candidate.sessionId === sessionId
+          && (scheduleId == null || candidate.id === scheduleId || candidate.providerScheduleId === scheduleId)
+          && (scheduleId != null || (candidate.kind === "cron" && candidate.providerScheduleId != null))
           && candidate.provider === "claude"
           && candidate.status === "scheduled"
           && !candidate.pausedFlag
diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts
index 9fe526307..36c6a9fd6 100644
--- a/apps/desktop/src/main/services/pty/ptyService.test.ts
+++ b/apps/desktop/src/main/services/pty/ptyService.test.ts
@@ -5,6 +5,7 @@ import path from "node:path";
 import type { IPty } from "node-pty";
 import type * as TerminalSessionSignals from "../../utils/terminalSessionSignals";
 import { buildOpenCodeReplayResumeCommand as buildCanonicalOpenCodeReplayResumeCommand } from "../../../shared/cliLaunch";
+import { isPtySendPreDeliveryError } from "../../../shared/types";
 import { expectNoJargon } from "../../../test/jargonGuard";
 
 // ---------------------------------------------------------------------------
@@ -2318,6 +2319,41 @@ describe("ptyService", () => {
       expect(loadPty).not.toHaveBeenCalled();
     });
 
+    it("accepts scheduled CLI turns only at a visible provider composer boundary", async () => {
+      vi.useFakeTimers();
+      try {
+        const { service, mockPty } = createHarness();
+        const created = await service.create({
+          sessionId: "session-scheduled-boundary",
+          allowNewSessionId: true,
+          laneId: "lane-1",
+          title: "Codex CLI",
+          cols: 80,
+          rows: 24,
+          toolType: "codex",
+          startupCommand: "codex",
+        });
+
+        expect(service.canAcceptScheduledTurn(created.sessionId)).toBe(false);
+        mockPty._emitter.emit("data", "\x1b[2J\x1b[HOpenAI Codex\nmodel: gpt-5.4\n› ");
+        await vi.advanceTimersByTimeAsync(599);
+        expect(service.canAcceptScheduledTurn(created.sessionId)).toBe(false);
+        await vi.advanceTimersByTimeAsync(1);
+        expect(service.canAcceptScheduledTurn(created.sessionId)).toBe(true);
+
+        // Output silence alone must not make an in-progress model turn safe.
+        mockPty._emitter.emit("data", "\x1b[2J\x1b[HWorking on the request…");
+        await vi.advanceTimersByTimeAsync(12_500);
+        expect(service.canAcceptScheduledTurn(created.sessionId)).toBe(false);
+
+        mockPty._emitter.emit("data", "\x1b[2J\x1b[HOpenAI Codex\nmodel: gpt-5.4\n› ");
+        await vi.advanceTimersByTimeAsync(600);
+        expect(service.canAcceptScheduledTurn(created.sessionId)).toBe(true);
+      } finally {
+        vi.useRealTimers();
+      }
+    });
+
     it("sendToSession uses line-submit for Droid CLI sessions", async () => {
       const { service, mockPty } = createHarness();
       const created = await service.create({
@@ -2405,10 +2441,13 @@ describe("ptyService", () => {
       });
       sessionService.readTranscriptTail.mockResolvedValueOnce("normal transcript without updater text");
 
-      await expect(service.sendToSession({
+      const error = await service.sendToSession({
         sessionId: `session-${provider}-targetless`,
         text: "keep going",
-      })).rejects.toThrow(new RegExp(`${expectedName} exited before ADE could capture a concrete resume target`));
+      }).catch((cause: unknown) => cause);
+      expect(isPtySendPreDeliveryError(error)).toBe(true);
+      expect(error).toBeInstanceOf(Error);
+      expect((error as Error).message).toMatch(new RegExp(`${expectedName} exited before ADE could capture a concrete resume target`));
       expect(loadPty).not.toHaveBeenCalled();
     });
 
diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts
index 73ee43def..70d2fbcdd 100644
--- a/apps/desktop/src/main/services/pty/ptyService.ts
+++ b/apps/desktop/src/main/services/pty/ptyService.ts
@@ -63,6 +63,10 @@ import type {
   TerminalSessionSummary,
   TerminalToolType,
 } from "../../../shared/types";
+import {
+  isTrackedAgentCliToolType,
+  PTY_SEND_PRE_DELIVERY_ERROR_CODE,
+} from "../../../shared/types";
 import { isProviderSlashCommandInput } from "../../../shared/chatSlashCommands";
 import {
   sanitizeTrackedCliPromptSeed,
@@ -274,6 +278,12 @@ function delay(ms: number): Promise {
   return new Promise((resolve) => setTimeout(resolve, ms));
 }
 
+function ptySendPreDeliveryError(
+  message: string,
+): Error & { code: typeof PTY_SEND_PRE_DELIVERY_ERROR_CODE } {
+  return Object.assign(new Error(message), { code: PTY_SEND_PRE_DELIVERY_ERROR_CODE });
+}
+
 function hasEnvKey(env: NodeJS.ProcessEnv, key: string): boolean {
   return Object.prototype.hasOwnProperty.call(env, key);
 }
@@ -815,17 +825,6 @@ function attributionRootKindForToolType(toolType: TerminalToolType | null): Reso
   return "provider-agent";
 }
 
-function isTrackedCliToolType(toolType: TerminalToolType | null): toolType is "claude" | "codex" | "cursor-cli" | "droid" | "opencode" | "claude-orchestrated" | "codex-orchestrated" | "opencode-orchestrated" {
-  return toolType === "claude"
-    || toolType === "codex"
-    || toolType === "cursor-cli"
-    || toolType === "droid"
-    || toolType === "opencode"
-    || toolType === "claude-orchestrated"
-    || toolType === "codex-orchestrated"
-    || toolType === "opencode-orchestrated";
-}
-
 function isCodexTrackedCliToolType(toolType: TerminalToolType | null | undefined): toolType is "codex" | "codex-orchestrated" {
   return toolType === "codex" || toolType === "codex-orchestrated";
 }
@@ -2223,7 +2222,7 @@ export function createPtyService({
     const session = sessionService.get(sessionId);
     if (!session?.tracked) return false;
     const effectiveToolType = preferredToolType ?? session.toolType ?? null;
-    if (!isTrackedCliToolType(effectiveToolType)) return false;
+    if (!isTrackedAgentCliToolType(effectiveToolType)) return false;
     const existingTargetId = sanitizeResumeTargetId(session.resumeMetadata?.targetId ?? null);
     if (existingTargetId) {
       const cwd = sessionCwd ?? inferSessionCwdFromTranscriptPath(session.transcriptPath);
@@ -2610,7 +2609,7 @@ export function createPtyService({
     if (!entry) return;
     if (entry.disposed) return;
     entry.disposed = true;
-    if (!entry.chatSessionId && isTrackedCliToolType(entry.toolTypeHint)) {
+    if (!entry.chatSessionId && isTrackedAgentCliToolType(entry.toolTypeHint)) {
       revokeBuiltInBrowserActorCapability(entry.sessionId);
     }
     if (entry.aiTitleTimer) {
@@ -3127,28 +3126,34 @@ export function createPtyService({
   ): Promise => {
     const deadline = Date.now() + timeoutMs;
     while (Date.now() < deadline) {
-      const live = liveEntryBySessionId(sessionId);
-      if (!live) return false;
-      const entry = live[1];
-      if (entry.disposed) return false;
-      const outputTail = stripAnsi(entry.recentOutputTail).replace(/\r/g, "\n");
-      const visibleText = entry.terminalSnapshot
-        ? visibleRowsFromTerminal(entry.terminalSnapshot.terminal)
-          .map((row) => row.text)
-          .join("\n")
-        : "";
-      const readinessText = visibleText.trim().length > 0 ? visibleText : outputTail;
-      const runtime = runtimeStates.get(sessionId);
-      const quietForMs = runtime ? Date.now() - runtime.lastActivityAt : 0;
-      if (providerReadyMarkerVisible(provider, readinessText) && quietForMs >= AGENT_CLI_READY_QUIET_MS) {
-        return true;
-      }
+      if (agentCliInputReadyNow(sessionId, provider)) return true;
+      if (!liveEntryBySessionId(sessionId)) return false;
       await delay(AGENT_CLI_READY_POLL_MS);
     }
     logger.warn("pty.agent_cli_ready_wait_timeout", { sessionId, provider, timeoutMs });
     return false;
   };
 
+  const agentCliInputReadyNow = (
+    sessionId: string,
+    provider: TerminalResumeProvider,
+  ): boolean => {
+    const live = liveEntryBySessionId(sessionId);
+    if (!live || live[1].disposed) return false;
+    const entry = live[1];
+    const outputTail = stripAnsi(entry.recentOutputTail).replace(/\r/g, "\n");
+    const visibleText = entry.terminalSnapshot
+      ? visibleRowsFromTerminal(entry.terminalSnapshot.terminal)
+        .map((row) => row.text)
+        .join("\n")
+      : "";
+    const readinessText = visibleText.trim().length > 0 ? visibleText : outputTail;
+    const runtime = runtimeStates.get(sessionId);
+    const quietForMs = runtime ? Date.now() - runtime.lastActivityAt : 0;
+    return providerReadyMarkerVisible(provider, readinessText)
+      && quietForMs >= AGENT_CLI_READY_QUIET_MS;
+  };
+
   const writeAgentCliInput = async (
     write: (data: string) => boolean,
     inputText: string,
@@ -3259,10 +3264,10 @@ export function createPtyService({
     action: "continued" | "resumed",
   ): void => {
     if (session?.tracked === false) {
-      throw new Error(`Terminal session '${sessionId}' is not tracked and cannot be ${action}.`);
+      throw ptySendPreDeliveryError(`Terminal session '${sessionId}' is not tracked and cannot be ${action}.`);
     }
     if (session && (session.toolType === "shell" || session.toolType === "run-shell" || isPersistedChatToolType(session.toolType))) {
-      throw new Error(`Terminal session '${sessionId}' is not an agent CLI session.`);
+      throw ptySendPreDeliveryError(`Terminal session '${sessionId}' is not an agent CLI session.`);
     }
   };
 
@@ -3270,14 +3275,14 @@ export function createPtyService({
     sessionId: string,
     session: TerminalSessionSummary | null,
   ): Promise<{ session: TerminalSessionSummary; provider: TerminalResumeProvider }> => {
-    if (!session) throw new Error(`Terminal session '${sessionId}' was not found.`);
+    if (!session) throw ptySendPreDeliveryError(`Terminal session '${sessionId}' was not found.`);
 
     const provider = session.resumeMetadata?.provider ?? providerFromTool(session.toolType);
-    if (!provider) throw new Error(`Terminal session '${sessionId}' does not have a resumable CLI provider.`);
+    if (!provider) throw ptySendPreDeliveryError(`Terminal session '${sessionId}' does not have a resumable CLI provider.`);
 
     const throwMissingResumeTarget = (): never => {
       const displayName = resumeProviderDisplayName(provider);
-      throw new Error(
+      throw ptySendPreDeliveryError(
         `${displayName} exited before ADE could capture a concrete resume target. Start a new ${displayName} session.`,
       );
     };
@@ -3291,7 +3296,7 @@ export function createPtyService({
 
     let resolvedSession = session;
     let storedResumeTargetId = resumeTargetIdFor(resolvedSession);
-    if (!storedResumeTargetId && provider !== "cursor" && isTrackedCliToolType(resolvedSession.toolType)) {
+    if (!storedResumeTargetId && provider !== "cursor" && isTrackedAgentCliToolType(resolvedSession.toolType)) {
       const cwd = inferSessionCwdFromTranscriptPath(resolvedSession.transcriptPath);
       const backfilled = await tryBackfillResumeTarget(sessionId, resolvedSession.toolType, "resume-launch", cwd);
       const updatedSession = backfilled ? sessionService.get(sessionId) : null;
@@ -3307,7 +3312,7 @@ export function createPtyService({
     ) {
       const transcript = await sessionService.readTranscriptTail(resolvedSession.transcriptPath, 220_000);
       if (isCodexCliUpdateTranscript(transcript)) {
-        throw new Error(
+        throw ptySendPreDeliveryError(
           "Codex updated and exited before ADE could create a resumable thread. Start a new Codex session.",
         );
       }
@@ -3449,13 +3454,13 @@ export function createPtyService({
         ? sessionService.get(requestedSessionId)
         : null;
       if (requestedSessionId.length && !existingSession && isResumeAttempt && !allowNewSessionId) {
-        throw new Error(`Terminal session '${requestedSessionId}' was not found.`);
+        throw ptySendPreDeliveryError(`Terminal session '${requestedSessionId}' was not found.`);
       }
       if (existingSession && existingSession.laneId !== laneId) {
         throw new Error(`Terminal session '${requestedSessionId}' belongs to lane '${existingSession.laneId}', not '${laneId}'.`);
       }
       if (existingSession && !existingSession.tracked) {
-        throw new Error(`Terminal session '${requestedSessionId}' is not tracked and cannot be resumed.`);
+        throw ptySendPreDeliveryError(`Terminal session '${requestedSessionId}' is not tracked and cannot be resumed.`);
       }
       const liveAttachedEntry = existingSession
         ? Array.from(ptys.entries()).find(([, entry]) => entry.sessionId === existingSession.id && !entry.disposed)
@@ -3501,7 +3506,7 @@ export function createPtyService({
       // Reaching here always spawns a NEW PTY/process — the live-attach case
       // returned above — so resuming a tracked CLI session whose PTY is gone
       // is a new launch and must be gated too, not only brand-new sessions.
-      if (tracked && isTrackedCliToolType(toolTypeHint)) {
+      if (tracked && isTrackedAgentCliToolType(toolTypeHint)) {
         const decision = diskPressureMonitor?.canPerform("cli_launch");
         if (decision && !decision.allowed) {
           throw Object.assign(new Error(decision.message), { code: decision.code });
@@ -3644,7 +3649,7 @@ export function createPtyService({
         projectRoot,
         laneId,
         chatSessionId,
-        ownerSessionId: isTrackedCliToolType(toolTypeHint) ? sessionId : null,
+        ownerSessionId: isTrackedAgentCliToolType(toolTypeHint) ? sessionId : null,
       });
       let launchEnv = withInteractiveTerminalColorEnv(
         getAdeCliAgentEnv?.(contextLaunchEnv) ?? contextLaunchEnv,
@@ -3655,7 +3660,7 @@ export function createPtyService({
       });
       const shouldBackfillResumeTarget =
         existingSession
-        && isTrackedCliToolType(toolTypeHint)
+        && isTrackedAgentCliToolType(toolTypeHint)
         && !sanitizeResumeTargetId(existingSession.resumeMetadata?.targetId ?? null);
       if (shouldBackfillResumeTarget) {
         const backfilled = await tryBackfillResumeTarget(sessionId, toolTypeHint, "resume-launch", cwd);
@@ -4164,6 +4169,17 @@ export function createPtyService({
       return { ptyId, sessionId, pid: pty.pid ?? null };
     },
 
+    canAcceptScheduledTurn(sessionId: string): boolean {
+      const normalizedSessionId = sessionId.trim();
+      const session = normalizedSessionId ? sessionService.get(normalizedSessionId) : null;
+      if (!session?.tracked || !isTrackedAgentCliToolType(session.toolType)) return false;
+      const live = liveEntryBySessionId(normalizedSessionId);
+      if (!live) return true;
+      const provider = session.resumeMetadata?.provider
+        ?? providerFromTool(session.toolType ?? live[1].toolTypeHint);
+      return provider != null && agentCliInputReadyNow(normalizedSessionId, provider);
+    },
+
     async sendToSession(args: PtySendToSessionArgs): Promise {
       const sessionId = typeof args.sessionId === "string" ? args.sessionId.trim() : "";
       const text = typeof args.text === "string" ? args.text.trim() : "";
@@ -4214,7 +4230,7 @@ export function createPtyService({
       if (live) {
         const [ptyId, entry] = live;
         const provider = session?.resumeMetadata?.provider ?? providerFromTool(session?.toolType ?? entry.toolTypeHint);
-        if (!provider) throw new Error(`Terminal session '${sessionId}' does not have a resumable CLI provider.`);
+        if (!provider) throw ptySendPreDeliveryError(`Terminal session '${sessionId}' does not have a resumable CLI provider.`);
         const written = await writeSubmittedText(sessionId, text, provider, {
           waitForReady: provider === "cursor",
         });
@@ -4260,7 +4276,7 @@ export function createPtyService({
       const promptAtLaunch = !openCodeReplayCommand && !resumeFlightAlreadyInProgress && builtResume.promptAtLaunch;
       const resumeCommand = openCodeReplayCommand ?? builtResume.command;
       if (!resumeCommand) {
-        throw new Error(`Terminal session '${sessionId}' does not have a resume command.`);
+        throw ptySendPreDeliveryError(`Terminal session '${sessionId}' does not have a resume command.`);
       }
 
       const { flight, created: resumeFlightCreated } = getOrCreateResumeFlight(resumableSession, resumeCommand, args);
@@ -4958,7 +4974,7 @@ export function createPtyService({
         // so stale sessions do not get stuck in a "running" state forever.
         const endedAt = new Date().toISOString();
         sessionService.end({ sessionId, endedAt, exitCode: null, status: "disposed" });
-        if (!session.chatSessionId && isTrackedCliToolType(session.toolType)) {
+        if (!session.chatSessionId && isTrackedAgentCliToolType(session.toolType)) {
           revokeBuiltInBrowserActorCapability(sessionId);
         }
         backfillResumeTargetFromTranscriptBestEffort(sessionId, session.toolType ?? null, "orphan-dispose");
@@ -4993,7 +5009,7 @@ export function createPtyService({
       }
       if (entry.disposed) return { disposed: false, reason: "already-disposed" };
       entry.disposed = true;
-      if (!entry.chatSessionId && isTrackedCliToolType(entry.toolTypeHint)) {
+      if (!entry.chatSessionId && isTrackedAgentCliToolType(entry.toolTypeHint)) {
         revokeBuiltInBrowserActorCapability(entry.sessionId);
       }
       if (entry.aiTitleTimer) {
diff --git a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx
index d3385fe79..d52e16c88 100644
--- a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx
+++ b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx
@@ -424,7 +424,7 @@ export function AiFeaturesSection() {
                   {item.title}
                 
                 
- {item.kind} · {item.status} · chat {item.sessionId.slice(0, 8)}{item.nextRunAt ? ` · ${new Date(item.nextRunAt).toLocaleString()}` : ""} + {item.kind} · {item.status} · session {item.sessionId.slice(0, 8)}{item.nextRunAt ? ` · ${new Date(item.nextRunAt).toLocaleString()}` : ""}