diff --git a/.ade/.gitignore b/.ade/.gitignore
index 19685b6ff..01224321c 100644
--- a/.ade/.gitignore
+++ b/.ade/.gitignore
@@ -20,6 +20,10 @@ cto/core-memory.json
cto/daily/
cto/sessions.jsonl
cto/subordinate-activity.jsonl
+cto/openclaw-history.json
+cto/openclaw-idempotency.json
+cto/openclaw-outbox.json
+cto/openclaw-routes.json
cto/openclaw-device.json
context/
memory/
diff --git a/.ade/cto/openclaw-history.json b/.ade/cto/openclaw-history.json
deleted file mode 100644
index fe51488c7..000000000
--- a/.ade/cto/openclaw-history.json
+++ /dev/null
@@ -1 +0,0 @@
-[]
diff --git a/.ade/cto/openclaw-idempotency.json b/.ade/cto/openclaw-idempotency.json
deleted file mode 100644
index 0967ef424..000000000
--- a/.ade/cto/openclaw-idempotency.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/.ade/cto/openclaw-outbox.json b/.ade/cto/openclaw-outbox.json
deleted file mode 100644
index fe51488c7..000000000
--- a/.ade/cto/openclaw-outbox.json
+++ /dev/null
@@ -1 +0,0 @@
-[]
diff --git a/.ade/cto/openclaw-routes.json b/.ade/cto/openclaw-routes.json
deleted file mode 100644
index e52cc3a0c..000000000
--- a/.ade/cto/openclaw-routes.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "byAgentId": {}
-}
diff --git a/.claude/commands/automate.md b/.claude/commands/automate.md
index 5ba5f9d19..9dc1d00d1 100644
--- a/.claude/commands/automate.md
+++ b/.claude/commands/automate.md
@@ -14,6 +14,16 @@ You are the Test Automation agent for the ADE (Agentic Development Environment)
**Arguments:** $ARGUMENTS
+## Execution Mode: Autonomous
+
+This command runs end-to-end without user interaction. Do NOT:
+- Ask the user to confirm, choose, or approve anything.
+- Pause between phases to request direction.
+- Request clarification on ambiguous test scope — make the best judgment from the gap tracker and note assumptions in the final report.
+- Stop on non-fatal warnings — log them and continue.
+
+Only produce the Phase 7 summary and any fatal error messages (e.g., cannot create a meaningful test). Every decision is made by the agent based on the rules in this file.
+
---
## Pipeline Overview
@@ -61,6 +71,32 @@ Categorize changes:
Copy their patterns exactly for: imports, setup/teardown, mocking, assertions, describe/it nesting.
+### 1.2.5 Read the feature doc for context
+
+Before writing any test, skim the relevant internal feature doc so you know what behavior is load-bearing vs incidental. Docs live under `docs/features//`:
+
+| Changed source area | Feature doc |
+|---|---|
+| services/orchestrator/ or renderer missions/ | docs/features/missions/ |
+| services/prs/ or renderer prs/ | docs/features/pull-requests/ |
+| services/lanes/ or renderer lanes/ | docs/features/lanes/ |
+| services/chat/ or services/ai/ or renderer chat/ | docs/features/chat/ + features/agents/ |
+| services/cto/ or renderer cto/ | docs/features/cto/ + features/linear-integration/ |
+| services/memory/ | docs/features/memory/ |
+| services/automations/ or renderer automations/ | docs/features/automations/ |
+| services/conflicts/ | docs/features/conflicts/ |
+| services/computerUse/ | docs/features/computer-use/ |
+| services/pty/ or sessions/ or processes/ or renderer terminals/ | docs/features/terminals-and-sessions/ |
+| services/files/ or renderer files/ | docs/features/files-and-editor/ |
+| services/sync/ or syncRemoteCommandService | docs/features/sync-and-multi-device/ |
+| services/onboarding/ or services/config/ or renderer settings/ | docs/features/onboarding-and-settings/ |
+| services/history/ | docs/features/history/ |
+| services/context/ | docs/features/context-packs/ |
+
+Each `README.md` has a "Source file map" at the top, plus "gotchas / fragile areas" prose. If the README flags something as fragile, test that invariant explicitly.
+
+Cross-cutting: `docs/ARCHITECTURE.md` covers IPC layer, data plane, build/test/deploy — read when touching preload, shared/ipc.ts, or registerIpc.
+
### 1.3 Key Test Infrastructure
**Desktop app:**
diff --git a/.claude/commands/finalize.md b/.claude/commands/finalize.md
index 896fa3686..99edd4bde 100644
--- a/.claude/commands/finalize.md
+++ b/.claude/commands/finalize.md
@@ -14,6 +14,17 @@ It guarantees three outcomes:
**Usage:** `/finalize`
+## Execution Mode: Autonomous
+
+This command runs end-to-end without user interaction. Do NOT:
+- Ask the user to confirm, choose, or approve anything.
+- Pause between phases to request direction.
+- Stop on non-fatal warnings — log them and continue.
+- Request clarification on ambiguous simplifications — skip the risky ones and note in the final report.
+- Ask before reverting your own work (e.g., Phase 3i drift check reverts simplifier edits silently).
+
+The only outputs are the Phase 4 summary and any error messages for genuinely fatal failures (typecheck/lint errors, build crashes, test failures the agent itself caused). Every decision is made by the agent based on the rules in this file.
+
## Pipeline Overview
```
@@ -68,52 +79,109 @@ Spawn agents in parallel using the **Agent** tool:
### Simplifier agents (1-3 based on batch size)
-Use `subagent_type: "code-simplifier"` for each batch.
+Use `subagent_type: "code-simplifier:code-simplifier"` for each batch (note the full namespaced form — plain `"code-simplifier"` is not a valid agent type).
Prompt each with:
- The list of files in their batch
- Branch context (what feature/area was changed)
- Instructions: focus on recently modified code, don't refactor untouched code
+- **Explicit safety rule**: before removing code that looks dead (unused helpers, "unused" local components, stale state), grep for references **including the file's colocated `*.test.ts(x)` neighbor**. Test expectations often lag behind feature refactors — removing "unused" code can silently break a test suite that will only light up in Phase 3e. When in doubt, leave it and note in the report.
+- **Diff-only scope**: `git diff main -- ` first; if zero diff, do not edit (a previous run tried to simplify files it thought were modified, and wasted time on unchanged code).
+- **Typecheck after every file**: `cd apps/desktop && npx tsc --noEmit -p . 2>&1 | head -20`.
### Doc updater agent
+The internal docs live under `docs/` with this structure (rebuilt; do NOT confuse with the public Mintlify site at repo root `docs.json` + `*.mdx`):
+
+```
+docs/
+├── README.md # navigation map
+├── PRD.md # product entry point — links to every feature
+├── ARCHITECTURE.md # consolidated system architecture
+├── OPTIMIZATION_OPPORTUNITIES.md # backlog (append-only)
+└── features/
+ ├── agents/ ├── memory/
+ ├── automations/ ├── missions/
+ ├── chat/ ├── onboarding-and-settings/
+ ├── computer-use/ ├── project-home/
+ ├── conflicts/ ├── pull-requests/
+ ├── context-packs/ ├── sync-and-multi-device/
+ ├── cto/ ├── terminals-and-sessions/
+ ├── files-and-editor/ └── workspace-graph/
+ ├── history/
+ ├── lanes/
+ └── linear-integration/
+```
+
+Each `features//` contains a `README.md` (overview + source file map at top) plus 1–4 detail `*.md` files.
+
Spawn a general-purpose agent with this prompt:
```
You are the documentation updater for the ADE project.
-Analyze all changes on the current branch vs main and update relevant documentation.
+Analyze all changes on the current branch vs main and update relevant internal
+docs under `docs/`. The public Mintlify site (docs.json + root-level .mdx files)
+is out of scope — do NOT touch it.
Step 1: Get changed files
git diff main --name-only
git diff main --stat | tail -30
-Step 2: Identify affected docs
-
-Map changed source directories to documentation:
-
-| Source Directory | Doc Location |
-|-----------------|--------------|
-| apps/desktop/src/main/services/orchestrator/ | docs/architecture/, docs/features/ |
-| apps/desktop/src/main/services/prs/ | docs/features/ (PR-related) |
-| apps/desktop/src/main/services/lanes/ | docs/features/ (lanes-related) |
-| apps/desktop/src/main/services/memory/ | docs/features/ (memory-related) |
-| apps/desktop/src/main/services/cto/ | docs/features/ (CTO/Linear-related) |
-| apps/desktop/src/main/services/ai/ | docs/architecture/ (AI integration) |
-| apps/desktop/src/renderer/components/ | docs/features/ (UI-related) |
-| apps/mcp-server/ | docs/ (MCP-related) |
-| .github/workflows/ | docs/ (CI/CD-related) |
-
-Step 3: Update docs
-- Rewrite sections to reflect current reality (not what changed)
-- Remove outdated information
-- Update code examples, file paths, API references
-- Do NOT add changelog sections or "Updated on X" notes
-- Do NOT create new doc files unless absolutely necessary
-
-Step 4: Run doc validation
+Step 2: Map changed source to internal docs
+
+| Source Directory | Doc Location |
+|----------------------------------------------------|----------------------------------------------------|
+| apps/desktop/src/main/services/orchestrator/ | docs/features/missions/ |
+| apps/desktop/src/main/services/prs/ | docs/features/pull-requests/ |
+| apps/desktop/src/main/services/lanes/ | docs/features/lanes/ |
+| apps/desktop/src/main/services/memory/ | docs/features/memory/ |
+| apps/desktop/src/main/services/cto/ | docs/features/cto/ (+ linear-integration/) |
+| apps/desktop/src/main/services/ai/ | docs/features/chat/ + features/agents/ |
+| apps/desktop/src/main/services/chat/ | docs/features/chat/ |
+| apps/desktop/src/main/services/automations/ | docs/features/automations/ |
+| apps/desktop/src/main/services/computerUse/ | docs/features/computer-use/ |
+| apps/desktop/src/main/services/context/ | docs/features/context-packs/ |
+| apps/desktop/src/main/services/conflicts/ | docs/features/conflicts/ |
+| apps/desktop/src/main/services/files/ | docs/features/files-and-editor/ |
+| apps/desktop/src/main/services/history/ | docs/features/history/ |
+| apps/desktop/src/main/services/onboarding/ | docs/features/onboarding-and-settings/ |
+| apps/desktop/src/main/services/pty/ | docs/features/terminals-and-sessions/ |
+| apps/desktop/src/main/services/sessions/ | docs/features/terminals-and-sessions/ |
+| apps/desktop/src/main/services/processes/ | docs/features/terminals-and-sessions/ |
+| apps/desktop/src/main/services/sync/ | docs/features/sync-and-multi-device/ |
+| apps/desktop/src/main/services/config/ | docs/features/onboarding-and-settings/ |
+| apps/desktop/src/main/services/ipc/ | docs/ARCHITECTURE.md (IPC section) |
+| apps/desktop/src/main/services/git/ | docs/ARCHITECTURE.md (Git engine section) + lanes/ |
+| apps/desktop/src/preload/ | docs/ARCHITECTURE.md (IPC contract) |
+| apps/desktop/src/shared/ | docs/ARCHITECTURE.md + touching feature's doc |
+| apps/desktop/src/renderer/components// | docs/features// |
+| apps/desktop/src/renderer/state/ | docs/ARCHITECTURE.md (UI framework) |
+| apps/mcp-server/ | docs/ARCHITECTURE.md + features/linear-integration/|
+| .github/workflows/ | docs/ARCHITECTURE.md (Build/Test/Deploy) |
+| apps/ios/ | docs/features/sync-and-multi-device/ios-companion.md |
+| apps/web/ | docs/ARCHITECTURE.md (Apps & Processes) |
+
+Step 3: Update docs in place
+- Prefer editing existing docs over creating new ones.
+- If a feature gets a genuinely new sub-concept worth its own page, add a new detail doc inside the existing features// folder.
+- Keep each README.md's "Source file map" section current — it is the primary way an agent orients itself.
+- Rewrite prose to reflect current reality (not a changelog of what changed).
+- Remove outdated information.
+- Do NOT add changelog sections, "Updated on X" notes, or dated markers.
+- Do NOT modify docs/OPTIMIZATION_OPPORTUNITIES.md via this agent — it is append-only and human-curated.
+
+Step 4: Append-only — NEVER touch the public Mintlify site
+- Do NOT modify docs.json or any *.mdx file at repo root.
+- Do NOT modify ./chat/, ./tools/, ./missions/, ./changelog/, ./configuration/, ./computer-use/, ./context-packs/, ./getting-started/, ./guides/, ./automations/, ./lanes/, ./cto/ (these are Mintlify pages).
+
+Step 5: Run doc validation
node scripts/validate-docs.mjs
+This validator only covers the Mintlify site. For internal docs, self-check:
+ - Every features//README.md still has a "Source file map" section.
+ - PRD.md links resolve (grep for broken relative links).
+
Report what docs were updated and what was changed.
```
@@ -207,8 +275,41 @@ cd apps/web && npm run build
node scripts/validate-docs.mjs
```
+This only validates the public Mintlify site (`docs.json` + `.mdx`). Also run these automated checks for the internal `docs/` tree:
+
+```bash
+# Every features//README.md has a "Source file map" section.
+for d in docs/features/*/README.md; do
+ grep -q "Source file map" "$d" || echo "MISSING map: $d"
+done
+
+# PRD.md links resolve.
+grep -oE "\[.*\]\([^)]+\.md\)" docs/PRD.md | \
+ sed -E 's/.*\(([^)]+)\).*/\1/' | \
+ while read -r p; do
+ test -f "docs/$p" || echo "BROKEN LINK: $p"
+ done
+```
+
+Both commands should produce empty output. Any `MISSING map:` or `BROKEN LINK:` line is a failure — fix the offending doc and re-run. Do not prompt the user; resolve autonomously.
+
All checks must pass. If any fail, fix and re-run only the failed step.
+### 3i. Test-simplifier drift check (catch Phase 2 over-reach)
+
+When a simplifier agent removed "unused" code, the colocated test may still reference it — the test will only light up in Phase 3e. If a test failure appears **only in a file the simplifier touched (or its test sibling)**, treat it as suspect:
+
+```bash
+# Files the simplifier touched this run:
+# (Run once before Phase 2; diff after to see what changed.)
+git diff main --name-only | sort > /tmp/finalize-branch-files.txt
+
+# After Phase 2, list what changed in this session on top of the prior branch state:
+git diff --name-only | sort > /tmp/finalize-session-files.txt
+```
+
+If Phase 3e fails only inside files the simplifier touched, revert the simplifier's edits to those files and re-run. Do NOT rewrite the test suite in Phase 3 — tests that drift because the feature branch refactored UI are a separate follow-up.
+
---
## Phase 4: Summary
diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts
index 2553284b3..59d8d5aae 100644
--- a/apps/desktop/src/main/main.ts
+++ b/apps/desktop/src/main/main.ts
@@ -248,7 +248,7 @@ async function createWindow(logger?: Logger): Promise {
? "'self' http://localhost:* http://127.0.0.1:*"
: "'self' file: app:";
const cspWsSources = isDevMode ? " ws://localhost:* ws://127.0.0.1:*" : "";
- const cspImageSources = `${cspSources} https://avatars.githubusercontent.com https://*.githubusercontent.com https://github.githubassets.com https://opengraph.githubassets.com https://github.com https://vercel.com https://*.vercel.com https://img.shields.io`;
+ const cspImageSources = `${cspSources} https://avatars.githubusercontent.com https://*.githubusercontent.com https://github.githubassets.com https://opengraph.githubassets.com https://github.com https://vercel.com https://*.vercel.com https://img.shields.io https://*.s3.amazonaws.com`;
const cspPolicy = [
`default-src ${cspSources}`,
`base-uri 'self'`,
@@ -1120,6 +1120,9 @@ app.whenReady().then(async () => {
});
const sessionService = createSessionService({ db });
+ sessionService.onChanged((event) => {
+ emitProjectEvent(projectRoot, IPC.sessionsChanged, event);
+ });
const reconciledSessions = sessionService.reconcileStaleRunningSessions({
status: "disposed",
excludeToolTypes: ["claude-chat", "codex-chat", "opencode-chat", "cursor"],
@@ -1460,16 +1463,23 @@ app.whenReady().then(async () => {
},
});
- const processService = createProcessService({
- db,
- projectId,
- processLogsDir: adePaths.processLogsDir,
- logger,
- laneService,
- projectConfigService,
- broadcastEvent: (ev) =>
- emitProjectEvent(projectRoot, IPC.processesEvent, ev),
- });
+ const getLaneRuntimeEnv = async (laneId: string) => {
+ const lease = portAllocationService.getLease(laneId);
+ const lane = (await laneService.list({ includeArchived: false, includeStatus: false })).find(
+ (entry) => entry.id === laneId,
+ );
+ const hostname = laneProxyService.getRoute(laneId)?.hostname
+ ?? laneProxyService.generateHostname(laneId, lane?.name);
+ const portStart = lease?.rangeStart ?? 3000;
+ const portEnd = lease?.rangeEnd ?? portStart;
+ return {
+ PORT: String(portStart),
+ PORT_RANGE_START: String(portStart),
+ PORT_RANGE_END: String(portEnd),
+ HOSTNAME: hostname,
+ PROXY_HOSTNAME: hostname,
+ };
+ };
const onTrackedSessionEnded = ({
laneId,
@@ -1515,6 +1525,7 @@ app.whenReady().then(async () => {
sessionService,
aiIntegrationService,
projectConfigService,
+ getLaneRuntimeEnv,
logger,
broadcastData: (ev) => {
emitProjectEvent(projectRoot, IPC.ptyData, ev);
@@ -1531,6 +1542,19 @@ app.whenReady().then(async () => {
loadPty,
});
+ const processService = createProcessService({
+ db,
+ projectId,
+ logger,
+ laneService,
+ projectConfigService,
+ sessionService,
+ ptyService,
+ getLaneRuntimeEnv,
+ broadcastEvent: (ev) =>
+ emitProjectEvent(projectRoot, IPC.processesEvent, ev),
+ });
+
const sessionDeltaService = createSessionDeltaService({
db,
projectId,
diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts
index ff690494a..0aaf8b384 100644
--- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts
+++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts
@@ -1,6 +1,17 @@
-import { describe, expect, it, vi } from "vitest";
+import { describe, expect, it, vi, beforeEach } from "vitest";
import { createCtoOperatorTools, type CtoOperatorToolDeps } from "./ctoOperatorTools";
+// Mock only execFileSync on node:child_process so searchCodebase can exercise it deterministically.
+// Preserve the rest of the module (e.g. spawn, exec) for unrelated tests.
+const execFileSyncMock = vi.fn();
+vi.mock("node:child_process", async () => {
+ const actual = await vi.importActual("node:child_process");
+ return {
+ ...actual,
+ execFileSync: (...args: unknown[]) => execFileSyncMock(...args),
+ };
+});
+
const baseSession = {
id: "chat-1",
laneId: "lane-1",
@@ -1731,4 +1742,223 @@ describe("createCtoOperatorTools", () => {
expect(result).toMatchObject({ success: false, error: expect.stringContaining("File service") });
});
});
+
+ // ── searchCodebase tool ─────────────────────────────────────────
+ describe("searchCodebase tool (execFileSync-backed rg)", () => {
+ beforeEach(() => {
+ execFileSyncMock.mockReset();
+ });
+
+ function getTool() {
+ const deps = buildDeps();
+ const tools = createCtoOperatorTools(deps);
+ const tool = tools.searchCodebase as any;
+ expect(tool, "searchCodebase tool must be registered").toBeTruthy();
+ return tool;
+ }
+
+ it("invokes execFileSync('rg', argv, opts) with the expected argv order and cwd", async () => {
+ execFileSyncMock.mockReturnValue("");
+ const tool = getTool();
+
+ await tool.execute({
+ pattern: "spawnChat",
+ fileGlob: "services/**/*.ts",
+ maxResults: 3,
+ contextLines: 2,
+ });
+
+ expect(execFileSyncMock).toHaveBeenCalledTimes(1);
+ const [cmd, argv, opts] = execFileSyncMock.mock.calls[0];
+ expect(cmd, "command must be rg, not a shell string").toBe("rg");
+ expect(Array.isArray(argv), "argv must be an array (execFileSync form, not shell)").toBe(true);
+ expect(argv).toEqual([
+ "--no-heading",
+ "--line-number",
+ "--max-count=3",
+ "--context=2",
+ "--glob",
+ "services/**/*.ts",
+ "--",
+ "spawnChat",
+ ".",
+ ]);
+ const optsRec = opts as Record;
+ expect(optsRec.encoding).toBe("utf8");
+ expect(optsRec.maxBuffer).toBe(512 * 1024);
+ expect(optsRec.timeout).toBe(10_000);
+ expect(typeof optsRec.cwd, "cwd must be a string path to ADE root").toBe("string");
+ expect((optsRec.cwd as string).length, "cwd path must be non-empty").toBeGreaterThan(0);
+ });
+
+ it("defaults fileGlob to '*.ts' when not provided (executor-level fallback)", async () => {
+ execFileSyncMock.mockReturnValue("");
+ const tool = getTool();
+
+ await tool.execute({ pattern: "foo", contextLines: 2 });
+
+ const argv = execFileSyncMock.mock.calls[0][1] as string[];
+ const globIdx = argv.indexOf("--glob");
+ expect(globIdx, "--glob must appear").toBeGreaterThanOrEqual(0);
+ expect(argv[globIdx + 1]).toBe("*.ts");
+ });
+
+ it("empty/whitespace fileGlob falls back to '*.ts'", async () => {
+ execFileSyncMock.mockReturnValue("");
+ const tool = getTool();
+
+ await tool.execute({ pattern: "foo", fileGlob: " ", contextLines: 2 });
+
+ const argv = execFileSyncMock.mock.calls[0][1] as string[];
+ const globIdx = argv.indexOf("--glob");
+ expect(argv[globIdx + 1]).toBe("*.ts");
+ });
+
+ it("dedupes output lines by filename so duplicate-file matches do not exhaust maxResults", async () => {
+ // Same file a.ts has 3 matches, then b.ts has 1, then c.ts has 1.
+ // With maxResults=2 we should still include a.ts + b.ts (2 unique files) but NOT c.ts.
+ const rgOutput = [
+ "a.ts:10:first",
+ "a.ts:20:second",
+ "a.ts:30:third",
+ "b.ts:5:first",
+ "c.ts:1:first",
+ ].join("\n");
+ execFileSyncMock.mockReturnValue(rgOutput);
+ const tool = getTool();
+
+ const result = await tool.execute({
+ pattern: "xyz",
+ maxResults: 2,
+ contextLines: 0,
+ });
+
+ expect(result.success).toBe(true);
+ // Output must contain a.ts and b.ts content but NOT c.ts (dedup stops before adding c.ts).
+ expect(result.output).toContain("a.ts:10:first");
+ expect(result.output).toContain("a.ts:20:second");
+ expect(result.output).toContain("a.ts:30:third");
+ expect(result.output).toContain("b.ts:5:first");
+ expect(result.output, "maxResults=2 should cut c.ts out").not.toContain("c.ts:1:first");
+ expect(result.truncated, "hitting maxResults must mark result truncated").toBe(true);
+ });
+
+ it("marks truncated=true when unique-file count reaches maxResults", async () => {
+ const rgOutput = ["a.ts:1:m", "b.ts:1:m", "c.ts:1:m"].join("\n");
+ execFileSyncMock.mockReturnValue(rgOutput);
+ const tool = getTool();
+
+ const result = await tool.execute({
+ pattern: "m",
+ maxResults: 3,
+ contextLines: 0,
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.truncated).toBe(true);
+ });
+
+ it("marks truncated=true when output exceeds 200 lines", async () => {
+ // 210 matches from distinct files so no dedup cap trips before the 200-line cap.
+ const lines: string[] = [];
+ for (let i = 0; i < 210; i += 1) {
+ lines.push(`file${i}.ts:1:match`);
+ }
+ execFileSyncMock.mockReturnValue(lines.join("\n"));
+ const tool = getTool();
+
+ const result = await tool.execute({
+ pattern: "match",
+ maxResults: 30,
+ contextLines: 0,
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.truncated, "output over 200 lines must be truncated").toBe(true);
+ expect(result.output.split("\n").length).toBeLessThanOrEqual(200);
+ });
+
+ it("returns success with matchCount=0 and empty output when rg exits with status 1 (no matches)", async () => {
+ const err: any = new Error("rg exit 1");
+ err.status = 1;
+ execFileSyncMock.mockImplementation(() => {
+ throw err;
+ });
+ const tool = getTool();
+
+ const result = await tool.execute({ pattern: "never-matches", contextLines: 0 });
+
+ expect(result).toMatchObject({
+ success: true,
+ matchCount: 0,
+ truncated: false,
+ });
+ expect(typeof result.output).toBe("string");
+ });
+
+ it("returns success=false with error message for non-1 execution failures", async () => {
+ const err: any = new Error("boom");
+ err.status = 2;
+ execFileSyncMock.mockImplementation(() => {
+ throw err;
+ });
+ const tool = getTool();
+
+ const result = await tool.execute({ pattern: "[bad-regex", contextLines: 0 });
+
+ expect(result.success).toBe(false);
+ expect(typeof result.error).toBe("string");
+ expect(result.error.length, "error message must not be empty").toBeGreaterThan(0);
+ });
+
+ it("truncates pattern to 500 chars before passing to argv", async () => {
+ execFileSyncMock.mockReturnValue("");
+ const tool = getTool();
+ const longPattern = "x".repeat(700);
+
+ await tool.execute({ pattern: longPattern, contextLines: 0 });
+
+ const argv = execFileSyncMock.mock.calls[0][1] as string[];
+ // The pattern sits at the position right after "--" separator.
+ const sep = argv.indexOf("--");
+ expect(sep, "-- separator must be present").toBeGreaterThan(0);
+ const sentPattern = argv[sep + 1];
+ expect(sentPattern.length, "pattern must be capped at 500 chars").toBe(500);
+ expect(sentPattern).toBe("x".repeat(500));
+ });
+
+ it("truncates fileGlob to 200 chars before passing to argv", async () => {
+ execFileSyncMock.mockReturnValue("");
+ const tool = getTool();
+ const longGlob = "*".repeat(300);
+
+ await tool.execute({ pattern: "p", fileGlob: longGlob, contextLines: 0 });
+
+ const argv = execFileSyncMock.mock.calls[0][1] as string[];
+ const globIdx = argv.indexOf("--glob");
+ expect(globIdx).toBeGreaterThanOrEqual(0);
+ const sentGlob = argv[globIdx + 1];
+ expect(sentGlob.length, "fileGlob must be capped at 200 chars").toBe(200);
+ expect(sentGlob).toBe("*".repeat(200));
+ });
+
+ it("passes dangerous shell characters through argv verbatim (execFileSync avoids shell injection)", async () => {
+ execFileSyncMock.mockReturnValue("");
+ const tool = getTool();
+ const evil = "foo; rm -rf / && echo pwned `whoami` $(id)";
+
+ await tool.execute({ pattern: evil, contextLines: 0 });
+
+ const [cmd, argv] = execFileSyncMock.mock.calls[0];
+ expect(cmd, "must call rg directly, not through a shell").toBe("rg");
+ expect(Array.isArray(argv), "argv must be an array (no shell string)").toBe(true);
+ const sep = (argv as string[]).indexOf("--");
+ const sentPattern = (argv as string[])[sep + 1];
+ expect(sentPattern, "dangerous chars must be passed through as a literal argv element").toBe(evil);
+ // Make sure no arg was concatenated into a shell string.
+ for (const a of argv as string[]) {
+ expect(typeof a).toBe("string");
+ }
+ });
+ });
});
diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts
index dbee718e0..334c0ec63 100644
--- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts
+++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts
@@ -1,6 +1,7 @@
+import path from "node:path";
import { executableTool as tool, type ExecutableTool as Tool } from "./executableTool";
import { z } from "zod";
-import { getModelById, resolveChatProviderForDescriptor } from "../../../../shared/modelRegistry";
+import { getModelById, resolveModelDescriptor, resolveChatProviderForDescriptor } from "../../../../shared/modelRegistry";
import type {
AgentChatCreateArgs,
AgentChatInterruptArgs,
@@ -677,7 +678,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record {
try {
- const selectedModelId = modelId?.trim() || deps.defaultModelId || null;
+ // Resolve model: supports full IDs (anthropic/claude-sonnet-4-6), short IDs (sonnet), and aliases (opus)
+ const rawModelId = modelId?.trim() || null;
+ const descriptor = rawModelId ? resolveModelDescriptor(rawModelId) : null;
+ const selectedModelId = descriptor?.id ?? rawModelId ?? deps.defaultModelId ?? null;
const resolved = deriveChatProvider({ modelId: selectedModelId });
const executionLaneId = await deps.resolveExecutionLane({
requestedLaneId: laneId?.trim() || undefined,
@@ -2226,9 +2238,9 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record {
try {
@@ -2240,6 +2252,37 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record {
+ try {
+ await deps.laneService.rename({ laneId, name });
+ return { success: true, laneId, name };
+ } catch (error) {
+ return { success: false, error: getErrorMessage(error) };
+ }
+ },
+ });
+
+ tools.archiveLane = tool({
+ description: "Archive a lane — hides it from the default lane list but preserves all data and the worktree.",
+ inputSchema: z.object({
+ laneId: z.string().trim().min(1).describe("ID of the lane to archive."),
+ }),
+ execute: async ({ laneId }) => {
+ try {
+ await deps.laneService.archive({ laneId });
+ return { success: true, laneId };
+ } catch (error) {
+ return { success: false, error: getErrorMessage(error) };
+ }
+ },
+ });
+
// ---------------------------------------------------------------------------
// Worker Management
// ---------------------------------------------------------------------------
@@ -2382,7 +2425,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record gitGuard(() => deps.gitService!.commit({ laneId: resolveLaneId(laneId), message, stageAll })),
});
@@ -3399,5 +3442,72 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record {
+ try {
+ const { execFileSync } = await import("node:child_process");
+ const adeRoot = path.resolve(__dirname, "../../../../..");
+ const searchPattern = pattern.trim().slice(0, 500);
+ const globArg = (fileGlob?.trim() || "*.ts").slice(0, 200);
+ const args = [
+ "--no-heading",
+ "--line-number",
+ "--max-count=3",
+ `--context=${contextLines}`,
+ "--glob",
+ globArg,
+ "--",
+ searchPattern,
+ ".",
+ ];
+ const result = execFileSync("rg", args, {
+ cwd: adeRoot,
+ encoding: "utf8",
+ maxBuffer: 512 * 1024,
+ timeout: 10_000,
+ }).trim();
+ const lines = result ? result.split("\n") : [];
+ const outputLines: string[] = [];
+ const seenFiles = new Set();
+ for (const line of lines) {
+ const match = line.match(/^([^:]+):\d+:/);
+ if (match && !seenFiles.has(match[1])) {
+ if (seenFiles.size >= maxResults) break;
+ seenFiles.add(match[1]);
+ }
+ outputLines.push(line);
+ if (outputLines.length >= 200) break;
+ }
+ const truncated = outputLines.length < lines.length || seenFiles.size >= maxResults;
+ return {
+ success: true,
+ matchCount: outputLines.filter((l) => l.match(/^\S+:\d+:/)).length,
+ truncated,
+ output: outputLines.join("\n"),
+ };
+ } catch (error: any) {
+ if (error?.status === 1) {
+ return { success: true, matchCount: 0, truncated: false, output: "No matches found." };
+ }
+ return { success: false, error: getErrorMessage(error) };
+ }
+ },
+ });
+
return tools;
}
diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts
index 8f560b287..616818509 100644
--- a/apps/desktop/src/main/services/chat/agentChatService.test.ts
+++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts
@@ -1926,6 +1926,27 @@ describe("createAgentChatService", () => {
const sessionsWithAutomation = await service.listSessions(undefined, { includeAutomation: true });
expect(sessionsWithAutomation.length).toBe(1);
});
+
+ it("does not expose completion summaries as the session summary before the chat is ended", async () => {
+ const { service } = createService();
+
+ const session = await service.createSession({
+ laneId: "lane-1",
+ provider: "claude",
+ model: "sonnet",
+ });
+
+ writePersistedChatState(session.id, {
+ ...readPersistedChatState(session.id),
+ completion: {
+ status: "completed",
+ summary: "Wrapped up the first pass and proposed a follow-up.",
+ },
+ });
+
+ const sessions = await service.listSessions();
+ expect(sessions[0]?.summary).toBeNull();
+ });
});
describe("ensureIdentitySession", () => {
@@ -2066,6 +2087,82 @@ describe("createAgentChatService", () => {
expect(send).toHaveBeenCalledWith(expect.stringContaining("Assistant: Use the primary-hosted coordinator first."));
});
+ it("reconstructs recent conversation tail for non-identity Claude sessions after resume", async () => {
+ const send = vi.fn().mockResolvedValue(undefined);
+ const setPermissionMode = vi.fn().mockResolvedValue(undefined);
+ let streamCall = 0;
+ const stream = vi.fn(() => (async function* () {
+ streamCall += 1;
+ if (streamCall === 1) {
+ yield {
+ type: "system",
+ subtype: "init",
+ session_id: "sdk-session-non-identity",
+ slash_commands: [],
+ };
+ yield {
+ type: "result",
+ usage: { input_tokens: 1, output_tokens: 1 },
+ };
+ return;
+ }
+
+ yield {
+ type: "assistant",
+ session_id: "sdk-session-non-identity",
+ message: {
+ content: [{ type: "text", text: "We should keep the lane state intact." }],
+ usage: { input_tokens: 1, output_tokens: 1 },
+ },
+ };
+ yield {
+ type: "result",
+ usage: { input_tokens: 1, output_tokens: 1 },
+ };
+ })());
+ vi.mocked(unstable_v2_createSession).mockReturnValue({
+ send,
+ stream,
+ close: vi.fn(),
+ sessionId: "sdk-session-non-identity",
+ setPermissionMode,
+ } as any);
+
+ const { service } = createService();
+ const session = await service.createSession({
+ laneId: "lane-1",
+ provider: "claude",
+ model: "sonnet",
+ });
+
+ const persisted = readPersistedChatState(session.id);
+ writePersistedChatState(session.id, {
+ ...persisted,
+ recentConversationEntries: [
+ { role: "user", text: "Can you keep the lane warm?" },
+ { role: "assistant", text: "Yes, I will keep the lane session alive." },
+ ],
+ });
+
+ const resumed = createService().service;
+ await resumed.resumeSession({ sessionId: session.id });
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ send.mockClear();
+
+ const result = await resumed.runSessionTurn({
+ sessionId: session.id,
+ text: "What changed?",
+ timeoutMs: 15_000,
+ });
+
+ expect(result.sessionId).toBe(session.id);
+ expect(send).toHaveBeenCalledTimes(1);
+ expect(send).toHaveBeenCalledWith(expect.stringContaining("Recent Conversation Tail"));
+ expect(send).toHaveBeenCalledWith(expect.stringContaining("User: Can you keep the lane warm?"));
+ expect(send).toHaveBeenCalledWith(expect.stringContaining("Assistant: Yes, I will keep the lane session alive."));
+ expect(send).not.toHaveBeenCalledWith(expect.stringContaining("Continuity Summary"));
+ });
+
it("persists a continuity snapshot and prewarms a fresh Claude session after identity session reset errors", async () => {
const primarySend = vi.fn().mockResolvedValue(undefined);
const recoverySend = vi.fn().mockResolvedValue(undefined);
@@ -2641,6 +2738,36 @@ describe("createAgentChatService", () => {
// --------------------------------------------------------------------------
describe("dispose", () => {
+ it("only writes the persisted chat summary when the session is explicitly disposed", async () => {
+ vi.mocked(streamText).mockReturnValue({
+ fullStream: (async function* () {
+ yield { type: "finish", totalUsage: { inputTokens: 1, outputTokens: 1 } };
+ })(),
+ } as any);
+
+ const { service, sessionService } = createService();
+ const session = await service.createSession({
+ laneId: "lane-1",
+ provider: "opencode",
+ model: "",
+ modelId: "opencode/anthropic/claude-sonnet-4-6",
+ });
+
+ await service.runSessionTurn({
+ sessionId: session.id,
+ text: "Investigate the flaky login tests",
+ });
+
+ expect(sessionService.setSummary).not.toHaveBeenCalled();
+
+ await service.dispose({ sessionId: session.id });
+
+ expect(sessionService.setSummary).toHaveBeenCalledWith(
+ session.id,
+ expect.stringContaining("Session closed"),
+ );
+ });
+
it("disposes a session and marks it ended", async () => {
const { service, sessionService } = createService();
const session = await service.createSession({
@@ -3211,6 +3338,76 @@ describe("createAgentChatService", () => {
]));
});
+ it("sends Codex image steer payloads as localImage input blocks", 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",
+ });
+
+ await service.sendMessage({
+ sessionId: session.id,
+ text: "Start working",
+ }, { awaitDispatch: true });
+ await waitForEvent(
+ events,
+ (event): event is AgentChatEventEnvelope & {
+ event: Extract;
+ } =>
+ event.event.type === "status"
+ && event.event.turnStatus === "started"
+ && event.event.turnId === "turn-1",
+ );
+
+ const imagePath = path.join(tmpRoot, "codex-steer-image.png");
+ fs.writeFileSync(imagePath, "fake-image-bytes");
+
+ const result = await service.steer({
+ sessionId: session.id,
+ text: "Use this screenshot while you keep going.",
+ attachments: [{ path: imagePath, type: "image" }],
+ });
+
+ expect(result.queued).toBe(false);
+ const steerRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/steer");
+ expect(steerRequest).toBeTruthy();
+ expect(steerRequest).toEqual(expect.objectContaining({
+ method: "turn/steer",
+ params: expect.objectContaining({
+ threadId: "thread-1",
+ expectedTurnId: "turn-1",
+ input: expect.arrayContaining([
+ expect.objectContaining({
+ type: "text",
+ text: "Use this screenshot while you keep going.",
+ }),
+ expect.objectContaining({
+ type: "localImage",
+ path: expect.stringContaining("codex-steer-image.png"),
+ }),
+ ]),
+ }),
+ }));
+
+ expect(events).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ event: expect.objectContaining({
+ type: "user_message",
+ text: "Use this screenshot while you keep going.",
+ attachments: [{ path: imagePath, type: "image" }],
+ deliveryState: "delivered",
+ steerId: result.steerId,
+ turnId: "turn-1",
+ }),
+ }),
+ ]));
+ });
+
it("marks active Codex subagents stopped on interrupt and ignores late child updates", async () => {
const events: AgentChatEventEnvelope[] = [];
const { service } = createService({
@@ -3433,7 +3630,7 @@ describe("createAgentChatService", () => {
signal: new AbortController().signal,
toolUseID: "tool-enter-plan",
});
- expect(enterResult).toEqual({ behavior: "allow" });
+ expect(enterResult).toMatchObject({ behavior: "allow" });
const entered = await service.getSessionSummary(sessionId);
expect(entered?.permissionMode).toBe("plan");
@@ -4209,9 +4406,8 @@ describe("createAgentChatService", () => {
try {
const events: AgentChatEventEnvelope[] = [];
let primaryStreamCall = 0;
- let primaryClosed = false;
+ let releaseInterruptedTurn = false;
const primarySend = vi.fn().mockResolvedValue(undefined);
- const resumedSend = vi.fn().mockResolvedValue(undefined);
const setPermissionMode = vi.fn().mockResolvedValue(undefined);
const primarySession = {
@@ -4241,40 +4437,32 @@ describe("createAgentChatService", () => {
},
};
- while (!primaryClosed) {
- await new Promise((resolve) => setTimeout(resolve, 1_000));
+ if (primaryStreamCall === 2) {
+ while (!releaseInterruptedTurn) {
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
+ }
+ return;
}
- throw new Error("aborted by user");
- })()),
- close: vi.fn(() => {
- primaryClosed = true;
- }),
- sessionId: "sdk-session-1",
- setPermissionMode,
- };
-
- const resumedSession = {
- send: resumedSend,
- stream: vi.fn(() => (async function* () {
- yield {
- type: "system",
- subtype: "init",
- session_id: "sdk-session-1",
- slash_commands: [],
- };
- yield {
- type: "assistant",
- session_id: "sdk-session-1",
- message: {
- content: [{ type: "text", text: "You were asking about the new chat buttons." }],
+ if (primaryStreamCall === 3) {
+ yield {
+ type: "assistant",
+ session_id: "sdk-session-1",
+ message: {
+ content: [{ type: "text", text: "You were asking about the new chat buttons." }],
+ usage: { input_tokens: 1, output_tokens: 1 },
+ },
+ };
+ yield {
+ type: "result",
usage: { input_tokens: 1, output_tokens: 1 },
- },
- };
- yield {
- type: "result",
- usage: { input_tokens: 1, output_tokens: 1 },
- };
+ };
+ return;
+ }
+
+ while (true) {
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
+ }
})()),
close: vi.fn(),
sessionId: "sdk-session-1",
@@ -4282,7 +4470,6 @@ describe("createAgentChatService", () => {
};
vi.mocked(unstable_v2_createSession).mockReturnValue(primarySession as any);
- vi.mocked(unstable_v2_resumeSession).mockReturnValue(resumedSession as any);
const { service } = createService({
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
@@ -4303,6 +4490,11 @@ describe("createAgentChatService", () => {
.then(() => null as Error | null)
.catch((error) => error instanceof Error ? error : new Error(String(error)));
await vi.advanceTimersByTimeAsync(120_000);
+ expect(events.find((event) =>
+ event.event.type === "status" && event.event.turnStatus === "interrupted",
+ )).toBeDefined();
+ releaseInterruptedTurn = true;
+ await vi.advanceTimersByTimeAsync(1_000);
const timeoutError = await firstTurnError;
expect(timeoutError?.message ?? "").toMatch(/Timed out waiting for session .* The turn was interrupted, but the chat stayed open\./i);
@@ -4320,11 +4512,8 @@ describe("createAgentChatService", () => {
timeoutMs: 15_000,
});
- expect(unstable_v2_resumeSession).toHaveBeenCalledWith(
- "sdk-session-1",
- expect.objectContaining({ model: "sonnet" }),
- );
- expect(resumedSend).toHaveBeenCalledTimes(1);
+ expect(unstable_v2_resumeSession).not.toHaveBeenCalled();
+ expect(primarySend).toHaveBeenCalledTimes(3);
expect(followUp.outputText).toContain("new chat buttons");
} finally {
vi.useRealTimers();
@@ -4406,7 +4595,10 @@ describe("createAgentChatService", () => {
timeoutMs: 500_000,
});
- await vi.advanceTimersByTimeAsync(361_000);
+ for (let index = 0; index < 6; index += 1) {
+ await vi.advanceTimersByTimeAsync(60_000);
+ }
+ await vi.advanceTimersByTimeAsync(1_000);
const result = await turn;
expect(result.outputText).toContain("Finished after a long run.");
@@ -4673,6 +4865,90 @@ describe("createAgentChatService", () => {
await expect(sendPromise).resolves.toBeUndefined();
});
+ it("emits a single interrupted status and done event without closing the Claude session", async () => {
+ const events: AgentChatEventEnvelope[] = [];
+ let streamCall = 0;
+ let warmupComplete = false;
+ let hangResolve: (() => void) | null = null;
+ const hangPromise = new Promise((resolve) => { hangResolve = resolve; });
+ const send = vi.fn().mockResolvedValue(undefined);
+ const setPermissionMode = vi.fn().mockResolvedValue(undefined);
+ const close = vi.fn();
+ const stream = vi.fn(() => (async function* () {
+ streamCall += 1;
+ if (streamCall === 1) {
+ yield { type: "system", subtype: "init", session_id: "sdk-single-interrupt", slash_commands: [] };
+ warmupComplete = true;
+ yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } };
+ return;
+ }
+
+ yield {
+ type: "stream_event",
+ event: {
+ type: "content_block_delta",
+ index: 0,
+ delta: { type: "text_delta", text: "still working" },
+ },
+ };
+ await hangPromise;
+ return;
+ })());
+ vi.mocked(unstable_v2_createSession).mockReturnValue({
+ send,
+ stream,
+ close,
+ sessionId: "sdk-single-interrupt",
+ setPermissionMode,
+ } as any);
+
+ const { service } = createService({
+ onEvent: (event: AgentChatEventEnvelope) => events.push(event),
+ });
+
+ const session = await service.createSession({
+ laneId: "lane-1",
+ provider: "claude",
+ model: "sonnet",
+ });
+
+ await vi.waitFor(() => {
+ expect(warmupComplete).toBe(true);
+ });
+
+ const sendPromise = service.sendMessage({
+ sessionId: session.id,
+ text: "Please keep working",
+ });
+
+ await waitForEvent(
+ events,
+ (event): event is AgentChatEventEnvelope => event.event.type === "text",
+ );
+
+ await service.interrupt({ sessionId: session.id });
+
+ const interruptedStatuses = events.filter(
+ (event) => event.event.type === "status" && event.event.turnStatus === "interrupted",
+ );
+ const interruptedDone = events.filter(
+ (event) => event.event.type === "done" && event.event.status === "interrupted",
+ );
+ expect(interruptedStatuses).toHaveLength(1);
+ expect(interruptedDone).toHaveLength(1);
+ expect(close).not.toHaveBeenCalled();
+
+ hangResolve!();
+ await expect(sendPromise).resolves.toBeUndefined();
+
+ expect(events.filter(
+ (event) => event.event.type === "status" && event.event.turnStatus === "interrupted",
+ )).toHaveLength(1);
+ expect(events.filter(
+ (event) => event.event.type === "done" && event.event.status === "interrupted",
+ )).toHaveLength(1);
+ });
+
});
// --------------------------------------------------------------------------
@@ -4945,6 +5221,87 @@ describe("createAgentChatService", () => {
);
expect(deliveredWithUpdatedText).toBeUndefined();
});
+
+ it("sends Claude image follow-ups as SDK user messages after an earlier text turn", async () => {
+ const send = vi.fn().mockResolvedValue(undefined);
+ const setPermissionMode = vi.fn().mockResolvedValue(undefined);
+ let streamCall = 0;
+ const stream = vi.fn(() => (async function* () {
+ streamCall += 1;
+ if (streamCall === 1) {
+ yield {
+ type: "system",
+ subtype: "init",
+ session_id: "sdk-session-1",
+ slash_commands: [],
+ };
+ yield {
+ type: "result",
+ usage: { input_tokens: 1, output_tokens: 1 },
+ };
+ return;
+ }
+
+ yield {
+ type: "assistant",
+ message: {
+ content: [{ type: "text", text: streamCall === 2 ? "First turn done" : "Follow-up done" }],
+ usage: { input_tokens: 1, output_tokens: 1 },
+ },
+ };
+ yield {
+ type: "result",
+ usage: { input_tokens: 1, output_tokens: 1 },
+ };
+ })());
+
+ const mockSession = {
+ send,
+ stream,
+ close: vi.fn(),
+ sessionId: "sdk-session-1",
+ setPermissionMode,
+ };
+
+ vi.mocked(unstable_v2_createSession).mockReturnValue(mockSession as any);
+ vi.mocked(unstable_v2_resumeSession).mockReturnValue(mockSession as any);
+
+ const { service } = createService();
+ const session = await service.createSession({
+ laneId: "lane-1",
+ provider: "claude",
+ model: "sonnet",
+ });
+
+ const imagePath = path.join(tmpRoot, "follow-up.png");
+ fs.writeFileSync(imagePath, "fake-image-bytes");
+
+ await service.runSessionTurn({
+ sessionId: session.id,
+ text: "Start with text only",
+ });
+
+ await service.runSessionTurn({
+ sessionId: session.id,
+ text: "Now use this screenshot",
+ attachments: [{ path: imagePath, type: "image" }],
+ });
+
+ expect(send).toHaveBeenCalledTimes(3);
+ expect(String(send.mock.calls[1]?.[0] ?? "")).toContain("Start with text only");
+
+ const followUpPayload = send.mock.calls[2]?.[0] as Record;
+ expect(followUpPayload.type).toBe("user");
+ expect(followUpPayload.session_id).toBe("sdk-session-1");
+ expect(followUpPayload.parent_tool_use_id).toBeNull();
+
+ const message = followUpPayload.message as { role: string; content: Array> };
+ expect(message.role).toBe("user");
+ expect(message.content[0]?.type).toBe("text");
+ expect(String(message.content[0]?.text ?? "")).toContain("Now use this screenshot");
+ expect(message.content[1]?.type).toBe("image");
+ expect((message.content[1]?.source as Record).type).toBe("base64");
+ });
});
// --------------------------------------------------------------------------
diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts
index 4c50b55c0..a1af63518 100644
--- a/apps/desktop/src/main/services/chat/agentChatService.ts
+++ b/apps/desktop/src/main/services/chat/agentChatService.ts
@@ -4,7 +4,7 @@ import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
import { unstable_v2_createSession, unstable_v2_resumeSession } from "@anthropic-ai/claude-agent-sdk";
-import type { Query as ClaudeSDKQuery, SDKMessage, SDKUserMessage, Options as ClaudeSDKOptions, PermissionResult as ClaudePermissionResult } from "@anthropic-ai/claude-agent-sdk";
+import type { SDKMessage, SDKUserMessage, Options as ClaudeSDKOptions, PermissionResult as ClaudePermissionResult } from "@anthropic-ai/claude-agent-sdk";
type ClaudeV2Session = {
send: (msg: string | Partial) => Promise;
@@ -311,13 +311,17 @@ type CodexRuntime = {
planModeFallbackNotified: boolean;
};
+type QueuedSteer = {
+ steerId: string;
+ text: string;
+ attachments: AgentChatFileRef[];
+ resolvedAttachments: ResolvedAgentChatFileRef[];
+};
+
type ClaudeRuntime = {
kind: "claude";
sdkSessionId: string | null;
- activeQuery: ClaudeSDKQuery | null;
v2Session: ClaudeV2Session | null;
- /** Single stream generator kept alive across turns (never closed by for-await). */
- v2StreamGen: AsyncGenerator | null;
/** Resolves when the subprocess is initialized (system:init received). */
v2WarmupDone: Promise | null;
/** Resolves the current warmup race so waiters can stop blocking immediately. */
@@ -328,9 +332,11 @@ type ClaudeRuntime = {
slashCommands: Array<{ name: string; description: string; argumentHint?: string }>;
busy: boolean;
activeTurnId: string | null;
- pendingSteers: Array<{ steerId: string; text: string }>;
+ pendingSteers: QueuedSteer[];
approvals: Map;
interrupted: boolean;
+ /** Set when early interrupt events have been emitted to avoid duplicate emission later. */
+ interruptEventsEmitted: boolean;
/** Set when a reasoning effort change is requested mid-turn; flushed when idle. */
pendingSessionReset?: boolean;
turnMemoryPolicyState: TurnMemoryPolicyState | null;
@@ -360,7 +366,7 @@ type OpenCodeRuntime = {
activeTurnId: string | null;
permissionMode: PermissionMode;
pendingApprovals: Map;
- pendingSteers: Array<{ steerId: string; text: string }>;
+ pendingSteers: QueuedSteer[];
interrupted: boolean;
modelDescriptor: ModelDescriptor;
textByPartId: Map;
@@ -385,7 +391,7 @@ type CursorRuntime = {
modelConfigId: string | null;
currentModelId: string | null;
availableModelIds: string[];
- pendingSteers: Array<{ steerId: string; text: string }>;
+ pendingSteers: QueuedSteer[];
permissionWaiters: Map;
modeConfigId: string | null;
currentModeId: string | null;
@@ -875,6 +881,7 @@ const DEFAULT_COLLABORATION_MODES_LIST_TIMEOUT_MS = 1_500;
const SESSION_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const SESSION_CLEANUP_INTERVAL_MS = 60 * 1000; // check every 60 seconds
const MAX_CONCURRENT_ACTIVE_RUNTIMES = 5;
+const MAX_RECENT_CONVERSATION_ENTRIES = 50;
const MAX_SESSION_MAP_ENTRIES = 200;
function evictOldestEntries(map: Map, maxSize: number): void {
@@ -2996,7 +3003,7 @@ export function createAgentChatService(args: {
turnId: runtime.activeTurnId ?? undefined,
});
}
- return { behavior: "allow" };
+ return { behavior: "allow", updatedInput: input };
}
// ── ExitPlanMode interception ──
@@ -3029,7 +3036,7 @@ export function createAgentChatService(args: {
applyClaudePlanModeTransition(managed.session, "default");
persistChatState(managed);
}
- return { behavior: "allow" };
+ return { behavior: "allow", updatedInput: input };
}
const inputRecord = (input && typeof input === "object" && !Array.isArray(input)) ? input as Record : {};
@@ -3145,12 +3152,12 @@ export function createAgentChatService(args: {
if (toolName === "AskUserQuestion") {
if (hasClaudeAskUserAnswers(input)) {
- return { behavior: "allow" };
+ return { behavior: "allow", updatedInput: input };
}
const request = buildClaudeAskUserPendingRequest(runtime, input, sdkOptions);
if (!request) {
- return { behavior: "allow" };
+ return { behavior: "allow", updatedInput: input };
}
const approvalItemId = request.itemId ?? request.requestId;
@@ -3219,7 +3226,7 @@ export function createAgentChatService(args: {
if (isMemorySearchToolName(toolName) && state) {
state.explicitSearchPerformed = true;
state.orientationSatisfied = true;
- return { behavior: "allow" };
+ return { behavior: "allow", updatedInput: input };
}
if (state && state.classification === "required" && !state.orientationSatisfied && !state.explicitSearchPerformed) {
if (isClaudeMutatingToolCall(toolName, input)) {
@@ -3235,7 +3242,7 @@ export function createAgentChatService(args: {
// Check session-wide overrides — user already said "Allow for Session" for this tool
const normalizedForOverride = normalizeToolNameForApproval(toolName);
if (runtime.approvalOverrides.has(normalizedForOverride)) {
- return { behavior: "allow" };
+ return { behavior: "allow", updatedInput: input };
}
const approvalItemId = randomUUID();
@@ -3296,6 +3303,7 @@ export function createAgentChatService(args: {
if (approved) {
return {
behavior: "allow",
+ updatedInput: input,
...(response.decision === "accept_for_session" && sdkOptions?.suggestions?.length
? { updatedPermissions: sdkOptions.suggestions }
: {}),
@@ -3310,7 +3318,7 @@ export function createAgentChatService(args: {
};
}
- return { behavior: "allow" };
+ return { behavior: "allow", updatedInput: input };
};
const clearSubagentSnapshots = (sessionId: string): void => {
@@ -3902,7 +3910,7 @@ export function createAgentChatService(args: {
return selectPreferredReasoningTier(supported);
};
- const buildRecentConversationContext = (managed: ManagedChatSession, limit = 6): string => {
+ const buildRecentConversationContext = (managed: ManagedChatSession, limit = 20): string => {
const liveEntries = managed.recentConversationEntries.map((entry) =>
`${entry.role === "user" ? "User" : "Assistant"}: ${entry.text}`,
);
@@ -4013,15 +4021,15 @@ export function createAgentChatService(args: {
}
managed.recentConversationEntries.push({ role, text, turnId });
- if (managed.recentConversationEntries.length > 12) {
- managed.recentConversationEntries.splice(0, managed.recentConversationEntries.length - 12);
+ if (managed.recentConversationEntries.length > MAX_RECENT_CONVERSATION_ENTRIES) {
+ managed.recentConversationEntries.splice(
+ 0,
+ managed.recentConversationEntries.length - MAX_RECENT_CONVERSATION_ENTRIES,
+ );
}
};
- const refreshReconstructionContext = (
- managed: ManagedChatSession,
- options?: { includeConversationTail?: boolean },
- ): void => {
+ const refreshReconstructionContext = (managed: ManagedChatSession): void => {
const sections: string[] = [];
if (managed.session.identityKey === "cto" && ctoStateService) {
@@ -4044,11 +4052,9 @@ export function createAgentChatService(args: {
].join("\n"));
}
- if (options?.includeConversationTail) {
- const recentConversation = buildRecentConversationContext(managed);
- if (recentConversation.length) {
- sections.push(["Recent Conversation Tail", recentConversation].join("\n"));
- }
+ const recentConversation = buildRecentConversationContext(managed);
+ if (recentConversation.length) {
+ sections.push(["Recent Conversation Tail", recentConversation].join("\n"));
}
const nextContext = sections.map((section) => section.trim()).filter((section) => section.length > 0).join("\n\n");
@@ -4782,7 +4788,7 @@ export function createAgentChatService(args: {
|| managed.runtime?.kind === "cursor")
) {
teardownRuntime(managed, "project_close");
- refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) });
+ refreshReconstructionContext(managed);
}
return launchContext;
};
@@ -5111,10 +5117,6 @@ export function createAgentChatService(args: {
if (report.summary.trim().length > 0) {
setSessionPreview(managed, report.summary);
}
- const summary = report.status === "completed"
- ? report.summary
- : `${report.status}: ${report.summary}`;
- sessionService.setSummary(managed.session.id, clipText(summary, 360));
persistChatState(managed);
};
@@ -5185,25 +5187,10 @@ export function createAgentChatService(args: {
if (event.report.summary.trim().length > 0) {
setSessionPreview(managed, event.report.summary);
}
- const summary = event.report.status === "completed"
- ? event.report.summary
- : `${event.report.status}: ${event.report.summary}`;
- sessionService.setSummary(managed.session.id, clipText(summary, 360));
}
- if (event.type === "done") {
- // Only set a fallback summary if no completion_report already provided one.
- const hasCompletionSummary = managed.session.completion?.summary?.trim().length;
- if (!hasCompletionSummary) {
- const preview = managed.preview?.trim() ?? "";
- const summary = preview.length
- ? (event.status === "completed" ? preview : `${event.status}: ${preview}`)
- : (event.status === "completed" ? "Response ready" : `Turn ${event.status}`);
- sessionService.setSummary(managed.session.id, summary);
- }
- // Fire AI-enhanced summary after each completed turn (not just on session end).
- void maybeGenerateSessionSummary(managed, null);
- }
+ // Session summaries are generated only when the chat is explicitly ended in ADE,
+ // so "done" events intentionally do not produce a summary here.
const envelope: AgentChatEventEnvelope = {
sessionId: managed.session.id,
@@ -5640,11 +5627,8 @@ export function createAgentChatService(args: {
// Mark interrupted so the streaming catch block takes the graceful path
managed.runtime.interrupted = true;
cancelClaudeWarmup(managed, managed.runtime, "teardown");
- managed.runtime.activeQuery?.close();
- managed.runtime.activeQuery = null;
try { managed.runtime.v2Session?.close(); } catch { /* ignore */ }
managed.runtime.v2Session = null;
- managed.runtime.v2StreamGen = null;
managed.runtime.v2WarmupDone = null;
managed.runtime.activeSubagents.clear();
for (const pending of managed.runtime.approvals.values()) {
@@ -6027,7 +6011,7 @@ export function createAgentChatService(args: {
managed.todoItems = readLatestTranscriptTodoItems(managed);
normalizeSessionNativePermissionControls(managed.session, resolveChatConfig());
managed.transcriptLimitReached = managed.transcriptBytesWritten >= MAX_CHAT_TRANSCRIPT_BYTES;
- refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) });
+ refreshReconstructionContext(managed);
managedSessions.set(sessionId, managed);
return managed;
@@ -6325,6 +6309,7 @@ export function createAgentChatService(args: {
runtime.busy = true;
runtime.activeTurnId = turnId;
runtime.interrupted = false;
+ runtime.interruptEventsEmitted = false;
runtime.resolvedToolUseIds.clear();
setSessionActive(managed);
@@ -6524,6 +6509,7 @@ export function createAgentChatService(args: {
// image content blocks (streaming input format per SDK docs).
const messageToSend = buildClaudeV2Message(basePromptText, resolvedAttachments, {
baseDir: managed.laneWorktreePath,
+ sessionId: runtime.sdkSessionId,
});
const turnPermissionMode = resolveClaudeTurnPermissionMode(managed);
@@ -6629,7 +6615,7 @@ export function createAgentChatService(args: {
});
}
void maybeRefreshIdentityContinuitySummary(managed, "compaction");
- refreshReconstructionContext(managed, { includeConversationTail: true });
+ refreshReconstructionContext(managed);
}
continue;
}
@@ -7135,7 +7121,6 @@ export function createAgentChatService(args: {
runtime.resumeIdleWatchdog = null;
flushOpenClaudeToolUses(runtime.interrupted ? "interrupted" : "completed");
// Note: v2Session is NOT closed here — it stays alive for the next turn
- runtime.activeQuery = null;
runtime.busy = false;
runtime.activeTurnId = null;
runtime.turnMemoryPolicyState = null;
@@ -7153,16 +7138,20 @@ export function createAgentChatService(args: {
const doneModel = buildDoneModelPayload();
const finalStatus = runtime.interrupted ? "interrupted" : "completed";
- emitChatEvent(managed, { type: "status", turnStatus: finalStatus, turnId });
- void emitTurnDiffSummaryIfChanged(managed, turnId);
- emitChatEvent(managed, {
- type: "done",
- turnId,
- status: finalStatus,
- ...doneModel,
- ...(usage ? { usage } : {}),
- ...(costUsd != null ? { costUsd } : {}),
- });
+ if (!runtime.interruptEventsEmitted) {
+ emitChatEvent(managed, { type: "status", turnStatus: finalStatus, turnId });
+ void emitTurnDiffSummaryIfChanged(managed, turnId);
+ emitChatEvent(managed, {
+ type: "done",
+ turnId,
+ status: finalStatus,
+ ...doneModel,
+ ...(usage ? { usage } : {}),
+ ...(costUsd != null ? { costUsd } : {}),
+ });
+ } else {
+ void emitTurnDiffSummaryIfChanged(managed, turnId);
+ }
if (assistantText.trim().length > 0) {
appendWorkerActivityToCto(managed, {
@@ -7186,7 +7175,6 @@ export function createAgentChatService(args: {
clearClaudeTurnTimers();
runtime.pauseIdleWatchdog = null;
runtime.resumeIdleWatchdog = null;
- runtime.activeQuery = null;
runtime.busy = false;
runtime.activeTurnId = null;
runtime.turnMemoryPolicyState = null;
@@ -7197,23 +7185,27 @@ export function createAgentChatService(args: {
: "failed";
flushOpenClaudeToolUses(finalToolStatus);
- // Close V2 session on error so the next turn starts fresh
- try { runtime.v2Session?.close(); } catch { /* ignore */ }
- runtime.v2Session = null;
- runtime.v2StreamGen = null;
- runtime.v2WarmupDone = null;
+ // Only close V2 session on genuine errors. Interrupted turns keep the
+ // session alive for the next send()+stream() cycle.
+ if (!runtime.interrupted) {
+ try { runtime.v2Session?.close(); } catch { /* ignore */ }
+ runtime.v2Session = null;
+ runtime.v2WarmupDone = null;
+ }
const doneModel = buildDoneModelPayload();
void emitTurnDiffSummaryIfChanged(managed, turnId);
if (runtime.interrupted) {
markSessionIdleWithFreshCache(managed);
- emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId });
- emitChatEvent(managed, {
- type: "done",
- turnId,
- status: "interrupted",
- ...doneModel,
- });
+ if (!runtime.interruptEventsEmitted) {
+ emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId });
+ emitChatEvent(managed, {
+ type: "done",
+ turnId,
+ status: "interrupted",
+ ...doneModel,
+ });
+ }
} else if (timeoutError) {
markSessionIdleWithFreshCache(managed);
const errorMessage = effectiveError instanceof Error ? effectiveError.message : String(effectiveError);
@@ -7239,13 +7231,15 @@ export function createAgentChatService(args: {
// System-triggered abort (dispose/teardown) that wasn't flagged as interrupted.
// Treat as interruption to avoid surfacing raw SDK messages like "aborted by user".
markSessionIdleWithFreshCache(managed);
- emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId });
- emitChatEvent(managed, {
- type: "done",
- turnId,
- status: "interrupted",
- ...doneModel,
- });
+ if (!runtime.interruptEventsEmitted) {
+ emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId });
+ emitChatEvent(managed, {
+ type: "done",
+ turnId,
+ status: "interrupted",
+ ...doneModel,
+ });
+ }
} else {
markSessionIdleWithFreshCache(managed);
const isAuthFailure = isClaudeRuntimeAuthError(effectiveError);
@@ -7290,7 +7284,7 @@ export function createAgentChatService(args: {
managed.runtimeInvalidated = true;
clearLaneDirectiveKey(managed);
void maybeRefreshIdentityContinuitySummary(managed, "provider_reset");
- refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) });
+ refreshReconstructionContext(managed);
prewarmClaudeV2Session(managed);
}
}
@@ -9896,22 +9890,24 @@ export function createAgentChatService(args: {
await runClaudeTurn(managed, {
promptText,
displayText: trimmed,
- attachments: [],
+ attachments: nextSteer.attachments,
+ resolvedAttachments: nextSteer.resolvedAttachments,
laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null,
});
} else if (runtime.kind === "cursor") {
await runCursorTurn(managed, {
promptText,
displayText: trimmed,
- attachments: [],
- resolvedAttachments: [],
+ attachments: nextSteer.attachments,
+ resolvedAttachments: nextSteer.resolvedAttachments,
laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null,
});
} else {
await runTurn(managed, {
promptText,
displayText: trimmed,
- attachments: [],
+ attachments: nextSteer.attachments,
+ resolvedAttachments: nextSteer.resolvedAttachments,
laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null,
});
}
@@ -9926,6 +9922,8 @@ export function createAgentChatService(args: {
sessionId: string,
steerId: string,
text: string,
+ attachments: AgentChatFileRef[] = [],
+ resolvedAttachments: ResolvedAgentChatFileRef[] = [],
): boolean => {
if (runtime.pendingSteers.length >= MAX_PENDING_STEERS) {
logger.warn("agent_chat.steer_queue_full", { sessionId, queueSize: runtime.pendingSteers.length });
@@ -9937,10 +9935,11 @@ export function createAgentChatService(args: {
});
return false;
}
- runtime.pendingSteers.push({ steerId, text });
+ runtime.pendingSteers.push({ steerId, text, attachments, resolvedAttachments });
emitChatEvent(managed, {
type: "user_message",
text,
+ ...(attachments.length ? { attachments } : {}),
steerId,
turnId: runtime.activeTurnId ?? undefined,
deliveryState: "queued",
@@ -10127,9 +10126,7 @@ export function createAgentChatService(args: {
const runtime: ClaudeRuntime = {
kind: "claude",
sdkSessionId,
- activeQuery: null,
v2Session: null,
- v2StreamGen: null,
v2WarmupDone: null,
v2WarmupCancel: null,
v2WarmupCancelled: false,
@@ -10140,6 +10137,7 @@ export function createAgentChatService(args: {
pendingSteers: [],
approvals: new Map(),
interrupted: false,
+ interruptEventsEmitted: false,
turnMemoryPolicyState: null,
approvalOverrides: new Set(persisted?.approvalOverrides ?? []),
pendingElicitations: new Map void>(),
@@ -10556,7 +10554,7 @@ export function createAgentChatService(args: {
};
normalizeSessionNativePermissionControls(managed.session, resolveChatConfig());
managed.transcriptLimitReached = managed.transcriptBytesWritten >= MAX_CHAT_TRANSCRIPT_BYTES;
- refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) });
+ refreshReconstructionContext(managed);
// Init dedicated chat transcript file for persistence
try {
@@ -10751,7 +10749,7 @@ export function createAgentChatService(args: {
managed.closed = false;
managed.endedNotified = false;
managed.ctoSessionStartedAt = managed.session.identityKey === "cto" ? nowIso() : null;
- refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) });
+ refreshReconstructionContext(managed);
}
if (managed.session.provider === "cursor" && managed.session.status === "active") {
@@ -10839,7 +10837,6 @@ export function createAgentChatService(args: {
if (managed.runtime?.kind === "claude" && !isBusyError) {
managed.runtime.busy = false;
managed.runtime.activeTurnId = null;
- managed.runtime.activeQuery = null;
}
if (managed.runtime?.kind === "cursor" && !isBusyError) {
managed.runtime.busy = false;
@@ -11993,7 +11990,7 @@ export function createAgentChatService(args: {
}
};
- const steer = async ({ sessionId, text }: AgentChatSteerArgs): Promise => {
+ const steer = async ({ sessionId, text, attachments = [] }: AgentChatSteerArgs): Promise => {
const trimmed = text.trim();
const steerId = randomUUID();
if (!trimmed.length) {
@@ -12001,6 +11998,9 @@ export function createAgentChatService(args: {
}
const managed = ensureManagedSession(sessionId);
+ if (attachments.length && managed.session.provider !== "claude" && managed.session.provider !== "codex") {
+ throw new Error("Attachments are only supported for Claude and Codex follow-up messages right now.");
+ }
// OpenCode runtime steer
if (managed.runtime?.kind === "opencode") {
@@ -12035,7 +12035,7 @@ export function createAgentChatService(args: {
});
return { steerId, queued: false };
}
- rt.pendingSteers.push({ steerId, text: trimmed });
+ rt.pendingSteers.push({ steerId, text: trimmed, attachments: [], resolvedAttachments: [] });
emitChatEvent(managed, {
type: "user_message",
text: trimmed,
@@ -12073,20 +12073,42 @@ export function createAgentChatService(args: {
throw new Error("No active turn to steer.");
}
+ const preparedSteer = prepareSendMessage({
+ sessionId,
+ text: trimmed,
+ displayText: trimmed,
+ attachments,
+ });
+ if (!preparedSteer) {
+ return { steerId, queued: false };
+ }
+
+ const input: Array> = [
+ {
+ type: "text",
+ text: trimmed,
+ text_elements: [],
+ },
+ ];
+ for (const attachment of preparedSteer.resolvedAttachments) {
+ const stagedPath = stageAttachmentForCodexInput(attachment);
+ if (attachment.type === "image") {
+ input.push({ type: "localImage", path: stagedPath });
+ continue;
+ }
+ const name = path.basename(attachment.path) || attachment.path;
+ input.push({ type: "mention", name, path: stagedPath });
+ }
+
await runtime.request("turn/steer", {
threadId: managed.session.threadId,
expectedTurnId: runtime.activeTurnId,
- input: [
- {
- type: "text",
- text: trimmed,
- text_elements: []
- }
- ]
+ input,
});
emitChatEvent(managed, {
type: "user_message",
- text: trimmed,
+ text: preparedSteer.visibleText,
+ ...(preparedSteer.attachments.length ? { attachments: preparedSteer.attachments } : {}),
steerId,
deliveryState: "delivered",
turnId: runtime.activeTurnId,
@@ -12095,20 +12117,27 @@ export function createAgentChatService(args: {
}
const runtime = ensureClaudeSessionRuntime(managed);
- if (runtime.busy) {
- enqueueSteerOrDrop(managed, runtime, sessionId, steerId, trimmed);
- return { steerId, queued: true };
- }
-
const preparedSteer = prepareSendMessage({
sessionId,
text: trimmed,
displayText: trimmed,
- attachments: [],
+ attachments,
});
if (!preparedSteer) {
return { steerId, queued: false };
}
+ if (runtime.busy) {
+ enqueueSteerOrDrop(
+ managed,
+ runtime,
+ sessionId,
+ steerId,
+ preparedSteer.visibleText,
+ preparedSteer.attachments,
+ preparedSteer.resolvedAttachments,
+ );
+ return { steerId, queued: true };
+ }
await executePreparedSendMessage(preparedSteer);
return { steerId, queued: false };
};
@@ -12236,26 +12265,33 @@ export function createAgentChatService(args: {
busy: runtime.busy,
warmupInFlight: Boolean(runtime.v2WarmupDone),
});
- // Set interrupted before closing the session so the streaming loop sees it
- // and breaks cleanly rather than throwing from a closed session.
+ // Set interrupted before touching the runtime so the streaming loop can
+ // break cleanly without tearing the session down.
runtime.interrupted = true;
+ const interruptedTurnId = runtime.activeTurnId;
+ if (runtime.busy && interruptedTurnId) {
+ runtime.interruptEventsEmitted = true;
+ runtime.busy = false;
+ runtime.activeTurnId = null;
+ emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId: interruptedTurnId });
+ emitChatEvent(managed, {
+ type: "done",
+ turnId: interruptedTurnId,
+ status: "interrupted",
+ });
+ }
cancelClaudeWarmup(managed, runtime, "interrupt");
cancelQueuedSteers(managed, runtime, "interrupted");
- runtime.activeQuery?.interrupt().catch(() => {});
// Drain pending approvals so their promises settle instead of hanging forever
for (const pending of runtime.approvals.values()) {
pending.resolve({ decision: "cancel" });
}
runtime.approvals.clear();
runtime.pendingElicitations.clear();
- // Close the V2 session on interrupt — it will be recreated on the next turn
- try { runtime.v2Session?.close(); } catch { /* ignore */ }
- runtime.v2Session = null;
- runtime.v2StreamGen = null;
// Emit subagent_result "stopped" for every active subagent so the UI
// properly transitions them from "running" → "stopped" (matching Claude Code CLI behaviour).
- const turnId = runtime.activeTurnId ?? undefined;
+ const turnId = interruptedTurnId ?? undefined;
for (const { taskId } of runtime.activeSubagents.values()) {
emitChatEvent(managed, {
type: "subagent_result",
@@ -12266,10 +12302,11 @@ export function createAgentChatService(args: {
});
}
runtime.activeSubagents.clear();
+ persistChatState(managed);
logger.info("agent_chat.turn_interrupt_completed", {
sessionId,
provider: "claude",
- turnId: runtime.activeTurnId,
+ turnId: interruptedTurnId,
busy: runtime.busy,
});
};
@@ -12279,7 +12316,7 @@ export function createAgentChatService(args: {
refreshManagedLaneLaunchContext(managed, { purpose: "resume this chat" });
const persisted = readPersistedState(sessionId);
managed.session.capabilityMode = managed.session.capabilityMode ?? inferCapabilityMode(managed.session.provider);
- refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) });
+ refreshReconstructionContext(managed);
if (managed.session.provider === "codex") {
const runtime = await ensureCodexSessionRuntime(managed);
@@ -12483,7 +12520,7 @@ export function createAgentChatService(args: {
endedAt: row.endedAt,
lastActivityAt: liveSession?.lastActivityAt ?? persisted?.updatedAt ?? row.endedAt ?? row.startedAt,
lastOutputPreview: row.lastOutputPreview,
- summary: row.summary ?? liveSession?.completion?.summary ?? persisted?.completion?.summary ?? null,
+ summary: row.summary ?? null,
...(hasLivePendingInput(liveManaged) ? { awaitingInput: true } : {}),
...(liveSession?.threadId || persisted?.threadId
? { threadId: liveSession?.threadId ?? persisted?.threadId }
@@ -12554,7 +12591,7 @@ export function createAgentChatService(args: {
enforceManagedLocalHarnessPermissionMode(managed);
normalizeSessionNativePermissionControls(managed.session, resolveChatConfig());
managed.selectedExecutionLaneId = selectedExecutionLaneId ?? managed.selectedExecutionLaneId;
- refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) });
+ refreshReconstructionContext(managed);
await refreshHeadShaStartForManagedExecutionLane(managed);
persistChatState(managed);
@@ -12620,7 +12657,7 @@ export function createAgentChatService(args: {
const managed = ensureManagedSession(created.id);
managed.selectedExecutionLaneId = selectedExecutionLaneId;
- refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) });
+ refreshReconstructionContext(managed);
await refreshHeadShaStartForManagedExecutionLane(managed);
persistChatState(managed);
return managed.session;
@@ -13083,6 +13120,8 @@ export function createAgentChatService(args: {
if (
managed.runtime
&& !managed.closed
+ // Keep Claude sessions warm until the user explicitly ends them.
+ && managed.runtime.kind !== "claude"
&& now - managed.lastActivityTimestamp > SESSION_INACTIVITY_TIMEOUT_MS
) {
teardownRuntime(managed, "idle_ttl");
@@ -13099,6 +13138,9 @@ export function createAgentChatService(args: {
for (const [id, managed] of managedSessions) {
if (id === excludeSessionId) continue;
if (!managed.runtime) continue;
+ // Claude V2 runtimes keep their in-memory session state and should not be
+ // evicted behind the user's back.
+ if (managed.runtime.kind === "claude") continue;
if (managed.lastActivityTimestamp < oldestTimestamp) {
oldestTimestamp = managed.lastActivityTimestamp;
oldest = managed;
@@ -13172,7 +13214,7 @@ export function createAgentChatService(args: {
if (managed.runtime && modelChanged) {
teardownRuntime(managed, "model_switch");
- refreshReconstructionContext(managed, { includeConversationTail: true });
+ refreshReconstructionContext(managed);
}
const currentTitle = sessionService.get(sessionId)?.title ?? null;
@@ -13326,7 +13368,7 @@ export function createAgentChatService(args: {
) {
managed.runtime.threadResumed = false;
}
- if (managed.runtime?.kind === "claude" && managed.runtime.v2Session && !managed.runtime.busy) {
+ if (managed.runtime?.kind === "claude" && managed.runtime.v2Session) {
const turnPermissionMode = resolveClaudeTurnPermissionMode(managed);
const control = getClaudeV2SessionControl(managed.runtime.v2Session);
if (typeof control.setPermissionMode === "function") {
@@ -13337,15 +13379,18 @@ export function createAgentChatService(args: {
// bypassPermissions on a session not started with
// --dangerously-skip-permissions), invalidate the V2 session
// so it is recreated with the correct mode on the next turn.
+ // When busy, only log the failure — don't tear down the active session.
logger.warn("agent_chat.v2_set_permission_mode_failed", {
sessionId: managed.session.id,
turnPermissionMode,
error: String(permErr),
});
- cancelClaudeWarmup(managed, managed.runtime, "session_reset");
- try { managed.runtime.v2Session?.close(); } catch { /* ignore */ }
- managed.runtime.v2Session = null;
- managed.runtime.v2WarmupDone = null;
+ if (!managed.runtime.busy) {
+ cancelClaudeWarmup(managed, managed.runtime, "session_reset");
+ try { managed.runtime.v2Session?.close(); } catch { /* ignore */ }
+ managed.runtime.v2Session = null;
+ managed.runtime.v2WarmupDone = null;
+ }
}
}
}
@@ -13370,7 +13415,7 @@ export function createAgentChatService(args: {
if (resetRuntimeForComputerUse && managed.runtime) {
teardownRuntime(managed, "model_switch");
- refreshReconstructionContext(managed, { includeConversationTail: true });
+ refreshReconstructionContext(managed);
}
if (title !== undefined) {
diff --git a/apps/desktop/src/main/services/chat/buildClaudeV2Message.test.ts b/apps/desktop/src/main/services/chat/buildClaudeV2Message.test.ts
index f412e6b93..2b74887c6 100644
--- a/apps/desktop/src/main/services/chat/buildClaudeV2Message.test.ts
+++ b/apps/desktop/src/main/services/chat/buildClaudeV2Message.test.ts
@@ -66,6 +66,8 @@ describe("buildClaudeV2Message", () => {
expect(typeof result).toBe("object");
const msg = result as SDKUserMessagePartial;
expect(msg.type).toBe("user");
+ expect(msg.session_id).toBe("");
+ expect(msg.parent_tool_use_id).toBeNull();
expect(msg.message.role).toBe("user");
// Should have two content blocks: text + image
@@ -92,6 +94,8 @@ describe("buildClaudeV2Message", () => {
const result = buildClaudeV2Message("Describe this image", attachments, { baseDir: tmpDir });
const msg = result as SDKUserMessagePartial;
+ expect(msg.session_id).toBe("");
+ expect(msg.parent_tool_use_id).toBeNull();
const imgBlock = msg.message.content[1] as Record;
const source = imgBlock.source as Record;
@@ -110,6 +114,8 @@ describe("buildClaudeV2Message", () => {
expect(typeof result).toBe("object");
const msg = result as SDKUserMessagePartial;
+ expect(msg.session_id).toBe("");
+ expect(msg.parent_tool_use_id).toBeNull();
const content = msg.message.content;
expect(content).toHaveLength(2);
expect(content[0]).toEqual({ type: "text", text: "Look at this" });
@@ -133,6 +139,8 @@ describe("buildClaudeV2Message", () => {
expect(typeof result).toBe("object");
const msg = result as SDKUserMessagePartial;
+ expect(msg.session_id).toBe("");
+ expect(msg.parent_tool_use_id).toBeNull();
const content = msg.message.content;
expect(content).toHaveLength(2);
expect(content[0]).toEqual({ type: "text", text: "Check this diagram" });
@@ -157,6 +165,8 @@ describe("buildClaudeV2Message", () => {
expect(typeof result).toBe("object");
const msg = result as SDKUserMessagePartial;
+ expect(msg.session_id).toBe("");
+ expect(msg.parent_tool_use_id).toBeNull();
const content = msg.message.content;
// 4 blocks: prompt text + file hint + image + file hint
diff --git a/apps/desktop/src/main/services/chat/buildClaudeV2Message.ts b/apps/desktop/src/main/services/chat/buildClaudeV2Message.ts
index 15fe11137..0cabfd0ad 100644
--- a/apps/desktop/src/main/services/chat/buildClaudeV2Message.ts
+++ b/apps/desktop/src/main/services/chat/buildClaudeV2Message.ts
@@ -66,6 +66,8 @@ export function inferAttachmentMediaType(attachment: AgentChatFileRef): string {
*/
export type SDKUserMessagePartial = {
type: "user";
+ session_id: string;
+ parent_tool_use_id: string | null;
message: {
role: "user";
content: Array>;
@@ -81,7 +83,7 @@ export type SDKUserMessagePartial = {
export function buildClaudeV2Message(
promptText: string,
attachments: ResolvedAgentChatFileRef[],
- options: { baseDir?: string } = {},
+ options: { baseDir?: string; sessionId?: string | null } = {},
): string | SDKUserMessagePartial {
const imageAttachments = attachments.filter((a) => a.type === "image");
if (!imageAttachments.length) {
@@ -126,10 +128,13 @@ export function buildClaudeV2Message(
}
}
- // Match the streaming input format from the SDK docs -- minimal fields,
- // let the SDK fill in session_id, parent_tool_use_id, etc.
+ // Match the SDKUserMessage shape that session.send() accepts in V2.
+ // An empty session_id mirrors the SDK's own string-to-message conversion
+ // before the first init event establishes the concrete session ID.
return {
type: "user",
+ session_id: options.sessionId?.trim() ?? "",
+ parent_tool_use_id: null,
message: { role: "user", content },
};
}
diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts
index 00cd63894..6c5389111 100644
--- a/apps/desktop/src/main/services/config/projectConfigService.ts
+++ b/apps/desktop/src/main/services/config/projectConfigService.ts
@@ -2227,11 +2227,12 @@ function validateEffectiveConfig(
issues.push({ path: `${p}.gracefulShutdownMs`, message: "gracefulShutdownMs must be > 0" });
}
- const absCwd = path.isAbsolute(proc.cwd) ? proc.cwd : path.join(projectRoot, proc.cwd);
- if (proc.cwd && !isDirectory(absCwd)) {
- issues.push({ path: `${p}.cwd`, message: `cwd does not exist: ${proc.cwd}` });
- } else if (proc.cwd && !isPathWithinProjectRoot(projectRoot, absCwd)) {
- issues.push({ path: `${p}.cwd`, message: `cwd must stay within the project root: ${proc.cwd}` });
+ if (proc.cwd) {
+ try {
+ resolvePathWithinRoot(projectRoot, proc.cwd, { allowMissing: true });
+ } catch {
+ issues.push({ path: `${p}.cwd`, message: `cwd must stay within the project root: ${proc.cwd}` });
+ }
}
if (proc.readiness.type === "port") {
diff --git a/apps/desktop/src/main/services/cto/ctoStateService.test.ts b/apps/desktop/src/main/services/cto/ctoStateService.test.ts
index b5f183181..fa19af225 100644
--- a/apps/desktop/src/main/services/cto/ctoStateService.test.ts
+++ b/apps/desktop/src/main/services/cto/ctoStateService.test.ts
@@ -58,6 +58,7 @@ describe("ctoStateService", () => {
expect(buildAdeGitignore()).not.toContain("cto/identity.yaml");
expect(buildAdeGitignore()).toContain("cto/core-memory.json");
expect(buildAdeGitignore()).toContain("cto/CURRENT.md");
+ expect(buildAdeGitignore()).toContain("cto/openclaw-history.json");
fixture.db.close();
});
@@ -549,14 +550,15 @@ describe("ctoStateService", () => {
expect(preview.sections[2]?.content).toContain("Immutable doctrine");
expect(preview.sections[2]?.content).toContain("Use memoryUpdateCore only when the standing project brief changes");
expect(preview.sections[2]?.content).toContain("Do not write ephemeral turn-by-turn status");
- // Knowledge section: ADE environment glossary, chat vs terminal disambiguation, task routing
- expect(preview.sections[3]?.content).toContain("ADE environment glossary");
+ // Knowledge section: ADE architecture, chat vs terminal disambiguation, task routing, model selection
+ expect(preview.sections[3]?.content).toContain("ADE Architecture");
expect(preview.sections[3]?.content).toContain("spawnChat");
expect(preview.sections[3]?.content).toContain("createTerminal");
expect(preview.sections[3]?.content).toContain("spawn_agent is an MCP tool");
- // Capabilities section: concrete tool list
- expect(preview.sections[4]?.content).toContain("ADE operator tools");
- expect(preview.sections[4]?.content).toContain("listLanes, inspectLane, createLane");
+ expect(preview.sections[3]?.content).toContain("Model Selection");
+ // Capabilities section: organized tool reference with descriptions
+ expect(preview.sections[4]?.content).toContain("ADE Operator Tools");
+ expect(preview.sections[4]?.content).toContain("listLanes");
expect(preview.sections[4]?.content).toContain("UI navigation is suggestion-only.");
expect(preview.prompt).toContain("Immutable ADE doctrine");
expect(preview.prompt).toContain("Selected personality overlay");
diff --git a/apps/desktop/src/main/services/cto/ctoStateService.ts b/apps/desktop/src/main/services/cto/ctoStateService.ts
index 94a4d3c03..033bd8bfc 100644
--- a/apps/desktop/src/main/services/cto/ctoStateService.ts
+++ b/apps/desktop/src/main/services/cto/ctoStateService.ts
@@ -67,8 +67,8 @@ const DURABLE_MEMORY_CATEGORY_ORDER: MemoryCategory[] = [
const IMMUTABLE_CTO_DOCTRINE = [
"You are the CTO for the current project inside ADE.",
- "ADE is the local-first operating environment for this codebase. It manages lanes, chats, missions, workers, proofs, and connected systems like Linear.",
- "You are not a generic assistant. You are the persistent technical and operational lead for this project inside ADE.",
+ "ADE (Autonomous Development Environment) is a local-first Electron desktop app that wraps your entire development workflow: git branching via lanes, AI chat sessions, terminal shells, PR management, mission orchestration, worker agents, conflict resolution, test execution, Linear integration, and more.",
+ "You are not a generic assistant. You are the persistent technical and operational lead for this project inside ADE. You have deep knowledge of every ADE feature and can perform any action the app supports through your operator tools.",
"Answer identity questions as the project's CTO. Do not reframe yourself as Codex, Claude, or a detached chatbot.",
"",
"Your responsibilities:",
@@ -77,6 +77,15 @@ const IMMUTABLE_CTO_DOCTRINE = [
"- Use ADE surfaces and delegation paths when they help move the project forward",
"- Search the repo and project memory before asking the user for context that ADE already has",
"- Be decisive when the tradeoff is clear, and escalate when a decision is risky or irreversible",
+ "- Execute user requests precisely — when a user asks for a specific model, lane, or configuration, honor the request exactly",
+ "- Proactively check project health, recent events, and worker status to stay aware of the project state",
+ "",
+ "Precision rules:",
+ "- When the user specifies a model (e.g. 'use opus', 'use gpt-5.4'), pass the exact modelId to spawnChat or other tools. Never silently fall back to a default.",
+ "- When the user asks to 'start a chat' or 'launch an agent', use spawnChat with the specified model and initial prompt. If the user explicitly asks for a terminal, CLI tool, or shell command, use createTerminal instead — both are valid, just match the intent.",
+ "- All ADE internals are fair game. The user can request any action: launching chats, opening terminals, running CLI tools, spawning agents, managing lanes, etc. Never refuse an action that ADE supports.",
+ "- When the user asks about something you can look up (lane status, PR checks, test results), call the tool first and report facts. Do not guess.",
+ "- When you are unsure which tool to use, consult the capability manifest in your system prompt before asking the user.",
].join("\n");
const CTO_MEMORY_OPERATING_MODEL = [
@@ -97,69 +106,335 @@ const CTO_MEMORY_OPERATING_MODEL = [
].join("\n");
const CTO_ENVIRONMENT_KNOWLEDGE = [
- "ADE environment glossary:",
- "- Lane: a git worktree with its own branch, directory, processes, and chat sessions. Lanes isolate parallel work streams. Tools: listLanes, inspectLane, createLane, deleteLane.",
- "- Native ADE chat: a persistent chat session in the ADE UI with streaming, tool approval, and full service integration. Created with spawnChat. NOT a terminal.",
- "- PTY terminal: a shell terminal session (may run any CLI command). Created with createTerminal. Indirect tool access, no ADE service integration.",
- "- Mission: a structured task unit with runs, steps, and workers. Can be launched, steered, paused, and observed. Tools: startMission, launchMissionRun, steerMission, getMissionStatus.",
- "- Worker: a named agent instance (engineer, QA, researcher) that runs in a lane executing missions or tasks. Tools: listWorkers, createWorker, wakeWorker, getWorkerStatus.",
- "- Convergence: ADE's PR merge pipeline with automated validation, issue detection, and iterative resolution rounds. Tools: getPullRequestConvergence, startPullRequestConvergenceRound.",
- "- Conflict resolution: ADE can predict, simulate, propose, and apply merge conflict resolutions. Tools: getConflictStatus, simulateMerge, requestConflictProposal, applyConflictProposal.",
+ "# ADE Architecture & Concepts",
+ "",
+ "## Core Concepts",
+ "",
+ "Lane: A git worktree with its own branch, working directory, processes, terminals, and chat sessions. Lanes isolate parallel work streams. Types:",
+ " - primary: the main checkout (repo root). Always exists.",
+ " - worktree: an isolated git worktree at .ade/worktrees//. Created by ADE for feature branches.",
+ " - attached: an external worktree the user linked to ADE.",
+ " Lanes have a parent (baseRef), can be stacked (child lanes), and carry metadata: color, icon, tags, status (dirty/ahead/behind/rebaseInProgress).",
+ " Tools: listLanes, inspectLane, createLane, deleteLane, renameLane, archiveLane.",
+ "",
+ "Native ADE Chat: A persistent AI chat session in the ADE UI with streaming responses, tool approval flow, file diff display, and full service integration. This is the primary way work gets done in ADE.",
+ " - Created with spawnChat({ laneId?, modelId?, reasoningEffort?, title?, initialPrompt? }).",
+ " - Supports any registered model (Claude, GPT, local models).",
+ " - Has a message composer with slash commands, file attachments, model selector.",
+ " - Chat sessions belong to a lane and can be listed, steered, interrupted, or ended.",
+ "",
+ "PTY Terminal: A shell terminal session (runs any CLI command). Created with createTerminal({ laneId, title?, startupCommand? }). No ADE tool integration — use for raw shell commands only.",
+ "",
+ "Mission: A structured, multi-step task unit with planning, execution runs, workers, and artifacts. Missions break down complex work into phases and steps.",
+ " - Lifecycle: draft → queued → planning → in_progress → completed/failed/cancelled.",
+ " - Missions can require intervention (human review at checkpoints).",
+ " - Tools: listMissions, startMission, getMissionStatus, launchMissionRun, steerMission, updateMission.",
+ "",
+ "Worker: A named agent instance (engineer, QA, researcher, etc.) that runs in a lane executing missions or tasks autonomously.",
+ " - Workers have a budget, heartbeat, and can be woken with specific tasks.",
+ " - Status: idle, active, running, paused.",
+ " - Tools: listWorkers, createWorker, updateWorker, removeWorker, wakeWorker, getWorkerStatus.",
+ "",
+ "Convergence: ADE's automated PR merge pipeline with validation, issue detection (CI failures, review threads, comments), and iterative AI resolution rounds.",
+ " - Tracks issues per PR with severity levels and automated fix attempts.",
+ " - Tools: getPullRequestConvergence, startPullRequestConvergenceRound, stopPullRequestConvergence, updatePullRequestConvergencePipeline.",
+ "",
+ "Conflict Resolution: ADE can predict, simulate, propose, and apply merge conflict resolutions across lanes.",
+ " - Risk matrix shows potential conflicts before they happen.",
+ " - AI-generated proposals can be applied or undone.",
+ " - Tools: getConflictStatus, getConflictRiskMatrix, simulateMerge, requestConflictProposal, applyConflictProposal.",
+ "",
+ "## ADE Pages & Navigation",
+ "",
+ "ADE has these main pages (accessible via tab navigation):",
+ " /work — Main workspace with terminal sessions and chat panels. This is where active development happens.",
+ " /lanes — Lane browser showing all lanes, their status, git actions, diffs, stacks, and PR panels.",
+ " /files — File explorer for browsing and editing project files.",
+ " /prs — Pull request management: list, detail view, convergence, queue, GitHub integration.",
+ " /missions — Mission control center: create missions, monitor runs, view artifacts and logs.",
+ " /cto — CTO settings page: your identity, core memory, team/workers, Linear integration.",
+ " /graph — Workspace dependency graph visualization showing lane relationships.",
+ " /history — Operation history timeline showing all past actions.",
+ " /automations — Automation rule builder: create rules triggered by events (PR opened, test failed, etc.).",
+ " /settings — App settings: AI providers, GitHub token, Linear integration, keybindings, usage budgets, MCP servers.",
+ " When an action should be opened in ADE, return a navigation suggestion. Never silently switch tabs.",
+ "",
+ "## Model Selection",
"",
- "Critical distinction — chats vs terminals:",
- "- To launch a native ADE chat session: call spawnChat({ laneId?, title?, initialPrompt? }) directly. Do NOT use ToolSearch to find it — just call it. This creates a full ADE work chat with UI, streaming, tool approval, and supervision.",
- "- To open a terminal: call createTerminal({ laneId, title?, startupCommand? }) directly.",
- "- spawn_agent is an MCP tool for Claude CLI subprocesses in tracked terminals. It is NOT the same as spawnChat. Never use spawn_agent when the user asks for 'a chat' or 'a new session'.",
+ "ADE supports multiple AI providers and models. When spawning chats or configuring workers, use the correct modelId:",
+ " Anthropic models (via Claude CLI): anthropic/claude-opus-4-6 (shortId: opus), anthropic/claude-sonnet-4-6 (shortId: sonnet), anthropic/claude-haiku-4-5 (shortId: haiku).",
+ " OpenAI models (via Codex CLI): openai/gpt-5.4-codex (shortId: gpt-5.4-codex), openai/gpt-5.4-mini-codex, openai/gpt-5.3-codex, openai/gpt-5.3-codex-spark, openai/gpt-5.2-codex, openai/gpt-5.1-codex-max, openai/gpt-5.1-codex-mini.",
+ " Local models: ollama/llama-3.3, lmstudio/* (discovered at runtime).",
+ " Reasoning effort (for supported models): low, medium, high, max (opus), xhigh (openai).",
+ " IMPORTANT: When the user says 'use opus' → modelId: 'anthropic/claude-opus-4-6'. 'Use sonnet' → 'anthropic/claude-sonnet-4-6'. 'Use gpt-5.4' → 'openai/gpt-5.4-codex'. Always pass the full modelId, never just the shortId, to spawnChat and other tools.",
+ "",
+ "## Critical Distinctions",
+ "",
+ "Chats vs Terminals — both are valid, match the user's intent:",
+ " - spawnChat: Creates a native ADE chat session with AI, streaming, tool approval, and service integration. Use when the user wants an AI agent, a chat, or AI-powered work.",
+ " - createTerminal: Opens a shell (PTY) for raw CLI commands. Use when the user wants a terminal, shell, or to run a specific CLI tool.",
+ " - spawn_agent is an MCP tool for Claude CLI subprocesses in tracked terminals. It differs from spawnChat — when the user says 'start a chat' or 'launch an agent', prefer spawnChat. But if the user explicitly wants a CLI agent or terminal-based tool, createTerminal or spawn_agent are fine.",
+ " - Example: 'Launch a chat with opus' → spawnChat({ modelId: 'anthropic/claude-opus-4-6', ... }). 'Open a terminal' → createTerminal. 'Run npm test' → createTerminal({ startupCommand: 'npm test' }).",
"",
"Tool calling convention:",
- "- ADE tools are available as MCP tools. When you see them in your tool list, they may be prefixed (e.g., mcp__ade__spawnChat). Call them directly by name — do not search for them with ToolSearch.",
- "- If a tool from the manifest below is not in your immediate tool list, it may still be available. Try calling it directly before concluding it does not exist.",
+ " - ADE tools are available as MCP tools. They may be prefixed (e.g., mcp__ade__spawnChat). Call them directly by name.",
+ " - If a tool from the manifest below is not in your immediate tool list, try calling it directly before concluding it does not exist.",
+ "",
+ "## PR Lifecycle in ADE",
+ "",
+ " 1. Create a lane for the feature branch (createLane).",
+ " 2. Work in the lane (spawnChat with initialPrompt, or manually via terminals).",
+ " 3. Commit and push (gitCommit, gitPush).",
+ " 4. Create PR from lane (createPrFromLane).",
+ " 5. Monitor PR status (getPullRequestStatus), checks, reviews.",
+ " 6. If issues: run convergence rounds (startPullRequestConvergenceRound) for automated fixes.",
+ " 7. Request reviewers (requestPrReviewers), respond to feedback.",
+ " 8. Land PR when ready (landPullRequest).",
+ "",
+ "## Git Operations in ADE",
+ "",
+ " All git operations are lane-scoped. Pass a laneId (defaults to the CTO's current lane if omitted).",
+ " Stage & commit: gitCommit({ laneId, message, stageAll: true }).",
+ " Push/pull/fetch: gitPush, gitPull, gitFetch.",
+ " Branch management: gitListBranches, gitCheckoutBranch({ branch, create: true }).",
+ " Stash: gitStashPush, gitStashPop, gitStashList.",
+ " Conflict handling: gitGetConflictState, gitRebaseContinue, gitRebaseAbort, gitMergeAbort.",
+ " History: gitListRecentCommits({ laneId, limit }).",
+ " Status: gitStatus({ laneId }) returns branch info, ahead/behind counts, dirty state.",
+ "",
+ "## Linear Integration",
+ "",
+ " ADE integrates with Linear for issue tracking and workflow automation.",
+ " - List and inspect Linear issues: listLinearIssues, getLinearIssue.",
+ " - Update issues: updateLinearIssueAssignee, addLinearIssueLabel, updateLinearIssueState, commentOnLinearIssue.",
+ " - Route issues to work: routeLinearIssueToCto (handle yourself), routeLinearIssueToMission (auto-plan), routeLinearIssueToWorker (delegate).",
+ " - Workflow management: listLinearWorkflows, getLinearRunStatus, resolveLinearRunAction, cancelLinearRun, rerouteLinearRun.",
+ "",
+ "## Automation System",
+ "",
+ " ADE automations are event-driven rules that trigger actions when conditions are met.",
+ " - List rules: listAutomations. Trigger manually: triggerAutomation. View history: listAutomationRuns.",
+ " - Rules can be configured in /automations or /settings.",
+ "",
+ "## Memory System",
+ "",
+ " ADE has a 4-layer memory model (detailed in the Memory and Continuity section).",
+ " - memorySearch: retrieve stored decisions, patterns, conventions, gotchas.",
+ " - memoryAdd: store durable lessons for future sessions.",
+ " - memoryUpdateCore: update the standing project brief (summary, conventions, preferences, focus, notes).",
+ " - memoryPin / memoryDelete: manage individual memory items.",
"",
- "Task routing:",
- "- 'Start a chat' or 'launch an agent' → spawnChat with an initialPrompt describing the task.",
- "- 'Check PR status' → getPullRequestStatus or getPullRequestConvergence.",
- "- 'Start work on [feature]' → create/find a lane, then spawnChat or startMission.",
- "- 'Open a terminal' → createTerminal.",
- "- 'Commit and push' → gitCommit then gitPush.",
- "- 'Check for conflicts' → getConflictStatus or getConflictRiskMatrix.",
- "- 'Resolve merge conflicts' → getConflictStatus, requestConflictProposal, applyConflictProposal.",
- "- 'Steer an active agent' → steerChat({ sessionId, instruction }).",
- "- 'How is the project doing?' → getProjectHealthSummary.",
- "- 'What happened recently?' → getRecentEvents.",
- "- 'Review browser screenshots' → listComputerUseArtifacts, getArtifactPreview, reviewArtifact.",
- "- 'How much are we spending?' → getProjectBudgetStatus or getWorkerCostBreakdown.",
- "- 'Review this PR's code' → getPullRequestDiff, then approvePullRequest or requestPrChanges.",
+ "## Tests",
+ "",
+ " ADE discovers test suites from the project config and can run them per-lane.",
+ " - listTestSuites: see available test commands.",
+ " - runTests({ laneId, suiteId }): execute tests and get results.",
+ " - listTestRuns, getTestLog: review test history and output.",
+ "",
+ "## Task Routing (intent → tool mapping)",
+ "",
+ " 'Start a chat' or 'launch an agent' → spawnChat({ modelId, initialPrompt, title }).",
+ " 'Start a chat with opus/sonnet/gpt-5.4/haiku' → spawnChat({ modelId: '', ... }). Always map the name to the full ID.",
+ " 'Check PR status' → getPullRequestStatus or getPullRequestConvergence.",
+ " 'Start work on [feature]' → create/find a lane, then spawnChat or startMission.",
+ " 'Open a terminal' → createTerminal.",
+ " 'Run the tests' → listTestSuites to find available suites, then runTests.",
+ " 'Commit and push' → gitCommit then gitPush.",
+ " 'Check for conflicts' → getConflictStatus or getConflictRiskMatrix.",
+ " 'Resolve merge conflicts' → getConflictStatus, requestConflictProposal, applyConflictProposal.",
+ " 'Steer an active agent' → steerChat({ sessionId, instruction }).",
+ " 'How is the project doing?' → getProjectHealthSummary.",
+ " 'What happened recently?' → getRecentEvents.",
+ " 'List all lanes' or 'show me the branches' → listLanes.",
+ " 'Create a new branch for [feature]' → createLane({ name, description }).",
+ " 'Read a file' → readWorkspaceFile({ filePath }).",
+ " 'Search the code for [pattern]' → searchWorkspaceText({ query }) or searchCodebase({ pattern }).",
+ " 'What model is this using?' → report the current session's model from your identity state.",
+ " 'Review browser screenshots' → listComputerUseArtifacts, getArtifactPreview, reviewArtifact.",
+ " 'How much are we spending?' → getProjectBudgetStatus or getWorkerCostBreakdown.",
+ " 'Review this PR's code' → getPullRequestDiff, then approvePullRequest or requestPrChanges.",
+ " 'Show me the Linear issues' → listLinearIssues.",
+ " 'What processes are running?' → listManagedProcesses.",
+ " 'Start the dev server' → startManagedProcess({ processId }) or createTerminal with the startup command.",
+ " 'Rename this lane' → renameLane({ laneId, name }).",
+ " 'Archive a lane' → archiveLane({ laneId }).",
+ " 'Show me recent commits' → gitListRecentCommits.",
+ " 'Create a PR for this lane' → createPrFromLane({ laneId, title, body }).",
].join("\n");
// Keep in sync with ctoOperatorTools.ts tool registrations
const CTO_CAPABILITY_MANIFEST = [
- "ADE operator tools (complete list):",
- "- Lanes: listLanes, inspectLane, createLane, deleteLane",
- "- Chats: listChats, spawnChat, sendChatMessage, interruptChat, resumeChat, endChat, getChatStatus, getChatTranscript",
- "- Chat steering: steerChat, cancelSteer, handoffChat, listSubagents, approveToolUse",
- "- Missions: listMissions, startMission, getMissionStatus, updateMission, launchMissionRun, resolveMissionIntervention, getMissionRunView, getMissionLogs, listMissionWorkerDigests, steerMission",
- "- Workers: listWorkers, createWorker, updateWorker, removeWorker, updateWorkerStatus, wakeWorker, getWorkerStatus",
- "- Git: gitStatus, gitCommit, gitPush, gitPull, gitFetch, gitListRecentCommits, gitListBranches, gitCheckoutBranch, gitStashPush, gitStashPop, gitStashList, gitGetConflictState, gitRebaseContinue, gitRebaseAbort, gitMergeAbort",
- "- PRs: listPullRequests, getPullRequestStatus, commentOnPullRequest, updatePullRequestTitle, updatePullRequestBody, createPrFromLane, landPullRequest, closePullRequest, requestPrReviewers, getPullRequestDiff, approvePullRequest, requestPrChanges",
- "- Convergence: getPullRequestConvergence, updatePullRequestConvergencePipeline, updatePullRequestConvergenceRuntime, startPullRequestConvergenceRound, stopPullRequestConvergence",
- "- Conflicts: getConflictStatus, getConflictRiskMatrix, simulateMerge, runConflictPrediction, listConflictProposals, requestConflictProposal, applyConflictProposal, undoConflictProposal",
- "- Files: listFileWorkspaces, readWorkspaceFile, searchWorkspaceText",
- "- Context: getContextStatus, generateContextDocs",
- "- Processes: listManagedProcesses, startManagedProcess, stopManagedProcess, getManagedProcessLog",
- "- Tests: listTestSuites, runTests, stopTestRun, listTestRuns, getTestLog",
- "- Terminals: createTerminal",
- "- Linear: listLinearWorkflows, getLinearRunStatus, resolveLinearRunAction, cancelLinearRun, commentOnLinearIssue, updateLinearIssueState, routeLinearIssueToCto, routeLinearIssueToMission, routeLinearIssueToWorker, rerouteLinearRun, listLinearIssues, getLinearIssue, updateLinearIssueAssignee, addLinearIssueLabel",
- "- Automations: listAutomations, triggerAutomation, listAutomationRuns",
- "- Events: getRecentEvents",
- "- Project health: getProjectHealthSummary",
- "- Computer use: listComputerUseArtifacts, getArtifactPreview, reviewArtifact",
- "- Budget: getProjectBudgetStatus, getWorkerCostBreakdown",
- "- Memory: memorySearch, memoryAdd, memoryUpdateCore, memoryPin, memoryDelete",
+ "# ADE Operator Tools (complete reference)",
+ "",
+ "## Lanes (workspace isolation)",
+ " listLanes — List all lanes with status, branch info, ahead/behind counts.",
+ " inspectLane — Get detailed info for a single lane (worktree path, status, stack chain).",
+ " createLane — Create a new lane (git worktree + branch). Params: name, description, baseRef, parentLaneId.",
+ " deleteLane — Remove a lane and its worktree. Params: laneId.",
+ " renameLane — Change a lane's display name. Params: laneId, name.",
+ " archiveLane — Archive a lane (hides from default view, preserves data). Params: laneId.",
+ "",
+ "## Chats (AI work sessions)",
+ " listChats — List all chat sessions, optionally filtered by lane.",
+ " spawnChat — Create a new ADE chat session. THIS IS THE PRIMARY WAY TO LAUNCH AI AGENTS. Params: laneId, modelId (use full ID like 'anthropic/claude-sonnet-4-6'), reasoningEffort, title, initialPrompt, openInUi. The modelId is critical — always pass it when the user specifies a model.",
+ " sendChatMessage — Send a follow-up message to an existing chat. Params: sessionId, text.",
+ " interruptChat — Stop a running turn in a chat. Params: sessionId.",
+ " resumeChat — Resume a paused chat session. Params: sessionId.",
+ " endChat — Terminate a chat session. Params: sessionId.",
+ " getChatStatus — Get the current status of a chat (running, idle, ended). Params: sessionId.",
+ " getChatTranscript — Read the conversation history of a chat. Params: sessionId, limit.",
+ "",
+ "## Chat Steering (supervise active agents)",
+ " steerChat — Inject a steering instruction into an active chat session. Params: sessionId, instruction.",
+ " cancelSteer — Cancel a pending steer instruction. Params: sessionId.",
+ " handoffChat — Hand off a chat to a different agent identity. Params: sessionId, targetIdentityKey, reason.",
+ " listSubagents — List sub-agents spawned by a chat. Params: sessionId.",
+ " approveToolUse — Approve or deny a pending tool use in a supervised chat. Params: sessionId, toolUseId, decision (accept/accept_for_session/decline/cancel).",
+ "",
+ "## Missions (structured multi-step tasks)",
+ " listMissions — List all missions with status and summary.",
+ " startMission — Create and start a new mission. Params: title, description, laneId.",
+ " getMissionStatus — Get detailed mission status, progress, and outcomes. Params: missionId.",
+ " updateMission — Update mission title, description, or configuration. Params: missionId.",
+ " launchMissionRun — Launch or re-launch a mission execution run. Params: missionId.",
+ " resolveMissionIntervention — Resolve a human intervention checkpoint. Params: missionId, resolution.",
+ " getMissionRunView — Get the detailed run view with phase/step progress. Params: missionId.",
+ " getMissionLogs — Read mission execution logs. Params: missionId.",
+ " listMissionWorkerDigests — Get summaries of worker activity in a mission. Params: missionId.",
+ " steerMission — Inject a steering directive into a running mission. Params: missionId, instruction.",
+ "",
+ "## Workers (autonomous agent instances)",
+ " listWorkers — List all worker agents with status and budget info.",
+ " createWorker — Create a new worker agent. Params: name, description, role, laneId.",
+ " updateWorker — Update worker config (name, description, role, model prefs). Params: agentId.",
+ " removeWorker — Delete a worker agent. Params: agentId.",
+ " updateWorkerStatus — Change worker status (active, paused, idle). Params: agentId, status.",
+ " wakeWorker — Wake a worker with a specific task or issue. Params: agentId, taskKey, issueKey, message.",
+ " getWorkerStatus — Get detailed worker status with recent activity. Params: agentId.",
+ "",
+ "## Git (version control)",
+ " gitStatus — Branch info, ahead/behind, dirty state for a lane.",
+ " gitCommit — Create a commit. Params: laneId, message, stageAll (default true).",
+ " gitPush — Push commits to remote. Params: laneId, force.",
+ " gitPull — Pull from remote. Params: laneId.",
+ " gitFetch — Fetch remote refs. Params: laneId.",
+ " gitListRecentCommits — Show recent commits. Params: laneId, limit (default 20).",
+ " gitListBranches — List all branches. Params: laneId.",
+ " gitCheckoutBranch — Switch or create branch. Params: laneId, branch, create.",
+ " gitStashPush — Stash working changes. Params: laneId, message.",
+ " gitStashPop — Pop latest stash. Params: laneId.",
+ " gitStashList — List stashes. Params: laneId.",
+ " gitGetConflictState — Check for merge/rebase conflicts. Params: laneId.",
+ " gitRebaseContinue — Continue rebase after conflict resolution. Params: laneId.",
+ " gitRebaseAbort — Abort in-progress rebase. Params: laneId.",
+ " gitMergeAbort — Abort in-progress merge. Params: laneId.",
+ "",
+ "## Pull Requests",
+ " listPullRequests — List all tracked PRs with status.",
+ " getPullRequestStatus — Detailed PR status: checks, reviews, merge readiness. Params: prId.",
+ " commentOnPullRequest — Add a comment to a PR. Params: prId, body.",
+ " updatePullRequestTitle — Change PR title. Params: prId, title.",
+ " updatePullRequestBody — Change PR description. Params: prId, body.",
+ " createPrFromLane — Create a GitHub PR from a lane. Params: laneId, title, body, draft.",
+ " landPullRequest — Merge/land a PR. Params: prId, mergeMethod.",
+ " closePullRequest — Close a PR without merging. Params: prId.",
+ " requestPrReviewers — Request reviewers for a PR. Params: prId, reviewers.",
+ " getPullRequestDiff — Get the full diff for code review. Params: prId.",
+ " approvePullRequest — Approve a PR review. Params: prId, body.",
+ " requestPrChanges — Request changes on a PR. Params: prId, body.",
+ "",
+ "## Convergence (automated PR resolution)",
+ " getPullRequestConvergence — Get convergence status, issues, and round history. Params: prId.",
+ " updatePullRequestConvergencePipeline — Update pipeline settings for a PR. Params: prId.",
+ " updatePullRequestConvergenceRuntime — Update runtime state (enable/disable auto-converge). Params: prId.",
+ " startPullRequestConvergenceRound — Start an AI resolution round for PR issues. Params: prId.",
+ " stopPullRequestConvergence — Stop an active convergence run. Params: prId.",
+ "",
+ "## Conflict Resolution",
+ " getConflictStatus — Check merge conflict status for a lane. Params: laneId.",
+ " getConflictRiskMatrix — Risk matrix across all lanes (predicts conflicts before they happen).",
+ " simulateMerge — Dry-run merge between two lanes. Params: sourceLaneId, targetLaneId.",
+ " runConflictPrediction — Batch conflict prediction across all lanes.",
+ " listConflictProposals — List AI-generated resolution proposals. Params: laneId.",
+ " requestConflictProposal — Request AI resolution for a conflict. Params: laneId, filePath.",
+ " applyConflictProposal — Apply a resolution proposal. Params: laneId, proposalId.",
+ " undoConflictProposal — Revert an applied proposal. Params: laneId, proposalId.",
+ "",
+ "## Files",
+ " listFileWorkspaces — List file workspaces (one per lane).",
+ " readWorkspaceFile — Read a file's contents. Params: filePath, laneId.",
+ " searchWorkspaceText — Search for text patterns in workspace files. Params: query, laneId.",
+ " searchCodebase — Search the ADE codebase itself for patterns (for self-debugging). Params: pattern, fileGlob.",
+ "",
+ "## Context & Documentation",
+ " getContextStatus — Check what ADE context docs exist and staleness.",
+ " generateContextDocs — Generate context packs for workers or export.",
+ "",
+ "## Processes (managed dev servers, builds, etc.)",
+ " listManagedProcesses — List defined processes and their runtime status.",
+ " startManagedProcess — Start a defined process. Params: processId, laneId.",
+ " stopManagedProcess — Stop a running process. Params: processId, laneId.",
+ " getManagedProcessLog — Read process log output. Params: processId, laneId.",
+ "",
+ "## Tests",
+ " listTestSuites — List available test suite definitions.",
+ " runTests — Run a test suite in a lane. Params: laneId, suiteId.",
+ " stopTestRun — Stop a running test. Params: runId.",
+ " listTestRuns — List recent test runs with pass/fail status.",
+ " getTestLog — Read test run output. Params: runId.",
+ "",
+ "## Terminals",
+ " createTerminal — Open a shell terminal in a lane. Params: laneId, title, startupCommand.",
+ "",
+ "## Linear Integration",
+ " listLinearWorkflows — List active Linear workflow runs.",
+ " getLinearRunStatus — Get status of a specific Linear workflow run. Params: runId.",
+ " resolveLinearRunAction — Approve/reject a Linear workflow action. Params: runId, action.",
+ " cancelLinearRun — Cancel a Linear workflow run. Params: runId.",
+ " rerouteLinearRun — Reroute a Linear run to a different handler. Params: runId, target.",
+ " commentOnLinearIssue — Add a comment to a Linear issue. Params: issueId, body.",
+ " updateLinearIssueState — Move a Linear issue to a new state. Params: issueId, stateId.",
+ " routeLinearIssueToCto — Route a Linear issue to yourself (the CTO) for handling.",
+ " routeLinearIssueToMission — Auto-create a mission from a Linear issue. Params: issueId.",
+ " routeLinearIssueToWorker — Delegate a Linear issue to a worker agent. Params: issueId, agentId.",
+ " listLinearIssues — Search/list Linear issues. Params: projectSlug, query, limit.",
+ " getLinearIssue — Get full detail of a Linear issue. Params: issueId.",
+ " updateLinearIssueAssignee — Assign/unassign a Linear issue. Params: issueId, assigneeId.",
+ " addLinearIssueLabel — Add a label to a Linear issue. Params: issueId, labelName.",
+ "",
+ "## Automations",
+ " listAutomations — List all automation rules.",
+ " triggerAutomation — Manually trigger an automation rule. Params: id, dryRun.",
+ " listAutomationRuns — List recent automation run history.",
+ "",
+ "## Events & Health",
+ " getRecentEvents — Unified feed of recent project events (sessions, worker activity, tests, PRs, missions, chats). Params: since, limit.",
+ " getProjectHealthSummary — Aggregate dashboard: mission counts, worker utilization, test pass rates, PR status, budget burn.",
+ "",
+ "## Computer Use",
+ " listComputerUseArtifacts — List browser screenshots and artifacts from computer use sessions.",
+ " getArtifactPreview — Preview a specific artifact. Params: artifactId.",
+ " reviewArtifact — Review and approve/reject a computer use artifact. Params: artifactId, decision.",
+ "",
+ "## Budget & Cost",
+ " getProjectBudgetStatus — Get project-wide budget and spending snapshot.",
+ " getWorkerCostBreakdown — Get cost breakdown per worker agent. Params: agentId, monthKey.",
+ "",
+ "## Memory",
+ " memorySearch — Search stored decisions, patterns, conventions, gotchas. Params: query.",
+ " memoryAdd — Store a durable lesson/decision for future sessions. Params: category, content.",
+ " memoryUpdateCore — Update the standing project brief (summary, conventions, preferences, focus, notes). Params: patch.",
+ " memoryPin — Pin an important memory item. Params: memoryId.",
+ " memoryDelete — Remove a memory item. Params: memoryId.",
+ "",
+ "# Operating Rules",
"",
- "Operating rules:",
"- Internal ADE actions run through service-backed tools even when no renderer click occurs.",
- "- UI navigation is suggestion-only. When an action should be opened in ADE, return an explicit navigation suggestion instead of silently switching tabs.",
+ "- UI navigation is suggestion-only. When an action should open in ADE, return an explicit navigation suggestion instead of silently switching tabs.",
"- Treat ADE as your operating environment. Do not describe yourself as blocked on renderer button clicks when an internal tool can do the work.",
+ "- When multiple tools exist for similar purposes, prefer the higher-level one (e.g., createPrFromLane over manual git commands).",
+ "- Always default laneId to the CTO's current lane if the user doesn't specify one.",
+ "- For model-specific requests, always resolve the user's model name to the full modelId before calling spawnChat.",
].join("\n");
function asStringArray(value: unknown): string[] {
diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts
index 530ee59b5..f1fd44655 100644
--- a/apps/desktop/src/main/services/ipc/registerIpc.ts
+++ b/apps/desktop/src/main/services/ipc/registerIpc.ts
@@ -3629,13 +3629,25 @@ export function registerIpc({
}
const expectedHostname = ctx.laneProxyService.generateHostname(laneId, lane.name);
+ const health = ctx.runtimeDiagnosticsService
+ ? await ctx.runtimeDiagnosticsService.checkLaneHealth(laneId).catch(() => null)
+ : null;
+ const validatedRespondingPort =
+ Number.isInteger(health?.respondingPort) &&
+ (health?.respondingPort as number) > 0 &&
+ (health?.respondingPort as number) >= lease.rangeStart &&
+ (health?.respondingPort as number) <= lease.rangeEnd
+ ? (health?.respondingPort as number)
+ : null;
+ const targetPort = validatedRespondingPort ?? lease.rangeStart;
const currentRoute = ctx.laneProxyService.getRoute(laneId);
if (
!currentRoute ||
- currentRoute.targetPort !== lease.rangeStart ||
- currentRoute.hostname !== expectedHostname
+ currentRoute.targetPort !== targetPort ||
+ currentRoute.hostname !== expectedHostname ||
+ currentRoute.status !== "active"
) {
- ctx.laneProxyService.addRoute(laneId, lease.rangeStart, lane.name);
+ ctx.laneProxyService.addRoute(laneId, targetPort, lane.name);
}
return ctx.laneProxyService.getPreviewInfo(laneId);
diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts
index 9f552a6e0..88d9ee705 100644
--- a/apps/desktop/src/main/services/lanes/laneService.ts
+++ b/apps/desktop/src/main/services/lanes/laneService.ts
@@ -1480,7 +1480,7 @@ export function createLaneService({
}
},
- async importBranch(args: { branchRef: string; name?: string; description?: string; parentLaneId?: string | null; baseBranch?: string }): Promise {
+ async importBranch(args: { branchRef: string; name?: string; description?: string; baseBranch?: string }): Promise {
const rawRef = (args.branchRef ?? "").trim();
if (!rawRef) throw new Error("branchRef is required");
if (rawRef.includes("\0")) throw new Error("Invalid branchRef");
@@ -1533,128 +1533,10 @@ export function createLaneService({
});
worktreeAdded = true;
- // --- Detect real parent lane via git merge-base ---
- const parentLaneIdRaw = typeof args.parentLaneId === "string" ? args.parentLaneId.trim() : "";
- const explicitParentLaneId = parentLaneIdRaw.length ? parentLaneIdRaw : null;
- let parentLaneId: string | null = null;
-
- // Try to detect the true parent by finding which lane's HEAD shares the
- // most recent common ancestor with the imported branch.
- try {
- const importedHeadSha = await getHeadSha(worktreePath);
- if (importedHeadSha) {
- const activeRows = getAllLaneRows(false);
- let bestLaneId: string | null = null;
- let bestScore = Infinity;
- let bestTieBreak: string | null = null;
-
- for (const row of activeRows) {
- // Skip the lane we are currently importing (same id won't exist yet,
- // but skip by branch_ref match just in case).
- if (row.branch_ref === branchRef) continue;
-
- const laneWorktree = row.worktree_path;
- if (!laneWorktree) continue;
-
- const laneHeadSha = await getHeadSha(laneWorktree);
- if (!laneHeadSha) continue;
-
- const mbResult = await runGit(
- ["merge-base", laneHeadSha, importedHeadSha],
- { cwd: projectRoot, timeoutMs: 10_000 },
- );
- if (mbResult.exitCode !== 0) continue;
- const mergeBaseSha = mbResult.stdout.trim();
- if (!mergeBaseSha) continue;
-
- const importedCountResult = await runGit(
- ["rev-list", "--count", `${mergeBaseSha}..${importedHeadSha}`],
- { cwd: projectRoot, timeoutMs: 10_000 },
- );
- if (importedCountResult.exitCode !== 0) continue;
- const importedDistance = parseInt(importedCountResult.stdout.trim(), 10);
- if (isNaN(importedDistance)) continue;
-
- const candidateCountResult = await runGit(
- ["rev-list", "--count", `${mergeBaseSha}..${laneHeadSha}`],
- { cwd: projectRoot, timeoutMs: 10_000 },
- );
- if (candidateCountResult.exitCode !== 0) continue;
- const candidateDistance = parseInt(candidateCountResult.stdout.trim(), 10);
- if (isNaN(candidateDistance)) continue;
-
- const score = importedDistance + candidateDistance;
- const tieBreak = `${row.id}\0${row.branch_ref}`;
- if (
- score < bestScore
- || (score === bestScore && tieBreak.localeCompare(bestTieBreak ?? "") < 0)
- ) {
- bestScore = score;
- bestLaneId = row.id;
- bestTieBreak = tieBreak;
- }
- }
-
- // Also score against defaultBaseRef (main) directly — if main is
- // equally good or better, there is no real parent lane.
- if (bestLaneId) {
- let mainScore = Infinity;
- try {
- // Prefer the remote-tracking ref so the comparison uses the
- // latest fetched state rather than a potentially stale local tip.
- let mainShaRes = await runGit(
- ["rev-parse", `origin/${defaultBaseRef}`],
- { cwd: projectRoot, timeoutMs: 10_000 },
- );
- if (mainShaRes.exitCode !== 0) {
- mainShaRes = await runGit(
- ["rev-parse", defaultBaseRef],
- { cwd: projectRoot, timeoutMs: 10_000 },
- );
- }
- const mainSha = mainShaRes.exitCode === 0 ? mainShaRes.stdout.trim() : null;
- if (mainSha) {
- const mbRes = await runGit(
- ["merge-base", mainSha, importedHeadSha],
- { cwd: projectRoot, timeoutMs: 10_000 },
- );
- if (mbRes.exitCode === 0 && mbRes.stdout.trim()) {
- const mb = mbRes.stdout.trim();
- const d1 = await runGit(["rev-list", "--count", `${mb}..${importedHeadSha}`], { cwd: projectRoot, timeoutMs: 10_000 });
- const d2 = await runGit(["rev-list", "--count", `${mb}..${mainSha}`], { cwd: projectRoot, timeoutMs: 10_000 });
- if (d1.exitCode === 0 && d2.exitCode === 0) {
- mainScore = parseInt(d1.stdout.trim(), 10) + parseInt(d2.stdout.trim(), 10);
- }
- }
- }
- } catch {
- // If main scoring fails, fall through to lane-based parent.
- }
-
- if (mainScore <= bestScore) {
- // The branch is based on main (or closer to it), no parent lane
- // — unless the caller explicitly provided one.
- parentLaneId = explicitParentLaneId ?? null;
- } else {
- if (explicitParentLaneId && explicitParentLaneId !== bestLaneId) {
- logger.warn("laneService.importBranch.parent_mismatch", {
- explicitParentLaneId,
- detectedParentLaneId: bestLaneId,
- });
- }
- parentLaneId = bestLaneId;
- }
- }
- }
- } catch (err) {
- logger.warn("laneService.importBranch.parent_detection_failed", { error: err instanceof Error ? err.message : String(err) });
- }
-
- // Fallback: use only the explicit parent when detection yielded nothing.
- if (!parentLaneId) {
- parentLaneId = explicitParentLaneId;
- }
-
+ // Imported branches are always root lanes. No caller passes
+ // parentLaneId — if a child lane is wanted, the "child" creation
+ // mode is used instead.
+ const parentLaneId: string | null = null;
const parent = parentLaneId ? getLaneRow(parentLaneId) : null;
if (parentLaneId && !parent) throw new Error(`Parent lane not found: ${parentLaneId}`);
if (parent && parent.status === "archived") throw new Error("Parent lane is archived");
diff --git a/apps/desktop/src/main/services/lanes/oauthRedirectService.test.ts b/apps/desktop/src/main/services/lanes/oauthRedirectService.test.ts
index da9746dc7..2ea9d997e 100644
--- a/apps/desktop/src/main/services/lanes/oauthRedirectService.test.ts
+++ b/apps/desktop/src/main/services/lanes/oauthRedirectService.test.ts
@@ -3,6 +3,22 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { createOAuthRedirectService } from "./oauthRedirectService";
import type { OAuthRedirectEvent, ProxyRoute } from "../../../shared/types";
+// Mock node:http — we replace `http.request` so the oauth service's
+// defaultRequestUpstream can be exercised deterministically. All other existing
+// tests inject a fake `requestUpstream`, so they never touch `http.request`.
+const httpRequestMock = vi.fn();
+vi.mock("node:http", async () => {
+ const actual = await vi.importActual("node:http");
+ return {
+ ...actual,
+ default: {
+ ...actual,
+ request: (...args: unknown[]) => httpRequestMock(...args),
+ },
+ request: (...args: unknown[]) => httpRequestMock(...args),
+ };
+});
+
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -50,6 +66,13 @@ function makeRoute(
};
}
+function locationFrom(res: any): string | null {
+ const call = res.writeHead.mock.calls.at(-1);
+ const headers = call?.[1] as Record | undefined;
+ const location = headers?.location;
+ return typeof location === "string" ? location : Array.isArray(location) ? location[0] ?? null : null;
+}
+
// ---------------------------------------------------------------------------
// Test suite
// ---------------------------------------------------------------------------
@@ -59,6 +82,7 @@ describe("oauthRedirectService", () => {
let routes: ProxyRoute[];
let logger: ReturnType;
let forwardToPort: ReturnType;
+ let requestUpstream: ReturnType;
let svc: ReturnType;
beforeEach(() => {
@@ -66,6 +90,7 @@ describe("oauthRedirectService", () => {
routes = [];
logger = createLogger();
forwardToPort = vi.fn();
+ requestUpstream = vi.fn();
svc = createOAuthRedirectService({
logger,
@@ -74,6 +99,7 @@ describe("oauthRedirectService", () => {
getProxyPort: () => 8080,
getHostnameSuffix: () => ".localhost",
forwardToPort,
+ requestUpstream: requestUpstream as any,
});
});
@@ -394,6 +420,105 @@ describe("oauthRedirectService", () => {
expect(sessions[0].status).toBe("failed");
expect(sessions[0].error).toBeDefined();
});
+
+ it("rewrites auth starts onto the stable ADE callback URL", async () => {
+ routes.push(makeRoute("lane-1", 3001));
+ requestUpstream.mockResolvedValue({
+ statusCode: 307,
+ headers: {
+ location:
+ "https://accounts.google.com/o/oauth2/v2/auth?state=raw-state&redirect_uri=http%3A%2F%2Flane-1.localhost%3A8080%2Fapi%2Fauth%2Fgoogle%2Fcallback&scope=openid",
+ "set-cookie": ["versic-oauth-state=raw-state; Path=/; HttpOnly"],
+ },
+ body: Buffer.alloc(0),
+ });
+
+ const req = mockReq("/api/auth/google", "lane-1.localhost:8080");
+ const res = mockRes();
+
+ expect(svc.handleRequest(req, res)).toBe(true);
+
+ await vi.waitFor(() => {
+ expect(res.writeHead).toHaveBeenCalled();
+ });
+
+ const rewrittenLocation = locationFrom(res);
+ expect(rewrittenLocation).toContain("redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Foauth%2Fcallback");
+ expect(rewrittenLocation).toContain("state=ade%3A");
+ expect(svc.listSessions()).toHaveLength(1);
+ expect(svc.listSessions()[0].status).toBe("pending");
+ });
+
+ it("replays the callback response back on the lane host after routing through the stable callback", async () => {
+ routes.push(makeRoute("lane-1", 3001));
+ requestUpstream
+ .mockResolvedValueOnce({
+ statusCode: 307,
+ headers: {
+ location:
+ "https://accounts.google.com/o/oauth2/v2/auth?state=raw-state&redirect_uri=http%3A%2F%2Flane-1.localhost%3A8080%2Fapi%2Fauth%2Fgoogle%2Fcallback&scope=openid",
+ "set-cookie": [
+ "versic-oauth-state=raw-state; Path=/; HttpOnly",
+ "versic-oauth-redirect=%2Fdashboard; Path=/",
+ ],
+ },
+ body: Buffer.alloc(0),
+ })
+ .mockResolvedValueOnce({
+ statusCode: 302,
+ headers: {
+ location: "/dashboard",
+ "set-cookie": ["versic-access-token=test-token; Path=/; HttpOnly"],
+ },
+ body: Buffer.alloc(0),
+ });
+
+ const startReq = mockReq("/api/auth/google", "lane-1.localhost:8080");
+ const startRes = mockRes();
+ expect(svc.handleRequest(startReq, startRes)).toBe(true);
+ await vi.waitFor(() => {
+ expect(startRes.writeHead).toHaveBeenCalled();
+ });
+
+ const rewrittenLocation = locationFrom(startRes)!;
+ const encodedState = new URL(rewrittenLocation).searchParams.get("state");
+ expect(encodedState).toBeTruthy();
+
+ const callbackReq = mockReq(
+ `/oauth/callback?code=test-code&state=${encodeURIComponent(encodedState!)}`,
+ );
+ const callbackRes = mockRes();
+ expect(svc.handleRequest(callbackReq, callbackRes)).toBe(true);
+
+ await vi.waitFor(() => {
+ expect(callbackRes.writeHead).toHaveBeenCalled();
+ });
+
+ expect(requestUpstream).toHaveBeenCalledTimes(2);
+ expect(requestUpstream.mock.calls[1][0].overridePath).toContain("/api/auth/google/callback");
+ expect(requestUpstream.mock.calls[1][0].overridePath).toContain("state=raw-state");
+ expect(requestUpstream.mock.calls[1][0].overrideHeaders.cookie).toContain("versic-oauth-state=raw-state");
+ expect(requestUpstream.mock.calls[1][0].overrideHeaders.host).toBe("lane-1.localhost:8080");
+
+ const finalizeLocation = locationFrom(callbackRes);
+ expect(finalizeLocation).toContain("lane-1.localhost:8080/__ade/oauth/finalize?token=");
+ const finalizeToken = new URL(finalizeLocation!).searchParams.get("token");
+ expect(finalizeToken).toBeTruthy();
+
+ const finalizeReq = mockReq(
+ `/__ade/oauth/finalize?token=${encodeURIComponent(finalizeToken!)}`,
+ "lane-1.localhost:8080",
+ );
+ const finalizeRes = mockRes();
+ expect(svc.handleRequest(finalizeReq, finalizeRes)).toBe(true);
+
+ expect(finalizeRes.writeHead).toHaveBeenCalledWith(302, {
+ location: "/dashboard",
+ "set-cookie": ["versic-access-token=test-token; Path=/; HttpOnly"],
+ });
+ expect(svc.listSessions()).toHaveLength(1);
+ expect(svc.listSessions()[0].status).toBe("completed");
+ });
});
// =========================================================================
@@ -575,17 +700,12 @@ describe("oauthRedirectService", () => {
// =========================================================================
describe("redirect URI generation", () => {
- it("generates generic URIs with all callback paths", () => {
+ it("generates one stable ADE-managed callback URI for the generic helper", () => {
const infos = svc.generateRedirectUris();
expect(infos).toHaveLength(1);
expect(infos[0].provider).toBe("Generic");
- expect(infos[0].uris).toEqual([
- "http://localhost:8080/oauth/callback",
- "http://localhost:8080/auth/callback",
- "http://localhost:8080/api/auth/callback",
- "http://localhost:8080/callback",
- ]);
- expect(infos[0].instructions).toBeTruthy();
+ expect(infos[0].uris).toEqual(["http://localhost:8080/oauth/callback"]);
+ expect(infos[0].instructions).toContain("ADE-managed callback URL");
});
it("Google provider returns specific URI and instructions", () => {
@@ -600,18 +720,15 @@ describe("oauthRedirectService", () => {
const infos = svc.generateRedirectUris("github");
expect(infos).toHaveLength(1);
expect(infos[0].provider).toBe("GitHub");
- expect(infos[0].uris).toEqual(["http://localhost:8080/auth/callback"]);
+ expect(infos[0].uris).toEqual(["http://localhost:8080/oauth/callback"]);
expect(infos[0].instructions).toContain("GitHub OAuth App");
});
- it("Auth0 provider returns specific URIs and instructions", () => {
+ it("Auth0 provider returns the stable ADE callback URI and instructions", () => {
const infos = svc.generateRedirectUris("auth0");
expect(infos).toHaveLength(1);
expect(infos[0].provider).toBe("Auth0");
- expect(infos[0].uris).toEqual([
- "http://localhost:8080/oauth/callback",
- "http://localhost:8080/auth/callback",
- ]);
+ expect(infos[0].uris).toEqual(["http://localhost:8080/oauth/callback"]);
expect(infos[0].instructions).toContain("Auth0");
expect(infos[0].instructions).toContain("Allowed Callback URLs");
});
@@ -631,11 +748,11 @@ describe("oauthRedirectService", () => {
custom.dispose();
});
- it("unknown provider falls back to generic URIs", () => {
+ it("unknown provider falls back to the generic stable callback URI", () => {
const infos = svc.generateRedirectUris("okta");
expect(infos).toHaveLength(1);
expect(infos[0].provider).toBe("okta");
- expect(infos[0].uris).toHaveLength(4);
+ expect(infos[0].uris).toEqual(["http://localhost:8080/oauth/callback"]);
});
});
@@ -719,9 +836,409 @@ describe("oauthRedirectService", () => {
"/oauth/callback",
"/auth/callback",
"/api/auth/callback",
+ "/api/auth/google/callback",
"/callback",
]);
expect(status.activeSessions).toEqual([]);
});
});
+
+ // =========================================================================
+ // 10. defaultRequestUpstream — timeout, abort/close listeners, body buffering
+ // =========================================================================
+ //
+ // These tests exercise the internal defaultRequestUpstream closure by NOT
+ // injecting a `requestUpstream` and driving flow through `handleRequest`.
+ // All http.request calls go through our mock so we can simulate upstream
+ // responses, timeouts, and client-side events deterministically.
+ describe("defaultRequestUpstream (private helper — driven through handleRequest)", () => {
+ let nativeSvc: ReturnType;
+ let nativeRoutes: ProxyRoute[];
+ let nativeEvents: OAuthRedirectEvent[];
+
+ function makeIncomingReq(opts: {
+ url: string;
+ host: string;
+ method: string;
+ }): any {
+ const req: any = new EventEmitter();
+ req.url = opts.url;
+ req.headers = { host: opts.host };
+ req.method = opts.method;
+ req.complete = false;
+ return req;
+ }
+
+ /**
+ * Build a fake upstream ClientRequest returned from http.request().
+ * `onEnd` fires whenever the service calls `upstreamReq.end(…)`.
+ * `emitResponse(res)` delivers a fake upstream IncomingMessage to the callback.
+ * `emitError(err)` fires the "error" listener the service registered.
+ */
+ function makeFakeUpstream(): {
+ upstreamReq: any;
+ onEnd: ReturnType;
+ emitResponse: (statusCode: number, headers: Record, body?: Buffer | string) => void;
+ emitError: (err: Error) => void;
+ setCallback: (cb: (res: any) => void) => void;
+ callback?: (res: any) => void;
+ } {
+ const onEnd = vi.fn();
+ const upstreamReq: any = new EventEmitter();
+ upstreamReq.end = onEnd;
+ const harness: any = { upstreamReq, onEnd };
+ harness.setCallback = (cb: (res: any) => void) => {
+ harness.callback = cb;
+ };
+ harness.emitResponse = (statusCode: number, headers: Record, body: Buffer | string = "") => {
+ const upstreamRes: any = new EventEmitter();
+ upstreamRes.statusCode = statusCode;
+ upstreamRes.headers = headers;
+ // Invoke service-registered callback.
+ harness.callback?.(upstreamRes);
+ // Deliver body data then end.
+ const buf = Buffer.isBuffer(body) ? body : Buffer.from(body);
+ upstreamRes.emit("data", buf);
+ upstreamRes.emit("end");
+ };
+ harness.emitError = (err: Error) => {
+ upstreamReq.emit("error", err);
+ };
+ return harness;
+ }
+
+ beforeEach(() => {
+ httpRequestMock.mockReset();
+ nativeRoutes = [];
+ nativeEvents = [];
+ nativeSvc = createOAuthRedirectService({
+ logger: createLogger(),
+ broadcastEvent: (ev) => nativeEvents.push(ev),
+ getRoutes: () => nativeRoutes,
+ getProxyPort: () => 8080,
+ getHostnameSuffix: () => ".localhost",
+ forwardToPort: vi.fn(),
+ // deliberately NO requestUpstream — drives defaultRequestUpstream
+ });
+ });
+
+ afterEach(() => {
+ nativeSvc.dispose();
+ vi.useRealTimers();
+ });
+
+ // -----------------------------------------------------------------------
+ // GET-path (auth-start) covers:
+ // * GET bypasses body buffering → upstreamReq.end() called immediately w/ no args
+ // * 30s timeout rejects with TimeoutError and aborts the AbortController
+ // * After success, a late timer tick is a no-op (settled guard)
+ // -----------------------------------------------------------------------
+
+ it("GET request bypasses body buffering: upstreamReq.end() called immediately with no args", () => {
+ nativeRoutes.push(makeRoute("lane-1", 3001));
+ const fake = makeFakeUpstream();
+ httpRequestMock.mockImplementation((_options: any, cb: (res: any) => void) => {
+ fake.setCallback(cb);
+ return fake.upstreamReq;
+ });
+
+ const req = makeIncomingReq({
+ url: "/api/auth/google",
+ host: "lane-1.localhost:8080",
+ method: "GET",
+ });
+ const res = mockRes();
+
+ expect(nativeSvc.handleRequest(req, res)).toBe(true);
+
+ expect(httpRequestMock).toHaveBeenCalledTimes(1);
+ // First end() call should be a single no-arg invocation (no body for GET).
+ expect(fake.onEnd).toHaveBeenCalledTimes(1);
+ expect(fake.onEnd.mock.calls[0]).toEqual([]);
+ });
+
+ it("upstream request signal is an AbortSignal (so aborts cancel the outbound socket)", () => {
+ nativeRoutes.push(makeRoute("lane-1", 3001));
+ const fake = makeFakeUpstream();
+ httpRequestMock.mockImplementation((_options: any, cb: (res: any) => void) => {
+ fake.setCallback(cb);
+ return fake.upstreamReq;
+ });
+
+ const req = makeIncomingReq({
+ url: "/api/auth/google",
+ host: "lane-1.localhost:8080",
+ method: "GET",
+ });
+ nativeSvc.handleRequest(req, mockRes());
+
+ const options = httpRequestMock.mock.calls[0][0];
+ expect(options, "http.request options must be defined").toBeTruthy();
+ expect(options.signal, "AbortSignal must be wired to the upstream request").toBeInstanceOf(AbortSignal);
+ expect(options.signal.aborted, "signal starts un-aborted").toBe(false);
+ });
+
+ it("30s timeout rejects with TimeoutError and aborts the AbortController signal", async () => {
+ vi.useFakeTimers();
+ nativeRoutes.push(makeRoute("lane-1", 3001));
+ const fake = makeFakeUpstream();
+ let capturedSignal: AbortSignal | undefined;
+ httpRequestMock.mockImplementation((options: any, cb: (res: any) => void) => {
+ capturedSignal = options.signal;
+ fake.setCallback(cb);
+ // Simulate http: when aborted via signal, the request emits an error.
+ options.signal?.addEventListener("abort", () => {
+ const reason = (options.signal as any).reason;
+ fake.emitError(reason instanceof Error ? reason : new Error("aborted"));
+ });
+ return fake.upstreamReq;
+ });
+
+ const req = makeIncomingReq({
+ url: "/api/auth/google",
+ host: "lane-1.localhost:8080",
+ method: "GET",
+ });
+ const res = mockRes();
+ expect(nativeSvc.handleRequest(req, res)).toBe(true);
+
+ // Advance 30 seconds → timeout fires.
+ await vi.advanceTimersByTimeAsync(30_000);
+ // Let any microtask follow-ups flush.
+ await vi.runAllTimersAsync();
+
+ expect(capturedSignal, "signal must have been captured").toBeTruthy();
+ expect(capturedSignal!.aborted, "AbortController.abort must fire on timeout").toBe(true);
+ const reason = (capturedSignal as any).reason;
+ expect(reason, "abort reason should be the TimeoutError").toBeInstanceOf(Error);
+ expect(reason.name).toBe("TimeoutError");
+ expect(reason.message).toMatch(/timed out after 30/i);
+
+ // handleRequest catches and writes a 502 error page.
+ expect(res.writeHead).toHaveBeenCalledWith(502, { "Content-Type": "text/html" });
+ });
+
+ it("resolves successfully on upstream response and a subsequent 30s timer tick is a no-op (settled flag)", async () => {
+ vi.useFakeTimers();
+ nativeRoutes.push(makeRoute("lane-1", 3001));
+ const fake = makeFakeUpstream();
+ httpRequestMock.mockImplementation((_options: any, cb: (res: any) => void) => {
+ fake.setCallback(cb);
+ return fake.upstreamReq;
+ });
+
+ const req = makeIncomingReq({
+ url: "/api/auth/google",
+ host: "lane-1.localhost:8080",
+ method: "GET",
+ });
+ const res = mockRes();
+ expect(nativeSvc.handleRequest(req, res)).toBe(true);
+
+ // Upstream responds immediately — status 200 w/ a non-redirect location (parsedRedirect is null)
+ // so the service just replays the response via sendUpstreamResponse.
+ fake.emitResponse(200, {}, "hello");
+ await vi.runAllTimersAsync();
+
+ // First writeHead call should be the success replay (status 200), not 502.
+ expect(res.writeHead).toHaveBeenCalled();
+ const firstCall = res.writeHead.mock.calls[0];
+ expect(firstCall[0], "success should not produce a 502").not.toBe(502);
+
+ const writeHeadCountBefore = res.writeHead.mock.calls.length;
+
+ // Fire the 30s timer that the timeout scheduled — cleanup should have
+ // cleared it, so we shouldn't see any additional writeHead(502) coming
+ // from a "late" timeout path.
+ await vi.advanceTimersByTimeAsync(60_000);
+ await vi.runAllTimersAsync();
+
+ expect(res.writeHead.mock.calls.length, "settled flag must prevent double-settle from late timeout").toBe(writeHeadCountBefore);
+ });
+
+ // -----------------------------------------------------------------------
+ // Non-GET path (managed callback) covers:
+ // * POST buffers chunks and ends upstream with a concatenated Buffer
+ // * Client 'aborted' rejects with AbortError and aborts the signal
+ // * Client 'close' before complete rejects with AbortError
+ // * Client 'close' after completion is a no-op
+ // -----------------------------------------------------------------------
+
+ /**
+ * Set up a pending OAuth start for lane-1 so that subsequent callbacks go
+ * through handleManagedCallback (which calls sendUpstreamRequest with the
+ * incoming req directly — allowing POST method etc).
+ *
+ * Returns the encoded state to be passed back in the callback.
+ */
+ async function primePendingStart(laneId: string, targetPort: number): Promise {
+ nativeRoutes.push(makeRoute(laneId, targetPort));
+ const fake = makeFakeUpstream();
+ httpRequestMock.mockImplementationOnce((_options: any, cb: (res: any) => void) => {
+ fake.setCallback(cb);
+ return fake.upstreamReq;
+ });
+
+ const startReq = makeIncomingReq({
+ url: "/api/auth/google",
+ host: `${laneId}.localhost:8080`,
+ method: "GET",
+ });
+ const startRes = mockRes();
+ expect(nativeSvc.handleRequest(startReq, startRes)).toBe(true);
+
+ fake.emitResponse(307, {
+ location:
+ "https://accounts.google.com/o/oauth2/v2/auth?state=raw-state&redirect_uri=http%3A%2F%2Flane-1.localhost%3A8080%2Fapi%2Fauth%2Fgoogle%2Fcallback&scope=openid",
+ });
+
+ // Wait for the auth-start promise to settle (microtask flush).
+ await vi.waitFor(() => {
+ expect(startRes.writeHead).toHaveBeenCalled();
+ });
+
+ const rewrittenLocation = locationFrom(startRes);
+ expect(rewrittenLocation, "auth-start must rewrite to the stable callback").toBeTruthy();
+ const encodedState = new URL(rewrittenLocation!).searchParams.get("state");
+ expect(encodedState, "rewritten URL must embed an ADE state").toBeTruthy();
+ return encodedState!;
+ }
+
+ it("POST callback buffers chunks and sends a concatenated Buffer to upstreamReq.end()", async () => {
+ const encodedState = await primePendingStart("lane-1", 3001);
+
+ // Now set up the upstream for the managed callback call.
+ const fake = makeFakeUpstream();
+ httpRequestMock.mockImplementationOnce((_options: any, cb: (res: any) => void) => {
+ fake.setCallback(cb);
+ return fake.upstreamReq;
+ });
+
+ const callbackReq = makeIncomingReq({
+ url: `/oauth/callback?code=xyz&state=${encodeURIComponent(encodedState)}`,
+ host: "lane-1.localhost:8080",
+ method: "POST",
+ });
+ const callbackRes = mockRes();
+ expect(nativeSvc.handleRequest(callbackReq, callbackRes)).toBe(true);
+
+ // For POST, the service should NOT have called end() yet — it's waiting for body end.
+ expect(fake.onEnd, "POST must not call end() before client 'end' fires").not.toHaveBeenCalled();
+
+ // Feed body chunks (mix Buffer + string to exercise both branches).
+ callbackReq.emit("data", Buffer.from("hello "));
+ callbackReq.emit("data", "world");
+ callbackReq.emit("end");
+
+ // Service synchronously forwards end() with the concatenated buffer.
+ expect(fake.onEnd).toHaveBeenCalledTimes(1);
+ const endArg = fake.onEnd.mock.calls[0][0];
+ expect(Buffer.isBuffer(endArg), "upstream end() should receive a Buffer").toBe(true);
+ expect((endArg as Buffer).toString("utf8")).toBe("hello world");
+
+ // Complete the upstream so the flow can fully settle (no dangling timers).
+ fake.emitResponse(200, {}, "ok");
+ await vi.waitFor(() => {
+ expect(callbackRes.writeHead).toHaveBeenCalled();
+ });
+ });
+
+ it("client 'aborted' event rejects with AbortError and aborts the AbortController signal", async () => {
+ const encodedState = await primePendingStart("lane-1", 3001);
+
+ const fake = makeFakeUpstream();
+ let capturedSignal: AbortSignal | undefined;
+ httpRequestMock.mockImplementationOnce((options: any, cb: (res: any) => void) => {
+ capturedSignal = options.signal;
+ fake.setCallback(cb);
+ return fake.upstreamReq;
+ });
+
+ const callbackReq = makeIncomingReq({
+ url: `/oauth/callback?code=xyz&state=${encodeURIComponent(encodedState)}`,
+ host: "lane-1.localhost:8080",
+ method: "POST",
+ });
+ const callbackRes = mockRes();
+ expect(nativeSvc.handleRequest(callbackReq, callbackRes)).toBe(true);
+
+ callbackReq.emit("aborted");
+
+ await vi.waitFor(() => {
+ expect(callbackRes.writeHead).toHaveBeenCalledWith(502, { "Content-Type": "text/html" });
+ });
+
+ expect(capturedSignal!.aborted, "AbortController must have been aborted").toBe(true);
+ const reason = (capturedSignal as any).reason;
+ expect(reason, "abort reason should be an AbortError").toBeInstanceOf(Error);
+ expect(reason.name).toBe("AbortError");
+ });
+
+ it("client 'close' event before req.complete rejects with AbortError", async () => {
+ const encodedState = await primePendingStart("lane-1", 3001);
+
+ const fake = makeFakeUpstream();
+ let capturedSignal: AbortSignal | undefined;
+ httpRequestMock.mockImplementationOnce((options: any, cb: (res: any) => void) => {
+ capturedSignal = options.signal;
+ fake.setCallback(cb);
+ return fake.upstreamReq;
+ });
+
+ const callbackReq = makeIncomingReq({
+ url: `/oauth/callback?code=xyz&state=${encodeURIComponent(encodedState)}`,
+ host: "lane-1.localhost:8080",
+ method: "POST",
+ });
+ // Explicitly mark the request as incomplete → close should reject.
+ callbackReq.complete = false;
+ const callbackRes = mockRes();
+ expect(nativeSvc.handleRequest(callbackReq, callbackRes)).toBe(true);
+
+ callbackReq.emit("close");
+
+ await vi.waitFor(() => {
+ expect(callbackRes.writeHead).toHaveBeenCalledWith(502, { "Content-Type": "text/html" });
+ });
+
+ expect(capturedSignal!.aborted).toBe(true);
+ expect((capturedSignal as any).reason).toBeInstanceOf(Error);
+ expect((capturedSignal as any).reason.name).toBe("AbortError");
+ });
+
+ it("client 'close' fired AFTER the upstream response has settled is a no-op", async () => {
+ const encodedState = await primePendingStart("lane-1", 3001);
+
+ const fake = makeFakeUpstream();
+ httpRequestMock.mockImplementationOnce((_options: any, cb: (res: any) => void) => {
+ fake.setCallback(cb);
+ return fake.upstreamReq;
+ });
+
+ const callbackReq = makeIncomingReq({
+ url: `/oauth/callback?code=xyz&state=${encodeURIComponent(encodedState)}`,
+ host: "lane-1.localhost:8080",
+ method: "POST",
+ });
+ const callbackRes = mockRes();
+ expect(nativeSvc.handleRequest(callbackReq, callbackRes)).toBe(true);
+
+ // Drive body end → upstream end → response.
+ callbackReq.emit("end");
+ fake.emitResponse(200, {}, "done");
+
+ // Wait for the managed-callback promise to settle (it writes a 302 redirect).
+ await vi.waitFor(() => {
+ expect(callbackRes.writeHead).toHaveBeenCalled();
+ });
+ const callsBefore = callbackRes.writeHead.mock.calls.length;
+
+ // Now simulate the usual late "close" that fires when the client socket tears down.
+ callbackReq.complete = true;
+ callbackReq.emit("close");
+
+ // No second writeHead should happen (the settled flag + listener cleanup prevent it).
+ expect(callbackRes.writeHead.mock.calls.length, "late 'close' must be a no-op after settlement").toBe(callsBefore);
+ });
+ });
});
diff --git a/apps/desktop/src/main/services/lanes/oauthRedirectService.ts b/apps/desktop/src/main/services/lanes/oauthRedirectService.ts
index a3df47350..4a0a23d6e 100644
--- a/apps/desktop/src/main/services/lanes/oauthRedirectService.ts
+++ b/apps/desktop/src/main/services/lanes/oauthRedirectService.ts
@@ -1,6 +1,6 @@
+import http from "node:http";
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
import { URL } from "node:url";
-import type http from "node:http";
import type {
OAuthRedirectConfig,
OAuthRedirectStatus,
@@ -14,6 +14,48 @@ import type { Logger } from "../logging/logger";
const STATE_PREFIX = "ade";
const STATE_SEP = ":";
+const FIXED_CALLBACK_PATH = "/oauth/callback";
+const FINALIZE_CALLBACK_PATH = "/__ade/oauth/finalize";
+const OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
+const AUTH_START_PATH_PREFIXES = ["/api/auth/", "/auth/", "/oauth/"];
+const HOP_BY_HOP_RESPONSE_HEADERS = new Set([
+ "connection",
+ "keep-alive",
+ "proxy-authenticate",
+ "proxy-authorization",
+ "te",
+ "trailer",
+ "transfer-encoding",
+ "upgrade",
+]);
+
+type UpstreamResponse = {
+ statusCode: number;
+ headers: http.IncomingHttpHeaders;
+ body: Buffer;
+};
+
+type PendingOAuthStartSession = {
+ encodedState: string;
+ laneId: string;
+ laneHostname: string;
+ targetPort: number;
+ originalState: string;
+ originalCallbackPath: string;
+ cookiePairs: string[];
+ createdAtMs: number;
+ provider?: string;
+ sessionId: string;
+};
+
+type PendingFinalizeSession = {
+ token: string;
+ laneId: string;
+ laneHostname: string;
+ response: UpstreamResponse;
+ createdAtMs: number;
+ sessionId: string;
+};
const DEFAULT_CONFIG: OAuthRedirectConfig = {
enabled: true,
@@ -21,6 +63,7 @@ const DEFAULT_CONFIG: OAuthRedirectConfig = {
"/oauth/callback",
"/auth/callback",
"/api/auth/callback",
+ "/api/auth/google/callback",
"/callback",
],
routingMode: "state-parameter",
@@ -29,10 +72,13 @@ const DEFAULT_CONFIG: OAuthRedirectConfig = {
/**
* OAuth Redirect Handling Service (Phase 5 W5).
*
- * Intercepts OAuth callbacks on the lane proxy, extracts the lane ID
- * from the `state` parameter, and forwards the callback to the correct
- * lane's dev server. Zero configuration required for the common case —
- * just encode your OAuth state via `encodeState()` and ADE handles routing.
+ * The stable ADE-managed callback flow works like this:
+ * 1. A sign-in starts from the lane preview URL.
+ * 2. ADE rewrites the provider redirect_uri to a single stable proxy callback.
+ * 3. ADE stores the lane-bound cookies needed for the callback.
+ * 4. The provider returns to the stable callback.
+ * 5. ADE forwards the callback to the correct lane app and replays the final
+ * response back on the lane preview host so cookies remain isolated.
*/
export function createOAuthRedirectService({
logger,
@@ -42,6 +88,7 @@ export function createOAuthRedirectService({
getProxyPort,
getHostnameSuffix,
forwardToPort,
+ requestUpstream,
}: {
logger: Logger;
config?: Partial;
@@ -58,9 +105,19 @@ export function createOAuthRedirectService({
res: http.ServerResponse,
targetPort: number,
) => void;
+ /** Optional injectable request helper for tests and advanced proxy flows. */
+ requestUpstream?: (args: {
+ req: http.IncomingMessage;
+ targetPort: number;
+ overridePath?: string;
+ overrideHeaders?: http.OutgoingHttpHeaders;
+ }) => Promise;
}) {
+ void getHostnameSuffix;
const cfg: OAuthRedirectConfig = { ...DEFAULT_CONFIG, ...userConfig };
const sessions = new Map();
+ const pendingStarts = new Map();
+ const pendingFinalize = new Map();
const stateSecret = randomBytes(32);
// ---------------------------------------------------------------------------
@@ -99,10 +156,15 @@ export function createOAuthRedirectService({
try {
const signature = rest.slice(0, signatureEnd);
- const laneId = Buffer.from(rest.slice(signatureEnd + STATE_SEP.length, laneEnd), "base64url").toString("utf-8");
+ const laneId = Buffer.from(
+ rest.slice(signatureEnd + STATE_SEP.length, laneEnd),
+ "base64url",
+ ).toString("utf-8");
const originalState = rest.slice(laneEnd + STATE_SEP.length);
if (!laneId.trim() || !signature) {
- logger.debug("oauth_redirect.decode_error", { reason: "empty laneId or signature" });
+ logger.debug("oauth_redirect.decode_error", {
+ reason: "empty laneId or signature",
+ });
return null;
}
@@ -133,6 +195,11 @@ export function createOAuthRedirectService({
return cfg.callbackPaths.some((p) => normalized === p.toLowerCase());
}
+ function isPotentialAuthStartPath(urlPath: string): boolean {
+ const normalized = urlPath.split("?")[0].toLowerCase();
+ return AUTH_START_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix));
+ }
+
function extractStateParam(req: http.IncomingMessage): string | null {
try {
const url = new URL(
@@ -155,42 +222,22 @@ export function createOAuthRedirectService({
}
}
- function completeSessionFromResponse(
- session: OAuthSession,
- res: http.ServerResponse,
- ): (status: OAuthSessionStatus, error?: string) => void {
- let finished = false;
-
- const finalize = (status: OAuthSessionStatus, error?: string) => {
- if (finished) return;
- finished = true;
- completeSession(session, status, error);
- if (status === "completed") {
- broadcastEvent({
- type: "oauth-callback-routed",
- session,
- status: buildStatus(),
- });
- }
- };
-
- res.once("finish", () => {
- if ((res.statusCode ?? 200) >= 400) {
- finalize(
- "failed",
- `OAuth callback forwarding failed with status ${res.statusCode}.`,
- );
- return;
- }
- finalize("completed");
- });
-
- res.once("close", () => {
- if (finished || res.writableEnded) return;
- finalize("failed", "OAuth callback connection closed before completion.");
- });
+ function normalizeHostHeader(hostHeader: string): string {
+ const trimmed = hostHeader.trim();
+ if (!trimmed.length) return "";
+ if (trimmed.startsWith("[")) {
+ const end = trimmed.indexOf("]");
+ return (end >= 0 ? trimmed.slice(1, end) : trimmed).toLowerCase();
+ }
+ return trimmed.split(":")[0].toLowerCase();
+ }
- return finalize;
+ function findRouteByHostHeader(hostHeader: string | undefined): ProxyRoute | null {
+ const hostname = normalizeHostHeader(hostHeader ?? "");
+ if (!hostname) return null;
+ return getRoutes().find(
+ (route) => route.hostname.toLowerCase() === hostname && route.status === "active",
+ ) ?? null;
}
// ---------------------------------------------------------------------------
@@ -200,13 +247,15 @@ export function createOAuthRedirectService({
function createSession(
laneId: string,
callbackPath: string,
+ options?: { status?: OAuthSessionStatus; provider?: string },
): OAuthSession {
const id = `oauth-${randomUUID()}`;
const session: OAuthSession = {
id,
laneId,
- status: "active",
+ status: options?.status ?? "active",
callbackPath,
+ ...(options?.provider ? { provider: options.provider } : {}),
createdAt: new Date().toISOString(),
};
sessions.set(id, session);
@@ -218,6 +267,16 @@ export function createOAuthRedirectService({
return session;
}
+ function markSessionActive(session: OAuthSession): void {
+ if (session.status === "active") return;
+ session.status = "active";
+ broadcastEvent({
+ type: "oauth-callback-routed",
+ session,
+ status: buildStatus(),
+ });
+ }
+
function completeSession(
session: OAuthSession,
status: OAuthSessionStatus,
@@ -235,6 +294,48 @@ export function createOAuthRedirectService({
});
}
+ function sessionById(sessionId: string): OAuthSession | null {
+ return sessions.get(sessionId) ?? null;
+ }
+
+ function completeSessionFromResponse(
+ session: OAuthSession,
+ res: http.ServerResponse,
+ ): (status: OAuthSessionStatus, error?: string) => void {
+ let finished = false;
+
+ const finalize = (status: OAuthSessionStatus, error?: string) => {
+ if (finished) return;
+ finished = true;
+ completeSession(session, status, error);
+ if (status === "completed") {
+ broadcastEvent({
+ type: "oauth-callback-routed",
+ session,
+ status: buildStatus(),
+ });
+ }
+ };
+
+ res.once("finish", () => {
+ if ((res.statusCode ?? 200) >= 400) {
+ finalize(
+ "failed",
+ `OAuth callback forwarding failed with status ${res.statusCode}.`,
+ );
+ return;
+ }
+ finalize("completed");
+ });
+
+ res.once("close", () => {
+ if (finished || res.writableEnded) return;
+ finalize("failed", "OAuth callback connection closed before completion.");
+ });
+
+ return finalize;
+ }
+
function buildStatus(): OAuthRedirectStatus {
return {
enabled: cfg.enabled,
@@ -246,8 +347,36 @@ export function createOAuthRedirectService({
};
}
+ function removeExpiredPendingSessions(): void {
+ const cutoff = Date.now() - OAUTH_SESSION_TTL_MS;
+ for (const [encodedState, session] of pendingStarts.entries()) {
+ if (session.createdAtMs >= cutoff) continue;
+ pendingStarts.delete(encodedState);
+ const tracked = sessionById(session.sessionId);
+ if (tracked && tracked.status !== "completed" && tracked.status !== "failed") {
+ completeSession(
+ tracked,
+ "failed",
+ "OAuth callback did not return before the ADE session expired.",
+ );
+ }
+ }
+ for (const [token, session] of pendingFinalize.entries()) {
+ if (session.createdAtMs >= cutoff) continue;
+ pendingFinalize.delete(token);
+ const tracked = sessionById(session.sessionId);
+ if (tracked && tracked.status !== "completed" && tracked.status !== "failed") {
+ completeSession(
+ tracked,
+ "failed",
+ "OAuth finalize response expired before the browser completed the redirect.",
+ );
+ }
+ }
+ }
+
// ---------------------------------------------------------------------------
- // Error page
+ // Error pages
// ---------------------------------------------------------------------------
function esc(s: string): string {
@@ -275,6 +404,430 @@ code{background:#0B0A0F;padding:2px 6px;font-size:12px;color:#A78BFA;font-family