diff --git a/.claude/commands/shipLane.md b/.claude/commands/shipLane.md index 260ab0fd2..7688a5077 100644 --- a/.claude/commands/shipLane.md +++ b/.claude/commands/shipLane.md @@ -78,7 +78,11 @@ If `TeamCreate` is genuinely not in scope for this session: ## Scheduling wake-ups -This wrapper is Claude Code-specific. Use `ScheduleWakeup` at the end of each iteration (playbook §5.3) with the same command re-invocation as the `prompt`: +The right primitive depends on the harness this command is running under. Pick one and stick with it for the whole run. + +### Claude Code CLI (interactive terminal) + +`ScheduleWakeup` is a CLI-native primitive — the CLI scheduler re-invokes the command later without the user typing. Use it at the end of each iteration (playbook §5.3): ``` ScheduleWakeup({ @@ -90,9 +94,19 @@ ScheduleWakeup({ Pass `$ARGUMENTS` through so a PR-number argument is preserved across wake-ups. -Waiting must be token-idle. After scheduling a wake-up, stop the active agent turn completely and let the scheduler re-invoke this command later. Do not keep agents alive in polling loops, do not run `--watch` commands, and do not ask sub-agents to sleep while holding context. Poll only once per scheduled invocation, then either fix, exit, or schedule the next wake. +### Claude Agent SDK chat (e.g. ADE Work chats) + +When this command runs inside a `claude-agent-sdk` v2 chat session (`unstable_v2_createSession` / `SDKSession.send` / `stream`), the SDK has **no scheduled-wakeup primitive**. `ScheduleWakeup` accepts the call but never re-invokes — the session only advances when the host calls `session.send(...)`, which only happens on a fresh user message. `Bash run_in_background: true` task notifications are queued in the SDK message stream and only flushed on the next user message; they do **not** start an autonomous turn either. + +So in an SDK-driven chat, do not pretend you can self-resume: +- Do all polling synchronously inside the current turn (`until ! ... ; do sleep N; done` in a foreground bash). One bounded sleep + one bounded poll per turn — no `run_in_background` if you actually need the result before turn end. +- Or stop the turn cleanly and tell the user to re-ping you when they want the next iteration. Write the updated state file with `status: running` so the next `/shipLane $ARGUMENTS` invocation continues from the right phase. -Other agent CLIs have their own sleep/resume mechanisms. If a Claude Code scheduler is not available, follow the playbook's generic guidance instead of copying `ScheduleWakeup` literally. For Codex-style terminal work, the recommended fallback is a shell sleep that does not involve the model, followed by one one-shot status command, for example: +Do not start a `run_in_background` poller and claim it will wake you — it won't. Do not "probe" the wake mechanism by starting a 30s background sleep and waiting silently; nothing will arrive. + +### Other agent CLIs + +If neither a CLI scheduler nor an SDK wake is available, fall back to a shell sleep that does not involve the model, followed by one one-shot status command, for example: ```bash sleep 720 && gh pr checks 185 && gh run list --branch ade/cli-prs-fixes-747d7096 --limit 5 @@ -100,6 +114,10 @@ sleep 720 && gh pr checks 185 && gh run list --branch ade/cli-prs-fixes-747d7096 That shell process can wait without spending model tokens; the agent should only resume reasoning after the command produces output. +### Common rules (all harnesses) + +Waiting must be token-idle. After scheduling a wake-up (or running a foreground sleep), do not keep agents alive in polling loops, do not run `--watch` commands, and do not ask sub-agents to sleep while holding context. Poll only once per scheduled invocation, then either fix, exit, or schedule the next wake. + Do NOT schedule a wake if `status` is `done-clean`, `done-max`, or `blocked` — print the summary and stop. --- @@ -116,6 +134,21 @@ If any rail fails, exit `blocked` with a clear reason in the state file and stop --- +## Worktree path discipline (CRITICAL — every iteration) + +ADE invokes `/shipLane` from a worktree like `/Users//Projects//.ade/worktrees//`. The project root (`/Users//Projects//`) **also** exists as a separate git checkout, usually on `main`, with the same files at the same relative paths. + +Every `Edit`/`Write` you do MUST target the worktree-prefixed absolute path. Editing the project-root copy lands changes on the wrong branch, leaves the worktree clean, and the iteration's commit silently picks up nothing. + +How to keep yourself honest: + +- Anchor every edit on the env's working directory: if `pwd` shows `.ade/worktrees//`, every Edit `file_path` must start with `.../.ade/worktrees//`. If a path begins anywhere else under the project root, that's the wrong target. +- `Read` tool result paths are not authoritative. If a Read resolved to the project-root copy (because of an earlier `cd` to project root for a `gh` or `git fetch` call), re-resolve to the worktree before editing. +- After any sequence of edits and before commit, run `git status` from the worktree. If it's empty but you "just edited" several files, you wrote to the wrong tree — recover via `cd && git diff > /tmp/x.patch && git checkout -- `, then `git apply /tmp/x.patch` from the worktree. +- Stay in the worktree directory. Use `git -C ...` for one-off project-root reads instead of `cd `, so subsequent edits don't accidentally use cached project-root paths. + +--- + ## ADE CLI discovery (Claude Code specific) This wrapper consumes `ade` everywhere the playbook says to use it. If `command -v ade` returns nothing, do NOT immediately fall back to `gh`. Try the local build first: 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 c5ad9b1e7..c2263b178 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts @@ -71,6 +71,44 @@ describe("buildCodingAgentSystemPrompt", () => { expect(result).toContain("Use the available tools deliberately"); }); + describe("runtime environment banner", () => { + it("omits the runtime block when runtime is not provided", () => { + const result = buildCodingAgentSystemPrompt({ cwd: "/x" }); + expect(result).not.toContain("## Runtime Environment"); + }); + + it("describes the Codex CLI runtime", () => { + 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"); + }); + + it("describes the Claude Agent SDK v2 runtime with wake-up caveat", () => { + const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "claude-agent-sdk-v2" }); + expect(result).toContain("## Runtime Environment"); + expect(result).toContain("Claude Agent SDK v2"); + expect(result).toContain("ScheduleWakeup"); + expect(result).toContain("not honored"); + expect(result).toContain("never re-invokes"); + }); + + it("describes the Cursor ACP runtime", () => { + const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "cursor-acp" }); + expect(result).toContain("Cursor agent via ACP"); + }); + + it("describes the Droid ACP runtime", () => { + const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "droid-acp" }); + expect(result).toContain("Factory Droid agent via ACP"); + }); + + it("describes the OpenCode runtime", () => { + const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "opencode" }); + expect(result).toContain("OpenCode session"); + }); + }); + it("includes interactive question guidance by default", () => { const result = buildCodingAgentSystemPrompt({ cwd: "/x" }); expect(result).toContain("ask one concise question"); diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts index eb9cc04c4..ab1f63e4d 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts @@ -3,6 +3,50 @@ import { ADE_CLI_AGENT_GUIDANCE } from "../../../../shared/adeCliGuidance"; type HarnessMode = "chat" | "coding" | "planning"; type HarnessPermissionMode = "plan" | "edit" | "full-auto"; +/** + * Identifier for the runtime that's actually executing the model. Used to tell + * the agent which harness it's in so it doesn't assume CLI-only primitives + * (like ScheduleWakeup) are available, and so it knows whether autonomous + * wake-ups are possible. + */ +export type AdeRuntimeKind = + | "claude-agent-sdk-v2" + | "codex-cli" + | "cursor-acp" + | "droid-acp" + | "opencode"; + +function describeRuntime(runtime: AdeRuntimeKind): string[] { + switch (runtime) { + case "claude-agent-sdk-v2": + return [ + "**Runtime:** ADE Work chat hosted on the Claude Agent SDK v2 (`unstable_v2_createSession` / `SDKSession`).", + "**Wake-up semantics:** The session only advances when the host calls `session.send(...)`, which fires on a fresh user message. There is no autonomous wake. `ScheduleWakeup` is **not honored** in this harness — the host accepts the call but never re-invokes you. `Bash run_in_background: true` task notifications are queued in the SDK message stream and only flushed on the next user turn; they do not start an autonomous turn either.", + "**To wait:** Either poll synchronously inside the active turn (foreground bash with one bounded `until ... ; do sleep N; done`) or stop the turn cleanly and ask the user to re-ping when ready. Do not run a background poller and claim it will wake you — it will not.", + ]; + 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.", + ]; + case "cursor-acp": + return [ + "**Runtime:** ADE Work chat wrapping the Cursor agent via ACP (Agent Client Protocol).", + "**Wake-up semantics:** Each turn is a discrete ACP `prompt` request. There is no autonomous wake; if you need to wait, use a shell `sleep` and surface results in the next user turn.", + ]; + case "droid-acp": + return [ + "**Runtime:** ADE Work chat wrapping the Factory Droid agent via ACP.", + "**Wake-up semantics:** Each turn is a discrete ACP `prompt` request. There is no autonomous wake; if you need to wait, use a shell `sleep` and surface results in the next user turn.", + ]; + 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.", + ]; + } +} + function describePermissionMode(mode: HarnessPermissionMode): string { switch (mode) { case "plan": @@ -31,11 +75,13 @@ export function buildCodingAgentSystemPrompt(args: { permissionMode?: HarnessPermissionMode; toolNames?: string[]; interactive?: boolean; + runtime?: AdeRuntimeKind; }): string { const mode = args.mode ?? "coding"; const permissionMode = args.permissionMode ?? "edit"; const toolNames = [...new Set((args.toolNames ?? []).filter((entry) => entry.trim().length > 0))]; const interactive = args.interactive !== false; + const runtime = args.runtime; const hasMemoryTools = toolNames.some((name) => name === "memorySearch" || name === "memoryAdd" @@ -71,6 +117,13 @@ export function buildCodingAgentSystemPrompt(args: { return [ `You are ADE's software engineering agent working in ${args.cwd}.`, "This session is bound to that worktree. Read, edit, and run commands only inside this path unless ADE explicitly relaunches you in a different lane.", + ...(runtime + ? [ + "", + "## Runtime Environment", + ...describeRuntime(runtime), + ] + : []), "", "## Mission", describeMode(mode), diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index cbb45a76a..db837a8bb 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -2637,6 +2637,7 @@ function buildCodexDeveloperInstructions(args: { mode: promptMode, permissionMode: toHarnessPermissionMode(args.session.permissionMode), interactive: true, + runtime: "codex-cli", }); } @@ -10539,6 +10540,11 @@ export function createAgentChatService(args: { type: "preset", preset: "claude_code", append: [ + "## Runtime Environment", + "**Runtime:** ADE Work chat hosted on the Claude Agent SDK v2 (`unstable_v2_createSession` / `SDKSession`). 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:** The session only advances when ADE calls `session.send(...)`, which fires on a fresh user message. There is no autonomous wake. `ScheduleWakeup` is **not honored** in this harness — the host accepts the call but never re-invokes you. `Bash run_in_background: true` task notifications are queued in the SDK message stream and only flushed on the next user turn; they do not start an autonomous turn either.", + "**To wait:** Either poll synchronously inside the active turn (foreground bash with one bounded `until ... ; do sleep N; done`) or stop the turn cleanly and ask the user to re-ping when ready. Do not run a background poller and claim it will wake you — it will not.", + "", "## ADE Workspace", `ADE launched this session in lane worktree: ${managed.laneWorktreePath}.`, "Read, edit, and run commands only inside that worktree. Do not switch to project root, another lane, or another repo unless ADE explicitly relaunches you there.", @@ -14052,9 +14058,12 @@ export function createAgentChatService(args: { const queue = runtime.pendingSteers; const idx = queue.findIndex((s) => s.steerId === steerId); - if (idx === -1) return; - - queue.splice(idx, 1); + if (idx !== -1) { + queue.splice(idx, 1); + } + // Always emit the cancelled notice — even when the steer already left the + // server-side queue (e.g. dispatched inline before this call landed) — so + // the client display clears the staged chip on the delete-button path. emitChatEvent(managed, { type: "system_notice", noticeKind: "info", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index fcea7481d..adc434032 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -795,7 +795,6 @@ export function AgentChatComposer({ const addFileAttachments = async (files: FileList | null | undefined) => { if (!files?.length) return; - if (turnActive) return; if (parallelChatMode && attachments.length >= PARALLEL_CHAT_MAX_ATTACHMENTS) return; if (fileAddInProgressRef.current) return; fileAddInProgressRef.current = true; @@ -847,7 +846,6 @@ export function AgentChatComposer({ const addNativeClipboardImageAttachment = async () => { if (!canAttach) return; - if (turnActive) return; if (parallelChatMode && attachments.length >= PARALLEL_CHAT_MAX_ATTACHMENTS) return; if (fileAddInProgressRef.current) return; fileAddInProgressRef.current = true; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 7e42fd695..f6219df6d 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -473,9 +473,17 @@ export function deriveRuntimeState(events: AgentChatEventEnvelope[]): { turnActive = event.turnStatus === "started"; } else if (event.type === "done") { turnActive = false; - } else if (event.type === "user_message" && event.steerId && event.deliveryState === "queued") { - if (!resolvedSteerIds.has(event.steerId)) { - steerMap.set(event.steerId, { steerId: event.steerId, text: event.text }); + } else if (event.type === "user_message" && event.steerId) { + if (event.deliveryState === "queued") { + if (!resolvedSteerIds.has(event.steerId)) { + steerMap.set(event.steerId, { steerId: event.steerId, text: event.text }); + } + } else { + // "inline" / "delivered" / "failed" — the steer left the queue, so + // clear it from the display. Without this the chip stays staged after + // the user clicks "Send Now" or after a queued steer is delivered. + steerMap.delete(event.steerId); + resolvedSteerIds.add(event.steerId); } } else if (event.type === "system_notice" && event.steerId) { // "cancelled" or "Delivering" notices resolve the steer diff --git a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift index cc647ac76..a7419a0bc 100644 --- a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift @@ -676,12 +676,14 @@ struct WorkStructuredQuestionCard: View { /// forwards this as one `chat.respondToInput` call. let onSubmitAll: @MainActor ([String: AgentChatInputAnswerValue], String?) async -> Void let onDecline: @MainActor () async -> Void + var onFreeformFocusChange: ((Bool) -> Void)? = nil @State private var currentPage: Int = 0 @State private var singleQuestionFreeformText: String = "" @State private var selections: [String: Set] = [:] @State private var freeformByQuestion: [String: String] = [:] @State private var expandedPreviews: Set = [] + @FocusState private var freeformFocused: Bool private var isPaged: Bool { question.questions.count > 1 } private var activeQuestion: WorkPendingQuestion { @@ -699,12 +701,11 @@ struct WorkStructuredQuestionCard: View { ForEach(Array(question.questions.enumerated()), id: \.offset) { index, q in questionPage(q) .tag(index) - .padding(.bottom, 24) + .padding(.bottom, 4) } } - .tabViewStyle(.page(indexDisplayMode: .always)) - .indexViewStyle(.page(backgroundDisplayMode: .always)) - .frame(minHeight: 280) + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(minHeight: 240) } else { questionPage(activeQuestion) } @@ -716,6 +717,9 @@ struct WorkStructuredQuestionCard: View { footerRow } .adeGlassCard(cornerRadius: 18, padding: 14) + .onChange(of: freeformFocused) { _, focused in + onFreeformFocusChange?(focused) + } } @ViewBuilder @@ -797,10 +801,12 @@ struct WorkStructuredQuestionCard: View { let binding = freeformBinding(for: q) if q.isSecret { SecureField(q.options.isEmpty ? "Response" : "Optional response", text: binding) + .focused($freeformFocused) .adeInsetField(cornerRadius: 14, padding: 12) .disabled(busy) } else { TextField(q.options.isEmpty ? "Response" : "Optional response", text: binding, axis: .vertical) + .focused($freeformFocused) .lineLimit(1...4) .autocorrectionDisabled(false) .textInputAutocapitalization(.sentences) @@ -812,22 +818,45 @@ struct WorkStructuredQuestionCard: View { @ViewBuilder private var footerRow: some View { HStack(spacing: 10) { + Button("Decline") { + Task { await declineQuestion() } + } + .buttonStyle(.glass) + .tint(ADEColor.danger) + .disabled(busy) + + Spacer(minLength: 8) + + if isPaged { + pageIndicator + Spacer(minLength: 8) + } + Button(submitLabel) { Task { await submitAll() } } .buttonStyle(.glassProminent) .tint(ADEColor.accent) .disabled(busy || !canSubmit) + } + } - Spacer(minLength: 0) - - Button("Decline") { - Task { await declineQuestion() } + @ViewBuilder + private var pageIndicator: some View { + HStack(spacing: 6) { + ForEach(0.. some View { + func timelineEntryView(for entry: WorkTimelineEntry, proxy: ScrollViewProxy) -> some View { switch entry.payload { case .message(let message): WorkChatMessageBubble(message: message, isLive: isLatestAssistantMessageLive(message)) @@ -57,8 +57,21 @@ extension WorkChatSessionView { await runSessionAction { await onDeclineQuestion(question.id) } + }, + onFreeformFocusChange: { focused in + guard focused else { return } + // Wait for the keyboard to start animating in so the ScrollView's + // safe-area inset is updated before we ask it to scroll the focused + // card above the keyboard. + Task { @MainActor in + try? await Task.sleep(nanoseconds: 300_000_000) + withAnimation(.easeInOut(duration: 0.25)) { + proxy.scrollTo("pending-question-\(question.id)", anchor: .bottom) + } + } } ) + .id("pending-question-\(question.id)") case .pendingPermission(let permission): WorkPermissionCard( permission: permission, diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 655f5e8dd..551d46564 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -195,7 +195,7 @@ struct WorkChatSessionView: View { } @ViewBuilder - var timelineSection: some View { + func timelineSection(proxy: ScrollViewProxy) -> some View { if timeline.isEmpty { ADEEmptyStateView( symbol: "bubble.left.and.bubble.right", @@ -221,7 +221,7 @@ struct WorkChatSessionView: View { } ForEach(visibleTimeline) { entry in - timelineEntryView(for: entry) + timelineEntryView(for: entry, proxy: proxy) } } } @@ -383,7 +383,7 @@ struct WorkChatSessionView: View { if !timelineSnapshot.subagentSnapshots.isEmpty { WorkSubagentStrip(snapshots: timelineSnapshot.subagentSnapshots) } - timelineSection + timelineSection(proxy: proxy) streamingStatusSection Color.clear diff --git a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift index 47348f438..8cd625be7 100644 --- a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift +++ b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift @@ -61,7 +61,7 @@ struct WorkModelCatalogGroupLegacyView: Identifiable, Hashable { let models: [WorkModelOption] } -private let workModelGroupOrder = ["claude", "codex", "cursor", "opencode"] +private let workModelGroupOrder = ["claude", "codex", "cursor", "droid", "opencode"] /// Flat view of the curated catalog: every model in a single provider tab so /// legacy call sites keep functioning. Prefer `workModelCatalogGroups` for @@ -153,6 +153,61 @@ private func workCuratedModelCatalogGroups() -> [WorkModelCatalogGroup] { ] )) + groups.append(WorkModelCatalogGroup( + key: "droid", + displayName: "Droid", + providers: [ + WorkModelProvider( + key: "anthropic", + displayName: "Anthropic (Droid)", + models: [ + WorkModelOption(id: "claude-opus-4-6", displayName: "Opus 4.6 (2x)", tier: .flagship, tagline: "Flagship reasoning · 2x usage", provider: "claude"), + WorkModelOption(id: "claude-opus-4-6-fast", displayName: "Opus 4.6 Fast Mode (12x)", tier: .flagship, tagline: "Faster Opus · 12x usage", provider: "claude"), + WorkModelOption(id: "claude-opus-4-5-20251101", displayName: "Opus 4.5 (2x)", tier: .flagship, tagline: "Prior-gen Opus", provider: "claude"), + WorkModelOption(id: "claude-sonnet-4-6", displayName: "Sonnet 4.6 (1.2x)", tier: .balanced, tagline: "Balanced default", provider: "claude"), + WorkModelOption(id: "claude-sonnet-4-5-20250929", displayName: "Sonnet 4.5 (1.2x)", tier: .balanced, tagline: "Prior-gen Sonnet", provider: "claude"), + WorkModelOption(id: "claude-haiku-4-5-20251001", displayName: "Haiku 4.5 (0.4x)", tier: .fast, tagline: "Fastest Anthropic", provider: "claude"), + ] + ), + WorkModelProvider( + key: "openai", + displayName: "OpenAI (Droid)", + models: [ + WorkModelOption(id: "gpt-5.4", displayName: "GPT-5.4", tier: .flagship, tagline: "OpenAI flagship", provider: "codex"), + WorkModelOption(id: "gpt-5.4-fast", displayName: "GPT-5.4 Fast", tier: .flagship, tagline: "Faster GPT-5.4", provider: "codex"), + WorkModelOption(id: "gpt-5.4-mini", displayName: "GPT-5.4 Mini", tier: .fast, tagline: "Cheaper general-purpose", provider: "codex"), + WorkModelOption(id: "gpt-5.3-codex", displayName: "GPT-5.3-Codex (0.7x)", tier: .balanced, tagline: "Tuned for code edits", provider: "codex"), + WorkModelOption(id: "gpt-5.3-codex-fast", displayName: "GPT-5.3-Codex Fast", tier: .balanced, tagline: "Faster Codex variant", provider: "codex"), + WorkModelOption(id: "gpt-5.2", displayName: "GPT-5.2 (0.7x)", tier: .balanced, tagline: "Prior-gen GPT-5", provider: "codex"), + WorkModelOption(id: "gpt-5.2-codex", displayName: "GPT-5.2-Codex (0.7x)", tier: .balanced, tagline: "Prior-gen Codex", provider: "codex"), + WorkModelOption(id: "gpt-5.1", displayName: "GPT-5.1 (0.5x)", tier: .balanced, tagline: "Older GPT-5", provider: "codex"), + WorkModelOption(id: "gpt-5.1-codex", displayName: "GPT-5.1-Codex (0.5x)", tier: .balanced, tagline: "Older Codex", provider: "codex"), + WorkModelOption(id: "gpt-5.1-codex-max", displayName: "GPT-5.1-Codex-Max (0.5x)", tier: .flagship, tagline: "Long-running Codex turns", provider: "codex"), + ] + ), + WorkModelProvider( + key: "google", + displayName: "Google (Droid)", + models: [ + WorkModelOption(id: "gemini-3-pro-preview", displayName: "Gemini 3 Pro", tier: .flagship, tagline: "Google flagship", provider: "google"), + WorkModelOption(id: "gemini-3.1-pro-preview", displayName: "Gemini 3.1 Pro (0.8x)", tier: .flagship, tagline: "Updated Gemini 3", provider: "google"), + WorkModelOption(id: "gemini-3-flash-preview", displayName: "Gemini 3 Flash (0.2x)", tier: .fast, tagline: "Fast Gemini", provider: "google"), + ] + ), + WorkModelProvider( + key: "factory", + displayName: "Droid Core", + models: [ + WorkModelOption(id: "glm-5.1", displayName: "Droid Core (GLM-5.1)", tier: .balanced, tagline: "Latest Droid Core", provider: "factory"), + WorkModelOption(id: "glm-5", displayName: "Droid Core (GLM-5) (0.4x)", tier: .balanced, tagline: "GLM-5 backbone", provider: "factory"), + WorkModelOption(id: "glm-4.7", displayName: "Droid Core (GLM-4.7) (0.25x)", tier: .fast, tagline: "Lightweight GLM", provider: "factory"), + WorkModelOption(id: "kimi-k2.5", displayName: "Droid Core (Kimi K2.5) (0.25x)", tier: .fast, tagline: "Kimi backbone", provider: "factory"), + WorkModelOption(id: "minimax-m2.5", displayName: "Droid Core (MiniMax M2.5) (0.12x)", tier: .fast, tagline: "MiniMax backbone", provider: "factory"), + ] + ) + ] + )) + groups.append(WorkModelCatalogGroup( key: "opencode", displayName: "OpenCode", @@ -324,6 +379,7 @@ private func workProviderDisplayName( case "ollama": return "Ollama" case "together": return "Together" case "cursor": return "Cursor" + case "factory": return "Droid Core" default: return providerKey.capitalized } } @@ -361,6 +417,30 @@ private func workModelProviderKey(for model: AgentChatModelInfo, topLevelProvide return "anthropic" case "codex": return "openai" + case "droid": + // Prefer the explicit `family` field over ID substring matches so an + // Anthropic model whose ID happens to contain "codex" (or similar) is + // still routed to the right bucket. + switch normalizedFamily { + case "anthropic": return "anthropic" + case "openai": return "openai" + case "google": return "google" + case "factory": return "factory" + default: break + } + if normalizedId.hasPrefix("glm-") || normalizedId.hasPrefix("kimi-") || normalizedId.hasPrefix("minimax-") || normalizedId.hasPrefix("custom:") { + return "factory" + } + if normalizedId.contains("claude") || normalizedId.contains("sonnet") || normalizedId.contains("opus") || normalizedId.contains("haiku") { + return "anthropic" + } + if normalizedId.contains("gpt") || normalizedId.contains("codex") { + return "openai" + } + if normalizedId.contains("gemini") { + return "google" + } + return normalizedFamily.isEmpty ? "factory" : normalizedFamily case "opencode": if normalizedId.hasPrefix("opencode/") { let parts = normalizedId.split(separator: "/", omittingEmptySubsequences: true) @@ -538,6 +618,9 @@ func workModelCatalogGroupKey(for currentModelId: String, currentProvider: Strin if modelId.hasPrefix("opencode/") || provider == "opencode" { return "opencode" } + if provider == "droid" || provider == "factory" || modelId.hasPrefix("droid/") { + return "droid" + } if provider == "cursor" || modelId.contains("cursor/") || modelId.contains("cursor-") || modelId.contains("composer") { return "cursor" } diff --git a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift index 4ac45df34..4a436f0f9 100644 --- a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift @@ -33,7 +33,6 @@ struct WorkModelPickerSheet: View { @State private var activeGroup: String = "" @State private var activeProvider: String = "" @State private var searchText: String = "" - @State private var reasoningEffort: String = "" @State private var liveCatalog: [WorkModelCatalogGroup]? @State private var isLoadingCatalog = false @State private var usingCuratedFallback = false @@ -119,7 +118,6 @@ struct WorkModelPickerSheet: View { } else if isSearching { searchList } else { - reasoningRow groupTabStrip providerBadgeRow Divider().overlay(ADEColor.border.opacity(0.18)) @@ -145,9 +143,6 @@ struct WorkModelPickerSheet: View { .presentationDragIndicator(.visible) .onAppear { syncSelectionStateToCatalog() - if reasoningEffort.isEmpty { - reasoningEffort = currentReasoningEffort - } } .onChange(of: catalogIdentity) { _, _ in syncSelectionStateToCatalog() @@ -184,7 +179,7 @@ struct WorkModelPickerSheet: View { usingCuratedFallback = false liveCatalog = nil - let providers = ["claude", "codex", "cursor", "opencode"] + let providers = ["claude", "codex", "cursor", "droid", "opencode"] var availableModelsByProvider: [String: [AgentChatModelInfo]] = [:] var successCount = 0 @@ -259,15 +254,6 @@ struct WorkModelPickerSheet: View { return workModelCatalogGroupKey(for: model.id, currentProvider: currentProvider) } - private func reasoningEffortForSelection(_ model: WorkModelOption) -> String? { - let supportedTiers = supportedReasoningTiers(for: model) - if supportedTiers.isEmpty { return nil } - let trimmed = reasoningEffort.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return nil } - let normalized = trimmed.lowercased() - return supportedTiers.contains(where: { $0.lowercased() == normalized }) ? normalized : nil - } - private func supportedReasoningTiers(for model: WorkModelOption) -> [String] { if let tiers = ADEColor.reasoningTiers(for: model.id), !tiers.isEmpty { return tiers @@ -282,29 +268,11 @@ struct WorkModelPickerSheet: View { return ["low", "medium", "high"] } if lower.contains("gpt-5") { - return ["low", "medium", "high", "xhigh"] + return lower.contains("mini") ? ["medium", "high"] : ["low", "medium", "high", "xhigh"] } return [] } - private func reasoningLevelsForVisibleContext() -> [(String, String)] { - let visibleModels = filteredModels - let preferredOrder = ["low", "medium", "high", "xhigh", "max"] - var tierSet = Set() - for model in visibleModels { - for tier in supportedReasoningTiers(for: model) { - tierSet.insert(tier.lowercased()) - } - } - let current = reasoningEffort.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if !current.isEmpty { - tierSet.insert(current) - } - let orderedTiers = preferredOrder.filter { tierSet.contains($0) } - + tierSet.filter { !preferredOrder.contains($0) }.sorted() - return [("", "Off")] + orderedTiers.map { ($0, reasoningLabel(for: $0)) } - } - private func reasoningLabel(for tier: String) -> String { switch tier.lowercased() { case "xhigh": return "XHigh" @@ -313,6 +281,7 @@ struct WorkModelPickerSheet: View { } } + @ViewBuilder private var loadingState: some View { VStack(spacing: 12) { @@ -383,62 +352,6 @@ struct WorkModelPickerSheet: View { .padding(.bottom, 10) } - /// Reasoning-effort segmented control, displayed above the group/provider - /// tabs. Users pick the effort level here and it is applied to any - /// reasoning-capable model they subsequently tap in the list; for models - /// that don't accept the chosen tier the value is ignored at the call site. - @ViewBuilder - private var reasoningRow: some View { - let levels = reasoningLevelsForVisibleContext() - HStack(spacing: 8) { - Text("REASONING") - .font(.caption2.weight(.bold)) - .tracking(0.4) - .foregroundStyle(ADEColor.textMuted) - HStack(spacing: 4) { - ForEach(levels, id: \.0) { entry in - let (id, label) = entry - let isActive = id.lowercased() == reasoningEffort.lowercased() - Button { - withAnimation(.easeInOut(duration: 0.14)) { - reasoningEffort = id - } - } label: { - Text(label) - .font(.caption2.weight(.semibold)) - .foregroundStyle(isActive ? ADEColor.textPrimary : ADEColor.textSecondary.opacity(0.7)) - .lineLimit(1) - .minimumScaleFactor(0.75) - .frame(maxWidth: .infinity) - .padding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(isActive ? ADEColor.accent.opacity(0.18) : Color.clear) - ) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(isActive ? ADEColor.accent.opacity(0.35) : Color.clear, lineWidth: 0.6) - ) - } - .buttonStyle(.plain) - .accessibilityAddTraits(isActive ? .isSelected : []) - .accessibilityLabel("Reasoning effort \(label)") - } - } - .padding(3) - .background( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .fill(ADEColor.surfaceBackground.opacity(0.3)) - ) - .overlay( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .stroke(ADEColor.border.opacity(0.12), lineWidth: 0.5) - ) - } - .padding(.horizontal, 16) - .padding(.bottom, 10) - } - @ViewBuilder private var groupTabStrip: some View { HStack(spacing: 4) { @@ -502,7 +415,8 @@ struct WorkModelPickerSheet: View { @ViewBuilder private var providerBadgeRow: some View { - if let block = activeGroupBlock, block.providers.count > 1 || block.key == "opencode" { + if let block = activeGroupBlock, !singleFamilyGroup(block.key), + block.providers.count > 1 || block.key == "opencode" { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(block.providers) { prov in @@ -512,16 +426,16 @@ struct WorkModelPickerSheet: View { .padding(.horizontal, 16) .padding(.bottom, 10) } - } else if let block = activeGroupBlock, let only = block.providers.first { - HStack(spacing: 8) { - providerBadge(only) - Spacer(minLength: 0) - } - .padding(.horizontal, 16) - .padding(.bottom, 10) } } + /// Groups whose entries all come from a single brand (Claude, Codex) don't + /// need a redundant filter row beneath the group tab — every model is from + /// that brand by definition. + private func singleFamilyGroup(_ key: String) -> Bool { + key == "claude" || key == "codex" + } + @ViewBuilder private func providerBadge(_ prov: WorkModelProvider) -> some View { let isActive = activeProviderBlock?.key == prov.key @@ -639,24 +553,42 @@ struct WorkModelPickerSheet: View { @ViewBuilder private func modelButton(model: WorkModelOption) -> some View { - Button { - let reasoningToSend = reasoningEffortForSelection(model) - let reasoningChanged = (reasoningToSend ?? "") != currentReasoningEffort - if model.id == currentModelId && !reasoningChanged { - dismiss() - } else { - onSelect(model, reasoningToSend, runtimeProvider(for: model)) + let tiers = supportedReasoningTiers(for: model) + let isSelected = model.id == currentModelId + VStack(alignment: .leading, spacing: 0) { + // Card header is always tappable: tapping the header commits the model + // with `effort: nil` (server default) even for reasoning-capable models, + // so users who don't care about a specific tier aren't forced to pick one. + Button { + commit(model: model, effort: nil) + } label: { + modelHeaderRow(model: model, isSelected: isSelected) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isBusy) + + if !tiers.isEmpty { + reasoningPills(model: model, tiers: tiers) + .padding(.top, 2) } - } label: { - modelRow(model: model) } - .buttonStyle(.plain) - .disabled(isBusy) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(isSelected ? ADEColor.accent.opacity(0.08) : ADEColor.surfaceBackground.opacity(0.55)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(isSelected ? ADEColor.accent.opacity(0.35) : ADEColor.border.opacity(0.14), lineWidth: isSelected ? 1 : 0.5) + ) + .contentShape(Rectangle()) } @ViewBuilder - private func modelRow(model: WorkModelOption) -> some View { - let isSelected = model.id == currentModelId + private func modelHeaderRow(model: WorkModelOption, isSelected: Bool) -> some View { HStack(alignment: .center, spacing: 12) { WorkProviderLogo(provider: model.provider, size: 30) @@ -707,17 +639,71 @@ struct WorkModelPickerSheet: View { } } } - .padding(.horizontal, 14) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(isSelected ? ADEColor.accent.opacity(0.08) : ADEColor.surfaceBackground.opacity(0.55)) - ) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(isSelected ? ADEColor.accent.opacity(0.35) : ADEColor.border.opacity(0.14), lineWidth: isSelected ? 1 : 0.5) - ) - .contentShape(Rectangle()) .accessibilityLabel("\(model.displayName), \(workModelTierLabel(model.tier)). \(model.tagline)\(isSelected ? ". Currently selected." : "")") } + + /// Reasoning level pill row shown inline under a model card. Tapping a pill + /// commits both the model selection and the chosen effort. Highlights the + /// currently-active effort for the active model so users see what's set. + @ViewBuilder + private func reasoningPills(model: WorkModelOption, tiers: [String]) -> some View { + let isActiveModel = model.id == currentModelId + let normalizedCurrent = currentReasoningEffort + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + HStack(spacing: 6) { + Text("REASONING") + .font(.system(size: 9, weight: .bold)) + .tracking(0.4) + .foregroundStyle(ADEColor.textMuted) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 5) { + ForEach(tiers, id: \.self) { tier in + let normalized = tier.lowercased() + let isActive = isActiveModel && normalized == normalizedCurrent + Button { + commit(model: model, effort: normalized) + } label: { + Text(reasoningLabel(for: tier)) + .font(.caption2.weight(.semibold)) + .foregroundStyle(isActive ? Color.white : ADEColor.textSecondary) + .lineLimit(1) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background( + Capsule(style: .continuous) + .fill(isActive ? ADEColor.accent : ADEColor.surfaceBackground.opacity(0.6)) + ) + .overlay( + Capsule(style: .continuous) + .stroke(isActive ? ADEColor.accent : ADEColor.border.opacity(0.18), lineWidth: 0.6) + ) + } + .buttonStyle(.plain) + .disabled(isBusy) + .accessibilityLabel("\(model.displayName) · reasoning \(reasoningLabel(for: tier))") + .accessibilityAddTraits(isActive ? .isSelected : []) + } + } + } + Spacer(minLength: 0) + } + .padding(.top, 8) + } + + private func commit(model: WorkModelOption, effort: String?) { + let normalizedEffort = effort? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "" + let normalizedCurrentEffort = currentReasoningEffort + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let nextEffort: String? = normalizedEffort.isEmpty ? nil : normalizedEffort + let effortChanged = (nextEffort ?? "") != normalizedCurrentEffort + if model.id == currentModelId && !effortChanged { + dismiss() + return + } + onSelect(model, nextEffort, runtimeProvider(for: model)) + } } diff --git a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift index 269bfbc73..c370efe22 100644 --- a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift @@ -684,6 +684,9 @@ func buildWorkToolCards( if suppressedPendingItemIds.contains(itemId) { continue } + // Suppress only when the structured-question payload parses cleanly. If + // parsing fails (malformed args), fall through and render the raw tool + // card so the user can see what the model emitted. if isQuestionInputToolName(tool), pendingWorkQuestionFromAskUserToolCall(argsText: argsText, itemId: itemId) != nil { continue @@ -701,6 +704,13 @@ func buildWorkToolCards( resultText: cards[itemId]?.resultText ) case .toolResult(let tool, let resultText, let itemId, _, _, let status): + // Skip results only when the corresponding call was intentionally + // suppressed as a structured-question card (no fallback card exists). + // If a fallback tool card was kept (malformed args), let the result + // update it. + if isQuestionInputToolName(tool), cards[itemId] == nil { + continue + } let existing = cards[itemId] if existing == nil { orderedIds.append(itemId) diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index 4e357d668..4cd70625e 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -48,6 +48,7 @@ func providerLabel(_ provider: String) -> String { case "claude": return "Claude" case "opencode": return "OpenCode" case "cursor": return "Cursor" + case "droid": return "Droid" default: return provider.capitalized } } @@ -117,6 +118,12 @@ func providerTint(_ provider: String?) -> Color { return .teal case "cursor": return .indigo + case "droid": + return .gray + case "google": + return .yellow + case "factory": + return .gray default: return ADEColor.accent } @@ -138,6 +145,9 @@ func providerFamilyKey(_ provider: String) -> String { if raw == "cursor" || raw.hasPrefix("cursor") { return "cursor" } + if raw == "droid" || raw == "factory" || raw.hasPrefix("droid") { + return "droid" + } return raw } diff --git a/docs/playbooks/ship-lane.md b/docs/playbooks/ship-lane.md index bf5246289..0c78ac587 100644 --- a/docs/playbooks/ship-lane.md +++ b/docs/playbooks/ship-lane.md @@ -620,7 +620,14 @@ These are separate comments (not a single body) so each bot handler parses its o ### 5.3 Self-pace the next wake -Agent-CLI-agnostic guidance (Claude Code maps this to `ScheduleWakeup`; Codex in a terminal should usually use shell `sleep ... && `; other CLIs map it to their native sleep/resume): +Agent-CLI-agnostic guidance. Pick the right primitive for the harness: + +- **Claude Code CLI** maps this to `ScheduleWakeup` (CLI scheduler re-invokes the command later). +- **Claude Agent SDK v2** (e.g. ADE Work chats using `unstable_v2_createSession`) has **no scheduled-wakeup primitive**. `SDKSession` only advances when the host calls `send(...)`, which fires on a fresh user message. `run_in_background` bash `task_notification` events are queued in the SDK message stream until the next user turn — they will not start an autonomous turn. In an SDK chat, either poll synchronously inside the current turn (foreground bash with one bounded `until ... ; do sleep N; done`) or stop with `status: running` written to the state file and ask the user to re-invoke the command. +- **Codex in a terminal** should usually use shell `sleep ... && `. +- **Other CLIs** map this to their native sleep/resume. + +Cadence (applies once you've picked a primitive): - Just pushed, neither CI nor review has started yet → **270 seconds** (stay in prompt cache; next poll only confirms things have kicked off) - CI running OR review bots still pending → **720 seconds** (12 min). This is the spec floor: CI shards typically finish in 3–5 min, Greptile in 5–10 min, Copilot within a few minutes of its ping. 12 min is what lets **both** land before the next poll.