Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions .claude/commands/shipLane.md
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -90,16 +94,30 @@ 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
```

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.

---
Expand All @@ -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/<you>/Projects/<repo>/.ade/worktrees/<lane>/`. The project root (`/Users/<you>/Projects/<repo>/`) **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/<lane>/`, every Edit `file_path` must start with `.../.ade/worktrees/<lane>/`. 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 <project-root> && git diff > /tmp/x.patch && git checkout -- <files>`, then `git apply /tmp/x.patch` from the worktree.
- Stay in the worktree directory. Use `git -C <project-root> ...` for one-off project-root reads instead of `cd <project-root>`, 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:
Expand Down
38 changes: 38 additions & 0 deletions apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
53 changes: 53 additions & 0 deletions apps/desktop/src/main/services/ai/tools/systemPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
];
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 ... && <one-shot command>` 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":
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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),
Expand Down
15 changes: 12 additions & 3 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2637,6 +2637,7 @@ function buildCodexDeveloperInstructions(args: {
mode: promptMode,
permissionMode: toHarnessPermissionMode(args.session.permissionMode),
interactive: true,
runtime: "codex-cli",
});
}

Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 11 additions & 3 deletions apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 40 additions & 11 deletions apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>] = [:]
@State private var freeformByQuestion: [String: String] = [:]
@State private var expandedPreviews: Set<String> = []
@FocusState private var freeformFocused: Bool

private var isPaged: Bool { question.questions.count > 1 }
private var activeQuestion: WorkPendingQuestion {
Expand All @@ -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)
}
Expand All @@ -716,6 +717,9 @@ struct WorkStructuredQuestionCard: View {
footerRow
}
.adeGlassCard(cornerRadius: 18, padding: 14)
.onChange(of: freeformFocused) { _, focused in
onFreeformFocusChange?(focused)
}
}

@ViewBuilder
Expand Down Expand Up @@ -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)
Expand All @@ -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..<question.questions.count, id: \.self) { index in
Button {
withAnimation(.easeInOut(duration: 0.18)) { currentPage = index }
} label: {
Circle()
.fill(index == currentPage ? ADEColor.accent : ADEColor.textMuted.opacity(0.35))
.frame(width: index == currentPage ? 7 : 6, height: index == currentPage ? 7 : 6)
}
.buttonStyle(.plain)
.accessibilityLabel("Question \(index + 1) of \(question.questions.count)")
}
.buttonStyle(.glass)
.tint(ADEColor.danger)
.disabled(busy)
}
.padding(.horizontal, 4)
}

private var submitLabel: String {
Expand Down
Loading
Loading