From d67d0b7d948a7e15eee976c0fbbcb6790cf12b46 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:51:31 -0400 Subject: [PATCH 1/3] Improve Tasks and Subagents panel typography and sizing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump text sizes across BottomDrawerSection, ChatTasksPanel, ChatSubagentsPanel, and ChatSubagentStrip to be more readable (e.g. 9px→11px, 10px→12px, 11px→13px). Switch labels, descriptions, and buttons from font-mono to the system sans font, keeping monospace only where it belongs (timestamps, task IDs, tool names, status badges). Slightly increase icon sizes and padding for better visual weight. --- .ade/cto/openclaw-history.json | 1 - .ade/cto/openclaw-idempotency.json | 1 - .ade/cto/openclaw-outbox.json | 1 - .ade/cto/openclaw-routes.json | 3 - apps/desktop/src/main/main.ts | 19 +- .../services/ai/tools/ctoOperatorTools.ts | 126 +- .../services/config/projectConfigService.ts | 11 +- .../main/services/cto/ctoStateService.test.ts | 12 +- .../src/main/services/cto/ctoStateService.ts | 383 +++- .../src/main/services/ipc/registerIpc.ts | 11 +- .../src/main/services/lanes/laneService.ts | 128 +- .../lanes/oauthRedirectService.test.ts | 134 +- .../services/lanes/oauthRedirectService.ts | 655 ++++++- .../lanes/runtimeDiagnosticsService.test.ts | 46 + .../lanes/runtimeDiagnosticsService.ts | 54 +- .../services/processes/processService.test.ts | 65 +- .../main/services/processes/processService.ts | 12 +- .../projects/adeProjectService.test.ts | 5 + .../services/projects/adeProjectService.ts | 5 + .../src/main/services/prs/prService.ts | 14 +- .../src/main/services/pty/ptyService.ts | 162 +- .../src/main/utils/terminalSessionSignals.ts | 12 +- .../src/renderer/components/app/TopBar.tsx | 31 +- .../automations/components/RuleCard.tsx | 190 -- .../components/chat/BottomDrawerSection.tsx | 12 +- .../components/chat/ChatContextMeter.tsx | 91 - .../components/chat/ChatGitToolbar.tsx | 31 +- .../components/chat/ChatSubagentStrip.tsx | 34 +- .../components/chat/ChatSubagentsPanel.tsx | 46 +- .../components/chat/ChatTasksPanel.tsx | 26 +- .../components/chat/ChatTurnDiffPanel.tsx | 260 --- .../components/chat/ChatTurnDivider.tsx | 93 - .../src/renderer/components/cto/CtoPage.tsx | 162 +- .../cto/OpenclawConnectionPanel.tsx | 36 +- .../src/renderer/components/cto/TeamPanel.tsx | 24 +- .../components/cto/WorkerActivityFeed.tsx | 2 +- .../components/cto/WorkerCreationWizard.tsx | 334 ++-- .../components/cto/WorkerDetailSlideOut.tsx | 357 ---- .../cto/shared/ConnectionStatusDot.tsx | 2 +- .../components/cto/shared/MemoryEntryCard.tsx | 159 -- .../components/cto/shared/designTokens.ts | 38 +- .../renderer/components/deets/DeetsPage.tsx | 13 - .../components/files/FilesPage.test.tsx | 4 - .../renderer/components/files/FilesPage.tsx | 321 +-- .../components/lanes/CommitTimeline.tsx | 36 +- .../components/lanes/CreateLaneDialog.tsx | 2 +- .../components/lanes/LaneConflictsPanel.tsx | 174 -- .../components/lanes/LaneContextMenu.tsx | 13 + .../components/lanes/LaneDiffPane.tsx | 75 +- .../lanes/LaneGitActionsPane.test.tsx | 5 + .../components/lanes/LaneGitActionsPane.tsx | 957 +++++---- .../components/lanes/LaneInspectorPane.tsx | 143 -- .../lanes/LaneOverlayConfigPanel.tsx | 140 -- .../components/lanes/LaneRebaseBanner.tsx | 173 +- .../src/renderer/components/lanes/LaneRow.tsx | 540 ----- .../components/lanes/LaneStackPane.tsx | 21 +- .../components/lanes/LaneTerminalsPanel.tsx | 427 ---- .../components/lanes/LaneWorkPane.tsx | 57 +- .../renderer/components/lanes/LanesPage.tsx | 166 +- .../components/lanes/ManageLaneDialog.tsx | 339 ++-- .../components/lanes/TilingLayout.tsx | 186 -- .../mergeSimulation/ConflictFileDiff.tsx | 315 --- .../mergeSimulation/MergeSimulationPanel.tsx | 109 -- .../mergeSimulation/extensionToLanguage.ts | 45 - .../missions/MissionControlPage.tsx | 207 -- .../components/missions/MissionPhaseBadge.tsx | 82 - .../components/missions/UsageDashboard.tsx | 649 ------ .../missions/WorkerTranscriptPane.tsx | 156 -- .../components/project/ProjectHomePage.tsx | 1743 ----------------- .../components/prs/LanePrPanel.test.ts | 60 - .../renderer/components/prs/LanePrPanel.tsx | 858 -------- .../components/prs/detail/PrDetailPane.tsx | 80 +- .../components/prs/shared/InlineTerminal.tsx | 110 -- .../components/prs/state/PrsContext.tsx | 11 +- .../components/run/AddCommandDialog.tsx | 204 +- .../renderer/components/run/CommandCard.tsx | 81 +- .../components/run/LaneRuntimeBar.tsx | 295 ++- .../components/run/ProcessMonitor.tsx | 365 +++- .../renderer/components/run/QuickRunMenu.tsx | 266 +++ .../components/run/RunNetworkPanel.tsx | 22 + .../src/renderer/components/run/RunPage.tsx | 507 +++-- .../renderer/components/run/RunSidebar.tsx | 159 +- .../renderer/components/run/RunStackTabs.tsx | 366 ++++ .../settings/AutomationsSection.tsx | 274 --- .../DiagnosticsDashboardSection.test.tsx | 1 + .../components/settings/GeneralSection.tsx | 46 +- .../settings/ProxyAndPreviewSection.tsx | 21 +- .../PostResolutionActions.tsx | 98 - .../ResolverTerminalModal.tsx | 978 --------- .../components/terminals/LaunchPanel.tsx | 184 -- .../components/terminals/SessionCard.tsx | 37 +- .../terminals/SessionInfoPopover.tsx | 87 +- .../components/terminals/SessionListPane.tsx | 125 +- .../components/terminals/TerminalView.tsx | 19 +- .../components/terminals/TerminalsPage.tsx | 2 + .../components/terminals/WorkStartSurface.tsx | 124 +- .../components/terminals/WorkViewArea.tsx | 238 ++- .../components/terminals/cliLaunch.ts | 6 + .../components/terminals/useWorkSessions.ts | 14 +- .../renderer/components/ui/SmartTooltip.tsx | 150 ++ apps/desktop/src/renderer/index.css | 91 + apps/desktop/src/renderer/lib/sessions.ts | 2 +- apps/desktop/src/renderer/state/appStore.ts | 44 + apps/desktop/src/shared/adeLayout.ts | 4 + apps/desktop/src/shared/types/config.ts | 1 + apps/desktop/src/shared/types/lanes.ts | 1 - docs/architecture/AI_INTEGRATION.md | 9 +- docs/architecture/DATA_MODEL.md | 16 +- 108 files changed, 5917 insertions(+), 10925 deletions(-) delete mode 100644 .ade/cto/openclaw-history.json delete mode 100644 .ade/cto/openclaw-idempotency.json delete mode 100644 .ade/cto/openclaw-outbox.json delete mode 100644 .ade/cto/openclaw-routes.json delete mode 100644 apps/desktop/src/renderer/components/automations/components/RuleCard.tsx delete mode 100644 apps/desktop/src/renderer/components/chat/ChatContextMeter.tsx delete mode 100644 apps/desktop/src/renderer/components/chat/ChatTurnDiffPanel.tsx delete mode 100644 apps/desktop/src/renderer/components/chat/ChatTurnDivider.tsx delete mode 100644 apps/desktop/src/renderer/components/cto/WorkerDetailSlideOut.tsx delete mode 100644 apps/desktop/src/renderer/components/cto/shared/MemoryEntryCard.tsx delete mode 100644 apps/desktop/src/renderer/components/deets/DeetsPage.tsx delete mode 100644 apps/desktop/src/renderer/components/lanes/LaneConflictsPanel.tsx delete mode 100644 apps/desktop/src/renderer/components/lanes/LaneInspectorPane.tsx delete mode 100644 apps/desktop/src/renderer/components/lanes/LaneOverlayConfigPanel.tsx delete mode 100644 apps/desktop/src/renderer/components/lanes/LaneRow.tsx delete mode 100644 apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx delete mode 100644 apps/desktop/src/renderer/components/lanes/TilingLayout.tsx delete mode 100644 apps/desktop/src/renderer/components/lanes/mergeSimulation/ConflictFileDiff.tsx delete mode 100644 apps/desktop/src/renderer/components/lanes/mergeSimulation/MergeSimulationPanel.tsx delete mode 100644 apps/desktop/src/renderer/components/lanes/mergeSimulation/extensionToLanguage.ts delete mode 100644 apps/desktop/src/renderer/components/missions/MissionControlPage.tsx delete mode 100644 apps/desktop/src/renderer/components/missions/MissionPhaseBadge.tsx delete mode 100644 apps/desktop/src/renderer/components/missions/UsageDashboard.tsx delete mode 100644 apps/desktop/src/renderer/components/missions/WorkerTranscriptPane.tsx delete mode 100644 apps/desktop/src/renderer/components/project/ProjectHomePage.tsx delete mode 100644 apps/desktop/src/renderer/components/prs/LanePrPanel.test.ts delete mode 100644 apps/desktop/src/renderer/components/prs/LanePrPanel.tsx delete mode 100644 apps/desktop/src/renderer/components/prs/shared/InlineTerminal.tsx create mode 100644 apps/desktop/src/renderer/components/run/QuickRunMenu.tsx create mode 100644 apps/desktop/src/renderer/components/run/RunStackTabs.tsx delete mode 100644 apps/desktop/src/renderer/components/settings/AutomationsSection.tsx delete mode 100644 apps/desktop/src/renderer/components/shared/conflictResolver/PostResolutionActions.tsx delete mode 100644 apps/desktop/src/renderer/components/shared/conflictResolver/ResolverTerminalModal.tsx delete mode 100644 apps/desktop/src/renderer/components/terminals/LaunchPanel.tsx create mode 100644 apps/desktop/src/renderer/components/ui/SmartTooltip.tsx 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/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 2553284b3..cda424551 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'`, @@ -1467,6 +1467,23 @@ app.whenReady().then(async () => { logger, laneService, projectConfigService, + getLaneRuntimeEnv: async (laneId) => { + 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, + }; + }, broadcastEvent: (ev) => emitProjectEvent(projectRoot, IPC.processesEvent, ev), }); diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index dbee718e0..62d930ada 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,56 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { + try { + const { execSync } = await import("node:child_process"); + const adeRoot = path.resolve(__dirname, "../../../../.."); + const globArg = fileGlob?.trim() || "*.ts"; + const args = [ + "--no-heading", "--line-number", "--max-count=3", + `--context=${contextLines}`, + `--glob=${globArg}`, + "--max-filecount=" + String(maxResults), + "--", pattern, adeRoot, + ]; + const result = execSync( + `rg ${args.map((a) => JSON.stringify(a)).join(" ")}`, + { encoding: "utf8", maxBuffer: 512 * 1024, timeout: 10_000 }, + ).trim(); + // Strip the absolute path prefix for cleaner output + const cleaned = result.replace(new RegExp(adeRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "/", "g"), ""); + const lines = cleaned.split("\n"); + const truncated = lines.length > 200; + return { + success: true, + matchCount: lines.filter((l) => l.match(/^\S+:\d+:/)).length, + truncated, + output: lines.slice(0, 200).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/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..6f549124b 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -3629,13 +3629,18 @@ export function registerIpc({ } const expectedHostname = ctx.laneProxyService.generateHostname(laneId, lane.name); + const health = ctx.runtimeDiagnosticsService + ? await ctx.runtimeDiagnosticsService.checkLaneHealth(laneId).catch(() => null) + : null; + const targetPort = health?.respondingPort ?? 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..10bbeb517 100644 --- a/apps/desktop/src/main/services/lanes/oauthRedirectService.test.ts +++ b/apps/desktop/src/main/services/lanes/oauthRedirectService.test.ts @@ -50,6 +50,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 +66,7 @@ describe("oauthRedirectService", () => { let routes: ProxyRoute[]; let logger: ReturnType; let forwardToPort: ReturnType; + let requestUpstream: ReturnType; let svc: ReturnType; beforeEach(() => { @@ -66,6 +74,7 @@ describe("oauthRedirectService", () => { routes = []; logger = createLogger(); forwardToPort = vi.fn(); + requestUpstream = vi.fn(); svc = createOAuthRedirectService({ logger, @@ -74,6 +83,7 @@ describe("oauthRedirectService", () => { getProxyPort: () => 8080, getHostnameSuffix: () => ".localhost", forwardToPort, + requestUpstream: requestUpstream as any, }); }); @@ -394,6 +404,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 +684,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 +704,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 +732,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,6 +820,7 @@ describe("oauthRedirectService", () => { "/oauth/callback", "/auth/callback", "/api/auth/callback", + "/api/auth/google/callback", "/callback", ]); expect(status.activeSessions).toEqual([]); diff --git a/apps/desktop/src/main/services/lanes/oauthRedirectService.ts b/apps/desktop/src/main/services/lanes/oauthRedirectService.ts index a3df47350..46eaa0e11 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,355 @@ code{background:#0B0A0F;padding:2px 6px;font-size:12px;color:#A78BFA;font-family `; } + function proxyErrorPage(message: string): string { + return ` +Preview Error — ADE +
+

Preview Request Failed

+

${esc(message)}

+
`; + } + + // --------------------------------------------------------------------------- + // Upstream request helpers + // --------------------------------------------------------------------------- + + function defaultRequestUpstream(args: { + req: http.IncomingMessage; + targetPort: number; + overridePath?: string; + overrideHeaders?: http.OutgoingHttpHeaders; + }): Promise { + const headers: http.OutgoingHttpHeaders = { + ...args.req.headers, + ...args.overrideHeaders, + }; + + return new Promise((resolve, reject) => { + const upstreamReq = http.request( + { + hostname: "127.0.0.1", + port: args.targetPort, + path: args.overridePath ?? args.req.url, + method: args.req.method, + headers, + }, + (upstreamRes) => { + const chunks: Buffer[] = []; + upstreamRes.on("data", (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + upstreamRes.on("end", () => { + resolve({ + statusCode: upstreamRes.statusCode ?? 502, + headers: upstreamRes.headers, + body: Buffer.concat(chunks), + }); + }); + }, + ); + + upstreamReq.once("error", reject); + + if (args.req.method === "GET" || args.req.method === "HEAD") { + upstreamReq.end(); + return; + } + + const chunks: Buffer[] = []; + args.req.on("data", (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + args.req.once("end", () => { + upstreamReq.end(Buffer.concat(chunks)); + }); + args.req.once("error", reject); + }); + } + + const sendUpstreamRequest = requestUpstream ?? defaultRequestUpstream; + + function cookiePairsFromSetCookie(header: string | string[] | undefined): string[] { + const values = Array.isArray(header) ? header : header ? [header] : []; + return values + .map((value) => value.split(";")[0]?.trim() ?? "") + .filter((value) => value.length > 0); + } + + function cookiePairsFromHeader(cookieHeader: string | string[] | undefined): string[] { + const raw = Array.isArray(cookieHeader) ? cookieHeader.join("; ") : cookieHeader ?? ""; + return raw + .split(";") + .map((value) => value.trim()) + .filter((value) => value.length > 0 && value.includes("=")); + } + + function mergeCookiePairs(...sources: string[][]): string[] { + const next = new Map(); + for (const source of sources) { + for (const pair of source) { + const equals = pair.indexOf("="); + if (equals <= 0) continue; + next.set(pair.slice(0, equals), pair); + } + } + return Array.from(next.values()); + } + + function buildCookieHeader(cookiePairs: string[]): string | undefined { + return cookiePairs.length > 0 ? cookiePairs.join("; ") : undefined; + } + + function copyResponseHeaders(headers: http.IncomingHttpHeaders): http.OutgoingHttpHeaders { + const next: http.OutgoingHttpHeaders = {}; + for (const [key, value] of Object.entries(headers)) { + if (value == null) continue; + if (HOP_BY_HOP_RESPONSE_HEADERS.has(key.toLowerCase())) continue; + next[key] = value; + } + return next; + } + + function sendUpstreamResponse( + res: http.ServerResponse, + upstream: UpstreamResponse, + overrides?: { headers?: http.OutgoingHttpHeaders; statusCode?: number }, + ): void { + const headers = { + ...copyResponseHeaders(upstream.headers), + ...(overrides?.headers ?? {}), + }; + res.writeHead(overrides?.statusCode ?? upstream.statusCode, headers); + res.end(upstream.body); + } + + function detectProvider(location: URL): string | undefined { + const host = location.hostname.toLowerCase(); + if (host.includes("google.")) return "Google"; + if (host === "github.com" || host.endsWith(".github.com")) return "GitHub"; + if (host.includes("auth0.com")) return "Auth0"; + return undefined; + } + + function parseOauthStartRedirect(locationHeader: string): { + authUrl: URL; + originalState: string; + originalRedirectUri: URL; + provider?: string; + } | null { + let authUrl: URL; + try { + authUrl = new URL(locationHeader); + } catch { + return null; + } + if (!["http:", "https:"].includes(authUrl.protocol)) return null; + const originalState = authUrl.searchParams.get("state"); + const redirectUri = authUrl.searchParams.get("redirect_uri"); + if (!originalState || !redirectUri) return null; + let originalRedirectUri: URL; + try { + originalRedirectUri = new URL(redirectUri); + } catch { + return null; + } + return { + authUrl, + originalState, + originalRedirectUri, + provider: detectProvider(authUrl), + }; + } + + function stableCallbackUrl(): string { + return `http://localhost:${getProxyPort()}${FIXED_CALLBACK_PATH}`; + } + + function rewriteOauthStartRedirect( + locationHeader: string, + encodedState: string, + ): string | null { + const parsed = parseOauthStartRedirect(locationHeader); + if (!parsed) return null; + parsed.authUrl.searchParams.set("state", encodedState); + parsed.authUrl.searchParams.set("redirect_uri", stableCallbackUrl()); + return parsed.authUrl.toString(); + } + + function buildForwardedHeaders( + req: http.IncomingMessage, + laneHostname: string, + cookiePairs?: string[], + ): http.OutgoingHttpHeaders { + const proxyPort = getProxyPort(); + return { + ...req.headers, + host: `${laneHostname}:${proxyPort}`, + "x-forwarded-host": `${laneHostname}:${proxyPort}`, + "x-forwarded-port": String(proxyPort), + "x-forwarded-proto": "http", + ...(cookiePairs?.length ? { cookie: buildCookieHeader(cookiePairs) } : {}), + }; + } + + function buildForwardCallbackPath( + pending: PendingOAuthStartSession, + req: http.IncomingMessage, + ): string { + const incoming = new URL( + req.url ?? FIXED_CALLBACK_PATH, + `http://${req.headers.host ?? "localhost"}`, + ); + const forwardUrl = new URL(pending.originalCallbackPath, "http://placeholder"); + const params = new URLSearchParams(forwardUrl.search); + incoming.searchParams.forEach((value, key) => { + params.set(key, value); + }); + params.set("state", pending.originalState); + forwardUrl.search = params.toString(); + return `${forwardUrl.pathname}${forwardUrl.search ? `?${forwardUrl.search}` : ""}`; + } + + function finalizeRedirectUrl(hostname: string, token: string): string { + return `http://${hostname}:${getProxyPort()}${FINALIZE_CALLBACK_PATH}?token=${encodeURIComponent(token)}`; + } + + // --------------------------------------------------------------------------- + // Async request handlers + // --------------------------------------------------------------------------- + + async function handleAuthStartRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + route: ProxyRoute, + ): Promise { + const upstream = await sendUpstreamRequest({ + req, + targetPort: route.targetPort, + overrideHeaders: buildForwardedHeaders(req, route.hostname), + }); + + const locationHeader = Array.isArray(upstream.headers.location) + ? upstream.headers.location[0] + : upstream.headers.location; + const parsedRedirect = typeof locationHeader === "string" + ? parseOauthStartRedirect(locationHeader) + : null; + + if (!parsedRedirect) { + sendUpstreamResponse(res, upstream); + return; + } + + const encodedState = encodeState(route.laneId, parsedRedirect.originalState); + const session = createSession(route.laneId, FIXED_CALLBACK_PATH, { + status: "pending", + ...(parsedRedirect.provider ? { provider: parsedRedirect.provider } : {}), + }); + + pendingStarts.set(encodedState, { + encodedState, + laneId: route.laneId, + laneHostname: route.hostname, + targetPort: route.targetPort, + originalState: parsedRedirect.originalState, + originalCallbackPath: `${parsedRedirect.originalRedirectUri.pathname}${parsedRedirect.originalRedirectUri.search}`, + cookiePairs: mergeCookiePairs( + cookiePairsFromHeader(req.headers.cookie), + cookiePairsFromSetCookie(upstream.headers["set-cookie"]), + ), + createdAtMs: Date.now(), + ...(parsedRedirect.provider ? { provider: parsedRedirect.provider } : {}), + sessionId: session.id, + }); + + const rewrittenLocation = rewriteOauthStartRedirect(locationHeader!, encodedState); + sendUpstreamResponse(res, upstream, { + headers: rewrittenLocation ? { location: rewrittenLocation } : undefined, + }); + } + + async function handleManagedCallback( + req: http.IncomingMessage, + res: http.ServerResponse, + pending: PendingOAuthStartSession, + ): Promise { + const activeRoute = getRoutes().find( + (route) => route.laneId === pending.laneId && route.status === "active", + ); + const route = activeRoute ?? { + laneId: pending.laneId, + hostname: pending.laneHostname, + targetPort: pending.targetPort, + status: "active" as const, + createdAt: new Date().toISOString(), + }; + + const trackedSession = sessionById(pending.sessionId); + if (trackedSession) { + markSessionActive(trackedSession); + } + + const upstream = await sendUpstreamRequest({ + req, + targetPort: route.targetPort, + overridePath: buildForwardCallbackPath(pending, req), + overrideHeaders: buildForwardedHeaders(req, route.hostname, pending.cookiePairs), + }); + + const finalizeToken = `oauth-finalize-${randomUUID()}`; + pendingFinalize.set(finalizeToken, { + token: finalizeToken, + laneId: pending.laneId, + laneHostname: route.hostname, + response: upstream, + createdAtMs: Date.now(), + sessionId: pending.sessionId, + }); + pendingStarts.delete(pending.encodedState); + + res.writeHead(302, { + location: finalizeRedirectUrl(route.hostname, finalizeToken), + "cache-control": "no-store", + }); + res.end(); + } + + function handleFinalizeRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + ): void { + const requestUrl = new URL( + req.url ?? FINALIZE_CALLBACK_PATH, + `http://${req.headers.host ?? "localhost"}`, + ); + const token = requestUrl.searchParams.get("token"); + if (!token) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(proxyErrorPage("OAuth finalize request is missing its ADE token.")); + return; + } + + const pending = pendingFinalize.get(token); + if (!pending) { + res.writeHead(410, { "Content-Type": "text/html" }); + res.end(proxyErrorPage("This ADE OAuth finalize token has expired. Start the sign-in flow again from the lane preview URL.")); + return; + } + + pendingFinalize.delete(token); + const trackedSession = sessionById(pending.sessionId); + if (trackedSession) { + completeSession(trackedSession, "completed"); + } + sendUpstreamResponse(res, pending.response); + } + // --------------------------------------------------------------------------- // Request interceptor (registered on laneProxyService) // --------------------------------------------------------------------------- @@ -284,16 +762,75 @@ code{background:#0B0A0F;padding:2px 6px;font-size:12px;color:#A78BFA;font-family res: http.ServerResponse, ): boolean { if (!cfg.enabled) return false; + removeExpiredPendingSessions(); + + const requestUrl = new URL( + req.url ?? "/", + `http://${req.headers.host ?? "localhost"}`, + ); + + if (requestUrl.pathname === FINALIZE_CALLBACK_PATH) { + handleFinalizeRequest(req, res); + return true; + } + + const state = extractStateParam(req); + const decoded = state ? decodeState(state) : null; + if (decoded) { + const pending = pendingStarts.get(state!); + if (pending) { + void handleManagedCallback(req, res, pending).catch((error) => { + const trackedSession = sessionById(pending.sessionId); + const message = + error instanceof Error + ? error.message + : "ADE could not forward the OAuth callback back to the lane."; + if (trackedSession) { + completeSession(trackedSession, "failed", message); + } + pendingStarts.delete(pending.encodedState); + logger.warn("oauth_redirect.managed_callback_failed", { + laneId: pending.laneId, + error: message, + }); + if (!res.headersSent) { + res.writeHead(502, { "Content-Type": "text/html" }); + } + res.end(errorPage(pending.laneId, message)); + }); + return true; + } + } - const urlPath = (req.url ?? "").split("?")[0]; + const routeForHost = findRouteByHostHeader(req.headers.host); + if ( + routeForHost && + (req.method === "GET" || req.method === "HEAD") && + isPotentialAuthStartPath(requestUrl.pathname) + ) { + void handleAuthStartRequest(req, res, routeForHost).catch((error) => { + const message = + error instanceof Error + ? error.message + : "ADE could not inspect the auth start response from the lane preview."; + logger.warn("oauth_redirect.auth_start_failed", { + laneId: routeForHost.laneId, + error: message, + }); + if (!res.headersSent) { + res.writeHead(502, { "Content-Type": "text/html" }); + } + res.end(proxyErrorPage(message)); + }); + return true; + } + + const urlPath = requestUrl.pathname; if (!isOAuthCallback(urlPath)) return false; // --- state-parameter routing --- if (cfg.routingMode === "state-parameter") { - const state = extractStateParam(req); if (!state) return false; // no state param — fall through to normal routing - - const decoded = decodeState(state); if (!decoded) return false; // not ADE-encoded — fall through const route = getRoutes().find( @@ -311,7 +848,6 @@ code{background:#0B0A0F;padding:2px 6px;font-size:12px;color:#A78BFA;font-family return true; } - // Rewrite state back to the original value before forwarding const rewrittenUrl = rewriteStateParam( req.url ?? "", decoded.originalState, @@ -369,30 +905,27 @@ code{background:#0B0A0F;padding:2px 6px;font-size:12px;color:#A78BFA;font-family return [ { provider: "Google", - uris: [`${base}/oauth/callback`], + uris: [`${base}${FIXED_CALLBACK_PATH}`], instructions: - "Add this URI in Google Cloud Console → APIs & Services → Credentials → OAuth 2.0 Client → Authorized redirect URIs.", + "Add this URI in Google Cloud Console → APIs & Services → Credentials → OAuth 2.0 Client → Authorized redirect URIs. Start sign-in from the lane preview URL so ADE can route the callback back to the correct lane.", }, ]; case "github": return [ { provider: "GitHub", - uris: [`${base}/auth/callback`], + uris: [`${base}${FIXED_CALLBACK_PATH}`], instructions: - "Set this as the Authorization callback URL in your GitHub OAuth App settings. GitHub supports one callback URL per app.", + "Set this as the Authorization callback URL in your GitHub OAuth App settings. Start sign-in from the lane preview URL so ADE can route the callback back to the correct lane.", }, ]; case "auth0": return [ { provider: "Auth0", - uris: [ - `${base}/oauth/callback`, - `${base}/auth/callback`, - ], + uris: [`${base}${FIXED_CALLBACK_PATH}`], instructions: - "Add these URIs to your Auth0 Application → Settings → Allowed Callback URLs (comma-separated).", + "Add this URI to your Auth0 Application → Settings → Allowed Callback URLs. Start sign-in from the lane preview URL so ADE can route the callback back to the correct lane.", }, ]; default: @@ -403,9 +936,9 @@ code{background:#0B0A0F;padding:2px 6px;font-size:12px;color:#A78BFA;font-family return [ { provider: provider ?? "Generic", - uris: cfg.callbackPaths.map((p) => `${base}${p}`), + uris: [`${base}${FIXED_CALLBACK_PATH}`], instructions: - "Register one of these redirect URIs with your OAuth provider. ADE automatically routes callbacks to the correct lane using the OAuth state parameter.", + "Register this ADE-managed callback URL with your OAuth provider. Start sign-in from the lane preview URL so ADE can route the callback back to the correct lane.", }, ]; } @@ -482,6 +1015,8 @@ code{background:#0B0A0F;padding:2px 6px;font-size:12px;color:#A78BFA;font-family /** Clean up. */ dispose(): void { sessions.clear(); + pendingStarts.clear(); + pendingFinalize.clear(); }, }; } diff --git a/apps/desktop/src/main/services/lanes/runtimeDiagnosticsService.test.ts b/apps/desktop/src/main/services/lanes/runtimeDiagnosticsService.test.ts index c1cabcf83..33fda0aef 100644 --- a/apps/desktop/src/main/services/lanes/runtimeDiagnosticsService.test.ts +++ b/apps/desktop/src/main/services/lanes/runtimeDiagnosticsService.test.ts @@ -278,6 +278,52 @@ describe("createRuntimeDiagnosticsService", () => { expect(health.proxyRouteActive).toBe(false); expect(health.issues.some((i) => i.type === "proxy-route-missing")).toBe(true); }); + + it("treats a responding port elsewhere in the lane range as the live app", async () => { + leases.set("lane-1", makeLease("lane-1")); + routes.set("lane-1", makeRoute("lane-1", 3007)); + svc.dispose(); + svc = createRuntimeDiagnosticsService({ + logger: createLogger(), + broadcastEvent: (ev) => events.push(ev), + getPortLease: (laneId) => leases.get(laneId) ?? null, + getPortConflicts: () => conflicts, + detectPortConflicts: () => conflicts, + getProxyStatus: () => proxyStatus, + getProxyRoute: (laneId) => routes.get(laneId) ?? null, + probePort: async (port) => port === 3007, + }); + + const health = await svc.checkLaneHealth("lane-1"); + + expect(health.status).toBe("healthy"); + expect(health.portResponding).toBe(true); + expect(health.respondingPort).toBe(3007); + expect(health.proxyRouteActive).toBe(true); + }); + + it("reports when the app responds on a different port than the active preview route", async () => { + leases.set("lane-1", makeLease("lane-1")); + routes.set("lane-1", makeRoute("lane-1", 3000)); + svc.dispose(); + svc = createRuntimeDiagnosticsService({ + logger: createLogger(), + broadcastEvent: (ev) => events.push(ev), + getPortLease: (laneId) => leases.get(laneId) ?? null, + getPortConflicts: () => conflicts, + detectPortConflicts: () => conflicts, + getProxyStatus: () => proxyStatus, + getProxyRoute: (laneId) => routes.get(laneId) ?? null, + probePort: async (port) => port === 3007, + }); + + const health = await svc.checkLaneHealth("lane-1"); + + expect(health.status).toBe("degraded"); + expect(health.respondingPort).toBe(3007); + expect(health.proxyRouteActive).toBe(false); + expect(health.issues.some((i) => i.message.includes("responding on port 3007"))).toBe(true); + }); }); // ========================================================================= diff --git a/apps/desktop/src/main/services/lanes/runtimeDiagnosticsService.ts b/apps/desktop/src/main/services/lanes/runtimeDiagnosticsService.ts index 9664dd5d1..66647f83d 100644 --- a/apps/desktop/src/main/services/lanes/runtimeDiagnosticsService.ts +++ b/apps/desktop/src/main/services/lanes/runtimeDiagnosticsService.ts @@ -27,6 +27,7 @@ export function createRuntimeDiagnosticsService({ detectPortConflicts, getProxyStatus, getProxyRoute, + probePort, }: { logger: Logger; broadcastEvent: (ev: RuntimeDiagnosticsEvent) => void; @@ -35,6 +36,7 @@ export function createRuntimeDiagnosticsService({ detectPortConflicts: () => PortConflict[]; getProxyStatus: () => ProxyStatus; getProxyRoute: (laneId: string) => ProxyRoute | null; + probePort?: (port: number, timeoutMs?: number) => Promise; }) { // Internal state const healthCache = new Map(); @@ -54,6 +56,36 @@ export function createRuntimeDiagnosticsService({ }); } + const probe = probePort ?? checkPort; + + async function findResponsivePort( + lease: PortLease, + preferredPorts: number[], + ): Promise { + const inRange = (port: number) => port >= lease.rangeStart && port <= lease.rangeEnd; + const orderedPreferred = Array.from(new Set(preferredPorts.filter(inRange))); + + for (const port of orderedPreferred) { + if (await probe(port, 150)) return port; + } + + const remainingPorts: number[] = []; + for (let port = lease.rangeStart; port <= lease.rangeEnd; port += 1) { + if (!orderedPreferred.includes(port)) remainingPorts.push(port); + } + + if (remainingPorts.length === 0) return null; + + const results = await Promise.all( + remainingPorts.map(async (port) => ({ + port, + ok: await probe(port, 75).catch(() => false), + })), + ); + + return results.find((result) => result.ok)?.port ?? null; + } + function deriveStatus(issues: LaneHealthIssue[], fallback: boolean): LaneHealthStatus { if (issues.length === 0) return fallback ? "degraded" : "healthy"; const hasCritical = issues.some((i) => @@ -81,6 +113,7 @@ export function createRuntimeDiagnosticsService({ status: "unhealthy", processAlive: false, portResponding: false, + respondingPort: null, proxyRouteActive: false, fallbackMode: fallbackLanes.has(laneId), lastCheckedAt: new Date().toISOString(), @@ -93,13 +126,15 @@ export function createRuntimeDiagnosticsService({ const isFallback = fallbackLanes.has(laneId); // 1. Port responding check + let respondingPort: number | null = null; let portResponding = false; if (lease && lease.status === "active") { - portResponding = await checkPort(lease.rangeStart); + respondingPort = await findResponsivePort(lease, [route?.targetPort ?? -1, lease.rangeStart]); + portResponding = respondingPort !== null; if (!portResponding) { issues.push({ type: "port-unresponsive", - message: `Port ${lease.rangeStart} is not responding. The dev server may not be running.`, + message: `No dev server responded in the assigned lane port range ${lease.rangeStart}-${lease.rangeEnd}.`, actionLabel: "Check dev server", }); } @@ -122,7 +157,13 @@ export function createRuntimeDiagnosticsService({ } // 3. Proxy route active - const proxyRouteActive = !!(route && route.status === "active" && proxyStatus.running); + const proxyRouteActive = !!( + route && + route.status === "active" && + proxyStatus.running && + respondingPort !== null && + route.targetPort === respondingPort + ); if (!proxyRouteActive) { if (isFallback) { issues.push({ @@ -138,6 +179,12 @@ export function createRuntimeDiagnosticsService({ actionLabel: "Start proxy", actionType: "restart-proxy", }); + } else if (route && respondingPort !== null && route.targetPort !== respondingPort) { + issues.push({ + type: "proxy-route-missing", + message: `App is responding on port ${respondingPort}, but preview is still routed to port ${route.targetPort}.`, + actionLabel: "Refresh preview", + }); } else if (!route) { issues.push({ type: "proxy-route-missing", @@ -180,6 +227,7 @@ export function createRuntimeDiagnosticsService({ status: deriveStatus(dedupedIssues, isFallback), processAlive, portResponding, + respondingPort, proxyRouteActive, fallbackMode: isFallback, lastCheckedAt: new Date().toISOString(), diff --git a/apps/desktop/src/main/services/processes/processService.test.ts b/apps/desktop/src/main/services/processes/processService.test.ts index 349f23d2b..e086d93f8 100644 --- a/apps/desktop/src/main/services/processes/processService.test.ts +++ b/apps/desktop/src/main/services/processes/processService.test.ts @@ -82,6 +82,69 @@ async function waitForExit( } describe("processService start logging", () => { + it("injects lane runtime env into spawned processes", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-process-env-")); + const dbPath = path.join(tmpDir, "kv.sqlite"); + const logsDir = path.join(tmpDir, "logs"); + const projectId = "proj-env"; + const logger = createLogger(); + + const db = await openKvDb(dbPath, createLogger()); + const now = "2026-03-24T12:00:00.000Z"; + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [projectId, tmpDir, "test", "main", now, now], + ); + db.run( + `insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ["lane-env", projectId, "Lane Env", null, "worktree", "main", "feature/env", tmpDir, null, 0, null, null, null, null, "active", now, null], + ); + + const config = makeMinimalConfig([ + { id: "print-env", command: ["sh", "-c", "printf '%s' \"$PORT|$PORT_RANGE_START|$PORT_RANGE_END|$HOSTNAME|$PROXY_HOSTNAME\""] }, + ]); + + const service = createProcessService({ + db, + projectId, + processLogsDir: logsDir, + logger, + laneService: { + getLaneWorktreePath: () => tmpDir, + list: async () => [makeLaneSummary(tmpDir, "lane-env")], + } as any, + projectConfigService: { + get: () => config, + getEffective: () => config.effective, + getExecutableConfig: () => config.effective, + } as any, + getLaneRuntimeEnv: async () => ({ + PORT: "3001", + PORT_RANGE_START: "3001", + PORT_RANGE_END: "3099", + HOSTNAME: "lane-env.localhost", + PROXY_HOSTNAME: "lane-env.localhost", + }), + broadcastEvent: () => {}, + }); + + try { + await service.start({ laneId: "lane-env", processId: "print-env" }); + await waitForExit(service, "lane-env", "print-env"); + + const logPath = path.join(logsDir, "lane-env", "print-env.log"); + const logText = fs.readFileSync(logPath, "utf8"); + expect(logText).toContain("3001|3001|3099|lane-env.localhost|lane-env.localhost"); + } finally { + service.disposeAll(); + db.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("includes envPath and envShell in the process.start log entry", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-process-startlog-")); const dbPath = path.join(tmpDir, "kv.sqlite"); @@ -262,7 +325,7 @@ describe("processService start logging", () => { try { await expect(service.start({ laneId: "lane-cwd", processId: "escape-proc" })).rejects.toThrow( - /cwd escapes lane workspace/, + /cwd must stay within the lane workspace/, ); } finally { service.disposeAll(); diff --git a/apps/desktop/src/main/services/processes/processService.ts b/apps/desktop/src/main/services/processes/processService.ts index b33a826a5..341b1915b 100644 --- a/apps/desktop/src/main/services/processes/processService.ts +++ b/apps/desktop/src/main/services/processes/processService.ts @@ -199,6 +199,7 @@ export function createProcessService({ logger, laneService, projectConfigService, + getLaneRuntimeEnv, broadcastEvent }: { db: AdeDb; @@ -207,6 +208,7 @@ export function createProcessService({ logger: Logger; laneService: ReturnType; projectConfigService: ReturnType; + getLaneRuntimeEnv?: (laneId: string) => Promise> | Record; broadcastEvent: (ev: ProcessEvent) => void; }) { const entries = new Map(); @@ -762,9 +764,14 @@ export function createProcessService({ let cwd: string; try { cwd = resolvePathWithinRoot(laneRoot, cwdCandidate); - } catch { - throw new Error(`Process '${definition.id}' cwd escapes lane workspace`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("Path does not exist")) { + throw new Error(`Process '${definition.id}' cwd does not exist: ${configuredCwd}`); + } + throw new Error(`Process '${definition.id}' cwd must stay within the lane workspace`); } + const laneRuntimeEnv = (await getLaneRuntimeEnv?.(laneId)) ?? {}; const env = { ...process.env, // Inject color-friendly defaults for processes running without a PTY. @@ -772,6 +779,7 @@ export function createProcessService({ // these vars override that heuristic so log output stays readable. FORCE_COLOR: "1", TERM: "xterm-256color", + ...laneRuntimeEnv, ...definition.env, ...(opts.overlay?.env ?? {}) }; diff --git a/apps/desktop/src/main/services/projects/adeProjectService.test.ts b/apps/desktop/src/main/services/projects/adeProjectService.test.ts index 9815532a6..01fa8928e 100644 --- a/apps/desktop/src/main/services/projects/adeProjectService.test.ts +++ b/apps/desktop/src/main/services/projects/adeProjectService.test.ts @@ -16,6 +16,8 @@ function createRepoFixture(): string { fs.mkdirSync(path.join(root, ".ade", "chat-sessions"), { recursive: true }); fs.writeFileSync(path.join(root, ".ade", "chat-sessions", "session-1.json"), "{\"id\":\"session-1\"}\n", "utf8"); fs.writeFileSync(path.join(root, ".ade", "mission-state-run-1.json"), "{\"runId\":\"run-1\"}\n", "utf8"); + fs.mkdirSync(path.join(root, ".ade", "cto"), { recursive: true }); + fs.writeFileSync(path.join(root, ".ade", "cto", "openclaw-history.json"), "[]\n", "utf8"); return root; } @@ -35,6 +37,7 @@ describe("initializeOrRepairAdeProject", () => { expect(adeGitignore).toContain("cto/core-memory.json"); expect(adeGitignore).toContain("context/"); expect(adeGitignore).toContain("agents/"); + expect(adeGitignore).toContain("cto/openclaw-history.json"); expect(adeGitignore).not.toContain("cto/identity.yaml"); expect(fs.readFileSync(path.join(layout.adeDir, "ade.yaml"), "utf8")).toContain("version: 1"); expect(fs.readFileSync(path.join(layout.ctoDir, "identity.yaml"), "utf8")).toContain("name: CTO"); @@ -44,8 +47,10 @@ describe("initializeOrRepairAdeProject", () => { expect(fs.existsSync(path.join(layout.logsDir, "main.jsonl"))).toBe(true); expect(fs.existsSync(path.join(layout.chatSessionsDir, "session-1.json"))).toBe(true); expect(fs.existsSync(path.join(layout.missionStateDir, "mission-state-run-1.json"))).toBe(true); + expect(fs.existsSync(path.join(layout.cacheDir, "openclaw", "openclaw-history.json"))).toBe(true); expect(fs.existsSync(path.join(layout.adeDir, "logs"))).toBe(false); expect(fs.existsSync(path.join(layout.adeDir, "chat-sessions"))).toBe(false); + expect(fs.existsSync(path.join(layout.ctoDir, "openclaw-history.json"))).toBe(false); }); it("is idempotent once the canonical structure is in place", () => { diff --git a/apps/desktop/src/main/services/projects/adeProjectService.ts b/apps/desktop/src/main/services/projects/adeProjectService.ts index 1edcf6cdc..111254ae4 100644 --- a/apps/desktop/src/main/services/projects/adeProjectService.ts +++ b/apps/desktop/src/main/services/projects/adeProjectService.ts @@ -222,6 +222,10 @@ function repairLegacyPaths(paths: AdeLayoutPaths, actions: AdeSyncAction[]): voi moveIfExists(path.join(paths.adeDir, "log-bundles"), paths.logBundlesDir, "artifacts/log-bundles", actions); moveIfExists(path.join(paths.adeDir, "github"), paths.githubSecretsDir, "secrets/github", actions); moveIfExists(path.join(paths.adeDir, "api-keys.json"), path.join(paths.secretsDir, "api-keys.json"), "secrets/api-keys.json", actions); + moveIfExists(path.join(paths.ctoDir, "openclaw-history.json"), path.join(paths.cacheDir, "openclaw", "openclaw-history.json"), "cache/openclaw/openclaw-history.json", actions); + moveIfExists(path.join(paths.ctoDir, "openclaw-idempotency.json"), path.join(paths.cacheDir, "openclaw", "openclaw-idempotency.json"), "cache/openclaw/openclaw-idempotency.json", actions); + moveIfExists(path.join(paths.ctoDir, "openclaw-outbox.json"), path.join(paths.cacheDir, "openclaw", "openclaw-outbox.json"), "cache/openclaw/openclaw-outbox.json", actions); + moveIfExists(path.join(paths.ctoDir, "openclaw-routes.json"), path.join(paths.cacheDir, "openclaw", "openclaw-routes.json"), "cache/openclaw/openclaw-routes.json", actions); const legacyFiles = fs.existsSync(paths.adeDir) ? fs.readdirSync(paths.adeDir) : []; for (const fileName of legacyFiles) { @@ -285,6 +289,7 @@ export function initializeOrRepairAdeProject(projectRoot: string, options: Repai ensureDir(paths.chatSessionsDir, "cache/chat-sessions", actions); ensureDir(paths.chatTranscriptsDir, "transcripts/chat", actions); ensureDir(paths.orchestratorCacheDir, "cache/orchestrator", actions); + ensureDir(path.join(paths.cacheDir, "openclaw"), "cache/openclaw", actions); ensureDir(paths.missionStateDir, "cache/mission-state", actions); ensureDir(paths.packsDir, "artifacts/packs", actions); ensureDir(paths.logBundlesDir, "artifacts/log-bundles", actions); diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index dac3bca71..0e8d6199d 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -335,8 +335,13 @@ function toChecksStatusFromCheckRuns(checkRuns: any[]): PrChecksStatus | null { const status = asString(run?.status).toLowerCase(); const conclusion = asString(run?.conclusion).toLowerCase(); if (status && status !== "completed") { - hasPending = true; - continue; + // A check can have a conclusion (e.g. "skipped") even when its status + // hasn't flipped to "completed". Treat it as finished if a terminal + // conclusion is present; otherwise it's genuinely pending. + if (!conclusion || (conclusion !== "success" && conclusion !== "neutral" && conclusion !== "skipped" && conclusion !== "failure" && conclusion !== "cancelled" && conclusion !== "timed_out" && conclusion !== "action_required" && conclusion !== "stale")) { + hasPending = true; + continue; + } } if (!conclusion) continue; if (conclusion === "success" || conclusion === "neutral" || conclusion === "skipped") { @@ -3492,9 +3497,10 @@ export function createPrService({ }; const toGitHubState = (rawPr: any): PrState => { - if (Boolean(rawPr?.draft)) return "draft"; if (rawPr?.merged_at) return "merged"; - return asString(rawPr?.state).toLowerCase() === "closed" ? "closed" : "open"; + if (asString(rawPr?.state).toLowerCase() === "closed") return "closed"; + if (Boolean(rawPr?.draft)) return "draft"; + return "open"; }; const toGitHubItem = (rawPr: any, scope: "repo" | "external"): GitHubPrListItem => { diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 01580f207..96b0a378b 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -161,28 +161,36 @@ function normalizeToolType(raw: unknown): TerminalToolType | null { return (allowed as string[]).includes(value) ? (value as TerminalToolType) : "other"; } +/** Extract --session-id from a Claude startup command if present. */ +function extractClaudeSessionIdFromCommand(command: string): string | null { + const match = command.match(/--session-id\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i); + return match?.[1] ?? null; +} + function buildInitialResumeMetadata(args: { toolType: TerminalToolType | null; startupCommand: string; }): TerminalResumeMetadata | null { const parsedLaunch = parseTrackedCliLaunchConfig(args.startupCommand, args.toolType); + const isClaude = args.toolType === "claude" || args.toolType === "claude-orchestrated"; + const isCodex = args.toolType === "codex" || args.toolType === "codex-orchestrated"; + + // Extract pre-assigned --session-id from Claude startup command + const preAssignedId = isClaude ? extractClaudeSessionIdFromCommand(args.startupCommand) : null; + if (parsedLaunch) { return { - provider: args.toolType === "codex" || args.toolType === "codex-orchestrated" - ? "codex" - : "claude", - targetKind: args.toolType === "codex" || args.toolType === "codex-orchestrated" - ? "thread" - : "session", - targetId: null, + provider: isCodex ? "codex" : "claude", + targetKind: isCodex ? "thread" : "session", + targetId: preAssignedId, launch: parsedLaunch, }; } - if (args.toolType === "claude" || args.toolType === "claude-orchestrated") { - return { provider: "claude", targetKind: "session", targetId: null, launch: {} }; + if (isClaude) { + return { provider: "claude", targetKind: "session", targetId: preAssignedId, launch: {} }; } - if (args.toolType === "codex" || args.toolType === "codex-orchestrated") { + if (isCodex) { return { provider: "codex", targetKind: "thread", targetId: null, launch: {} }; } return null; @@ -615,7 +623,7 @@ export function createPtyService({ ): void => { void endTranscriptStream(entry.transcriptStream) .finally(() => { - backfillResumeTargetFromTranscriptBestEffort(entry.sessionId, entry.toolTypeHint, reason); + backfillResumeTargetFromTranscriptBestEffort(entry.sessionId, entry.toolTypeHint, reason, entry.boundCwd); summarizeSessionBestEffort(entry.sessionId, { laneWorktreePath: entry.laneWorktreePath, boundCwd: entry.boundCwd, @@ -623,10 +631,109 @@ export function createPtyService({ }); }; + /** + * Try to find the Claude session ID from Claude's local JSONL storage. + * Claude Code stores conversations at ~/.claude/projects//.jsonl. + * We find the most recently modified JSONL in the project dir and return its UUID. + */ + const resolveClaudeSessionIdFromStorage = (cwd: string): string | null => { + try { + const homedir = require("node:os").homedir(); + // Claude encodes the cwd by replacing / with - (and leading -) + // Claude encodes cwd by replacing all / with - (e.g. /Users/admin/Projects/ADE → -Users-admin-Projects-ADE) + const escapedCwd = cwd.replace(/\//g, "-"); + const claudeProjectDir = path.join(homedir, ".claude", "projects", escapedCwd); + if (!fs.existsSync(claudeProjectDir)) return null; + + // Find the most recently modified .jsonl that is a direct session (not in subagents/) + const entries = fs.readdirSync(claudeProjectDir, { withFileTypes: true }); + let newest: { name: string; mtimeMs: number } | null = null; + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; + const stat = fs.statSync(path.join(claudeProjectDir, entry.name)); + if (!newest || stat.mtimeMs > newest.mtimeMs) { + newest = { name: entry.name, mtimeMs: stat.mtimeMs }; + } + } + if (!newest) return null; + // UUID is the filename without .jsonl extension + const uuid = newest.name.replace(/\.jsonl$/, ""); + // Basic UUID format check + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(uuid)) return null; + // Only consider if modified within the last 5 minutes (to avoid picking up stale sessions) + if (Date.now() - newest.mtimeMs > 5 * 60 * 1000) return null; + return uuid; + } catch { + return null; + } + }; + + /** + * Try to find the Codex session ID from Codex's local storage. + * Codex stores sessions at ~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl. + * Each JSONL starts with a session_meta event containing `payload.id` and `payload.cwd`. + * We find the most recently modified JSONL whose cwd matches, and return its UUID. + */ + const resolveCodexSessionIdFromStorage = (cwd: string): string | null => { + try { + const homedir = require("node:os").homedir(); + const sessionsBase = path.join(homedir, ".codex", "sessions"); + if (!fs.existsSync(sessionsBase)) return null; + + // Walk the date-based directory tree (YYYY/MM/DD) and find recent JSONLs + const now = new Date(); + const candidates: Array<{ filePath: string; mtimeMs: number }> = []; + // Check today and yesterday's directories + for (let dayOffset = 0; dayOffset <= 1; dayOffset++) { + const d = new Date(now.getTime() - dayOffset * 86400_000); + const dirPath = path.join( + sessionsBase, + String(d.getFullYear()), + String(d.getMonth() + 1).padStart(2, "0"), + String(d.getDate()).padStart(2, "0"), + ); + if (!fs.existsSync(dirPath)) continue; + for (const entry of fs.readdirSync(dirPath)) { + if (!entry.endsWith(".jsonl")) continue; + const fp = path.join(dirPath, entry); + const stat = fs.statSync(fp); + candidates.push({ filePath: fp, mtimeMs: stat.mtimeMs }); + } + } + if (!candidates.length) return null; + + // Sort by most recently modified + candidates.sort((a, b) => b.mtimeMs - a.mtimeMs); + + // Find one whose cwd matches (read first line) + for (const candidate of candidates.slice(0, 10)) { + // Only consider files modified in the last 5 minutes + if (now.getTime() - candidate.mtimeMs > 5 * 60 * 1000) break; + try { + const fd = fs.openSync(candidate.filePath, "r"); + const buf = Buffer.alloc(1024); + const bytesRead = fs.readSync(fd, buf, 0, 1024, 0); + fs.closeSync(fd); + const firstLine = buf.subarray(0, bytesRead).toString("utf8").split("\n")[0] ?? ""; + const meta = JSON.parse(firstLine); + if (meta?.type === "session_meta" && meta?.payload?.cwd === cwd && meta?.payload?.id) { + return meta.payload.id; + } + } catch { + continue; + } + } + return null; + } catch { + return null; + } + }; + const backfillResumeTargetFromTranscriptBestEffort = ( sessionId: string, preferredToolType: TerminalToolType | null, reason: "close" | "dispose" | "orphan-dispose", + sessionCwd?: string | null, ): void => { Promise.resolve() .then(async () => { @@ -636,14 +743,39 @@ export function createPtyService({ if (!isTrackedCliToolType(effectiveToolType)) return; if (session.resumeMetadata?.targetId?.trim()) return; + // Strategy 1: Try parsing the transcript for an explicit resume command const transcript = await sessionService.readTranscriptTail(session.transcriptPath, 220_000); const detected = extractResumeCommandFromOutput(transcript, effectiveToolType); - if (!detected) { - logger.warn("pty.resume_target_missing", { sessionId, toolType: effectiveToolType, reason }); + if (detected) { + sessionService.setResumeCommand(sessionId, detected); + logger.info("pty.resume_target_backfilled", { sessionId, toolType: effectiveToolType, reason, source: "transcript" }); return; } - sessionService.setResumeCommand(sessionId, detected); - logger.info("pty.resume_target_backfilled", { sessionId, toolType: effectiveToolType, reason }); + + // Strategy 2: Read the session/thread ID from the CLI's local storage + const cwd = sessionCwd ?? session.transcriptPath?.split("/.ade/transcripts/")?.[0] ?? null; + + if ((effectiveToolType === "claude" || effectiveToolType === "claude-orchestrated") && cwd) { + const claudeSessionId = resolveClaudeSessionIdFromStorage(cwd); + if (claudeSessionId) { + const resumeCmd = `claude --resume ${claudeSessionId}`; + sessionService.setResumeCommand(sessionId, resumeCmd); + logger.info("pty.resume_target_backfilled", { sessionId, toolType: effectiveToolType, reason, source: "claude-storage", claudeSessionId }); + return; + } + } + + if ((effectiveToolType === "codex" || effectiveToolType === "codex-orchestrated") && cwd) { + const codexSessionId = resolveCodexSessionIdFromStorage(cwd); + if (codexSessionId) { + const resumeCmd = `codex resume ${codexSessionId}`; + sessionService.setResumeCommand(sessionId, resumeCmd); + logger.info("pty.resume_target_backfilled", { sessionId, toolType: effectiveToolType, reason, source: "codex-storage", codexSessionId }); + return; + } + } + + logger.warn("pty.resume_target_missing", { sessionId, toolType: effectiveToolType, reason }); }) .catch((err) => { logger.warn("pty.resume_target_backfill_failed", { diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.ts b/apps/desktop/src/main/utils/terminalSessionSignals.ts index bbdb8647e..5dd3c3d8a 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.ts @@ -205,18 +205,26 @@ export function defaultResumeCommandForTool(toolType: TerminalToolType | null | return null; } +/** Strip ANSI escape codes so resume-command regexes can match TUI output. */ +function stripAnsiCodes(text: string): string { + return text.replace(/\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][A-Z0-9]|\x1b\[[\d;]*m/g, ""); +} + export function extractResumeCommandFromOutput( text: string, preferredTool?: TerminalToolType | null ): string | null { if (!text.trim()) return null; - const fromBackticks = Array.from(text.matchAll(RESUME_BACKTICK_REGEX)) + // Strip ANSI escape codes — TUI CLIs (claude/codex) embed escape codes that break regex matching + const cleaned = stripAnsiCodes(text); + + const fromBackticks = Array.from(cleaned.matchAll(RESUME_BACKTICK_REGEX)) .map((m) => normalizeResumeCommand(m[1] ?? "", preferredTool)) .filter(Boolean); if (fromBackticks[0]) return fromBackticks[0]; - const fromPlain = Array.from(text.matchAll(RESUME_PLAIN_REGEX)) + const fromPlain = Array.from(cleaned.matchAll(RESUME_PLAIN_REGEX)) .map((m) => normalizeResumeCommand(m[1] ?? "", preferredTool)) .filter(Boolean); if (fromPlain[0]) return fromPlain[0]; diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 51b117d58..5c35e5077 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; -import { ChatCircleDots, CircleNotch, Folder, FolderOpen, Plus, Minus, Trash, X } from "@phosphor-icons/react"; +import { ChatCircleDots, CircleNotch, Folder, FolderOpen, Info, Plus, Minus, Trash, X } from "@phosphor-icons/react"; +import { SmartTooltip } from "../ui/SmartTooltip"; import { useNavigate } from "react-router-dom"; import { useAppStore } from "../../state/appStore"; @@ -56,6 +57,8 @@ export function TopBar() { const projectTransitionError = useAppStore((s) => s.projectTransitionError); const clearProjectTransitionError = useAppStore((s) => s.clearProjectTransitionError); const switchProjectToPath = useAppStore((s) => s.switchProjectToPath); + const smartTooltipsEnabled = useAppStore((s) => s.smartTooltipsEnabled); + const setSmartTooltipsEnabled = useAppStore((s) => s.setSmartTooltipsEnabled); const [recentProjects, setRecentProjects] = useState([]); const [relocatingPath, setRelocatingPath] = useState(null); const [zoom, setZoom] = useState(getStoredZoomLevel); @@ -556,6 +559,32 @@ export function TopBar() { + + + + - - - {onDelete ? ( - - ) : null} - - - - - ); -} diff --git a/apps/desktop/src/renderer/components/chat/BottomDrawerSection.tsx b/apps/desktop/src/renderer/components/chat/BottomDrawerSection.tsx index 975034299..be6c9bafa 100644 --- a/apps/desktop/src/renderer/components/chat/BottomDrawerSection.tsx +++ b/apps/desktop/src/renderer/components/chat/BottomDrawerSection.tsx @@ -25,20 +25,20 @@ export function BottomDrawerSection({
diff --git a/apps/desktop/src/renderer/components/chat/ChatContextMeter.tsx b/apps/desktop/src/renderer/components/chat/ChatContextMeter.tsx deleted file mode 100644 index bc91d28c3..000000000 --- a/apps/desktop/src/renderer/components/chat/ChatContextMeter.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React, { useMemo } from "react"; -import type { AgentChatEventEnvelope } from "../../../shared/types"; - -type SessionTokenUsage = { - totalInputTokens: number; - totalOutputTokens: number; - totalCacheReadTokens: number; - totalCacheCreationTokens: number; - totalCostUsd: number; - turnCount: number; -}; - -export function deriveSessionTokenUsage(events: AgentChatEventEnvelope[]): SessionTokenUsage { - let totalInputTokens = 0; - let totalOutputTokens = 0; - let totalCacheReadTokens = 0; - let totalCacheCreationTokens = 0; - let totalCostUsd = 0; - let turnCount = 0; - - for (const envelope of events) { - const event = envelope.event; - if (event.type !== "done") continue; - turnCount++; - if (event.usage) { - totalInputTokens += event.usage.inputTokens ?? 0; - totalOutputTokens += event.usage.outputTokens ?? 0; - totalCacheReadTokens += event.usage.cacheReadTokens ?? 0; - totalCacheCreationTokens += event.usage.cacheCreationTokens ?? 0; - } - if (typeof event.costUsd === "number" && Number.isFinite(event.costUsd)) { - totalCostUsd += event.costUsd; - } - } - - return { totalInputTokens, totalOutputTokens, totalCacheReadTokens, totalCacheCreationTokens, totalCostUsd, turnCount }; -} - -function formatTokenCount(value: number): string { - if (value <= 0) return "0"; - if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; - if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`; - return String(Math.round(value)); -} - -export const ChatContextMeter = React.memo(function ChatContextMeter({ - events, - contextWindow, -}: { - events: AgentChatEventEnvelope[]; - contextWindow?: number; -}) { - const usage = useMemo(() => deriveSessionTokenUsage(events), [events]); - - if (usage.turnCount === 0) return null; - - const totalTokens = usage.totalInputTokens + usage.totalOutputTokens; - const fillPercent = contextWindow && contextWindow > 0 - ? Math.min(100, Math.round((usage.totalInputTokens / contextWindow) * 100)) - : null; - - const costStr = usage.totalCostUsd > 0 - ? usage.totalCostUsd < 0.01 - ? "<$0.01" - : `$${usage.totalCostUsd.toFixed(2)}` - : null; - - return ( -
- {formatTokenCount(totalTokens)} tokens - {usage.totalCacheReadTokens > 0 ? ( - ({formatTokenCount(usage.totalCacheReadTokens)} cached) - ) : null} - {costStr ? {costStr} : null} - {fillPercent !== null ? ( -
-
-
80 ? "bg-amber-400/60" : fillPercent > 50 ? "bg-sky-400/40" : "bg-emerald-400/30" - }`} - style={{ width: `${fillPercent}%` }} - /> -
- {fillPercent}% -
- ) : null} - {usage.turnCount} turn{usage.turnCount !== 1 ? "s" : ""} -
- ); -}); diff --git a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx index 685d948c1..9bb779f1a 100644 --- a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx @@ -12,7 +12,8 @@ import { } from "@phosphor-icons/react"; import { AnimatePresence, motion } from "motion/react"; import { cn } from "../ui/cn"; -import type { DiffChanges, GitBranchSummary, PrSummary } from "../../../shared/types"; +import { QuickRunMenu } from "../run/QuickRunMenu"; +import type { DiffChanges, PrSummary } from "../../../shared/types"; import { beginLaneGitActionRuntime, patchLaneGitActionRuntimeStateIfCurrent, @@ -32,11 +33,6 @@ type ChatGitToolbarProps = { // Helpers // --------------------------------------------------------------------------- -function currentBranchName(branches: GitBranchSummary[]): string | null { - const current = branches.find((b) => b.isCurrent && !b.isRemote); - return current?.name ?? null; -} - function dirtyFileCount(changes: DiffChanges): number { return changes.staged.length + changes.unstaged.length; } @@ -79,7 +75,6 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ const navigate = useNavigate(); const runtime = useLaneGitActionRuntimeState(laneId); - const [branch, setBranch] = useState(null); const [laneName, setLaneName] = useState(null); const [dirtyCount, setDirtyCount] = useState(0); const [diffStats, setDiffStats] = useState<{ adds: number; dels: number; files: number } | null>(null); @@ -104,11 +99,10 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ const refreshStatus = useCallback(async () => { try { - const [branches, changes] = await Promise.all([ + const [, changes] = await Promise.all([ window.ade.git.listBranches({ laneId }), window.ade.diff.getChanges({ laneId }), ]); - setBranch(currentBranchName(branches)); setDirtyCount(dirtyFileCount(changes)); const staged = changes.staged.length; const unstaged = changes.unstaged.length; @@ -279,14 +273,17 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({
{/* Lane name (navigates to lane detail) */} {laneId ? ( - + <> + + + ) : null} {/* Dirty count badge */} diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentStrip.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentStrip.tsx index 4f07f37e4..be744800f 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentStrip.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentStrip.tsx @@ -125,25 +125,25 @@ function PreviewCard({
{meta.label} {snapshot.background ? ( - + Background ) : null} {runtimeSummary ? ( - {runtimeSummary} + {runtimeSummary} ) : null}
{onDismiss ? ( diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx index bda9dc014..c2c7946d5 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx @@ -118,17 +118,17 @@ function SubagentDetailView({
- + {snapshot.description} {meta.label} @@ -139,12 +139,12 @@ function SubagentDetailView({ {runtimeSummary || snapshot.background ? (
{snapshot.background ? ( - + bg ) : null} {runtimeSummary ? ( - {runtimeSummary} + {runtimeSummary} ) : null}
) : null} @@ -154,7 +154,7 @@ function SubagentDetailView({ {hiddenCount > 0 && ( diff --git a/apps/desktop/src/renderer/components/chat/ChatTasksPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatTasksPanel.tsx index 6a9828e31..dab68ba86 100644 --- a/apps/desktop/src/renderer/components/chat/ChatTasksPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatTasksPanel.tsx @@ -37,31 +37,31 @@ function statusBadge(status: TurnDiffFile["status"]) { switch (status) { case "A": return ( - + A ); case "D": return ( - + D ); case "R": return ( - + R ); case "C": return ( - + C ); default: return ( - + M ); @@ -182,7 +182,7 @@ export const ChatTasksPanel = React.memo(function ChatTasksPanel({ if (!files.length) return null; const summaryContent = ( - + {files.length} file{files.length !== 1 ? "s" : ""} {totalAdditions > 0 && +{totalAdditions}} {totalDeletions > 0 && -{totalDeletions}} @@ -207,18 +207,18 @@ export const ChatTasksPanel = React.memo(function ChatTasksPanel({ key={file.path} type="button" className={cn( - "flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors", + "flex w-full items-center gap-2 px-3 py-2 text-left transition-colors", isSelected ? "bg-white/[0.05]" : "hover:bg-white/[0.03]", )} onClick={() => void handleSelectFile(file.path)} > {statusIcon(file.status)} - + {basename(file.path)}
- {file.additions > 0 && +{file.additions}} - {file.deletions > 0 && -{file.deletions}} + {file.additions > 0 && +{file.additions}} + {file.deletions > 0 && -{file.deletions}} {statusBadge(file.status)}
@@ -230,12 +230,12 @@ export const ChatTasksPanel = React.memo(function ChatTasksPanel({
{!selectedPath && (
- Select a file to view its diff + Select a file to view its diff
)} {selectedPath && loadingPath === selectedPath && (
- Loading diff... + Loading diff...
)} {selectedPath && loadingPath !== selectedPath && activeDiff && ( @@ -245,7 +245,7 @@ export const ChatTasksPanel = React.memo(function ChatTasksPanel({ )} {selectedPath && loadingPath !== selectedPath && !activeDiff && (
- Failed to load diff + Failed to load diff
)}
diff --git a/apps/desktop/src/renderer/components/chat/ChatTurnDiffPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatTurnDiffPanel.tsx deleted file mode 100644 index cabd43de9..000000000 --- a/apps/desktop/src/renderer/components/chat/ChatTurnDiffPanel.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import React, { useCallback, useRef, useState } from "react"; -import { AnimatePresence, motion } from "motion/react"; -import { - CaretDown, - CaretRight, - FileCode, - FilePlus, - FileX, -} from "@phosphor-icons/react"; -import type { - AgentChatGetTurnFileDiffArgs, - FileDiff, - TurnDiffFile, - TurnDiffSummary, -} from "../../../shared/types"; -import { MonacoDiffView } from "../lanes/MonacoDiffView"; -import { cn } from "../ui/cn"; - -/* ── Helpers ── */ - -function basename(filePath: string): string { - const normalized = filePath.replace(/\\/g, "/"); - return normalized.split("/").pop() ?? normalized; -} - -function statusIcon(status: TurnDiffFile["status"]) { - switch (status) { - case "A": - return ; - case "D": - return ; - default: - return ; - } -} - -function statusBadge(status: TurnDiffFile["status"]) { - switch (status) { - case "A": - return ( - - A - - ); - case "D": - return ( - - D - - ); - case "R": - return ( - - R - - ); - case "C": - return ( - - C - - ); - default: - return ( - - M - - ); - } -} - -/* ── Component ── */ - -export const ChatTurnDiffPanel = React.memo(function ChatTurnDiffPanel({ - summary, - sessionId, -}: { - summary: TurnDiffSummary; - sessionId: string; -}) { - const [expanded, setExpanded] = useState(false); - const [selectedPath, setSelectedPath] = useState(null); - const [loadingPath, setLoadingPath] = useState(null); - const diffCache = useRef>({}); - - const [activeDiff, setActiveDiff] = useState(null); - - const handleSelectFile = useCallback( - async (filePath: string) => { - setSelectedPath(filePath); - - // Serve from cache if available. - if (diffCache.current[filePath]) { - setActiveDiff(diffCache.current[filePath]); - return; - } - - setLoadingPath(filePath); - setActiveDiff(null); - - try { - const args: AgentChatGetTurnFileDiffArgs = { - sessionId, - beforeSha: summary.beforeSha, - afterSha: summary.afterSha, - filePath, - }; - const diff = await window.ade.agentChat.getTurnFileDiff(args); - if (!diff) { - setActiveDiff(null); - return; - } - diffCache.current[filePath] = diff; - setActiveDiff(diff); - } catch (err) { - console.error("[ChatTurnDiffPanel] Failed to fetch diff for", filePath, err); - } finally { - setLoadingPath(null); - } - }, - [sessionId, summary.beforeSha, summary.afterSha], - ); - - const fileCount = summary.files.length; - - return ( -
- {/* ── Collapsed header ── */} - - - {/* ── Expanded body ── */} - - {expanded && ( - -
-
- {/* ── File list (left pane) ── */} -
- {summary.files.map((file) => { - const isSelected = selectedPath === file.path; - return ( - - ); - })} -
- - {/* ── Diff viewer (right pane) ── */} -
- {!selectedPath && ( -
- - Select a file to view its diff - -
- )} - - {selectedPath && loadingPath === selectedPath && ( -
- - Loading diff... - -
- )} - - {selectedPath && loadingPath !== selectedPath && activeDiff && ( -
- -
- )} - - {selectedPath && loadingPath !== selectedPath && !activeDiff && ( -
- - Failed to load diff - -
- )} -
-
-
-
- )} -
-
- ); -}); diff --git a/apps/desktop/src/renderer/components/chat/ChatTurnDivider.tsx b/apps/desktop/src/renderer/components/chat/ChatTurnDivider.tsx deleted file mode 100644 index 9535cc285..000000000 --- a/apps/desktop/src/renderer/components/chat/ChatTurnDivider.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React from "react"; -import { cn } from "../ui/cn"; - -export type TurnDividerData = { - turnId: string; - timestamp: string; - endTimestamp?: string; - model?: string; - filesChanged?: number; - insertions?: number; - deletions?: number; - inputTokens?: number; - outputTokens?: number; - cacheReadTokens?: number; - costUsd?: number; - status?: "completed" | "interrupted" | "failed"; -}; - -function formatDuration(startIso: string, endIso?: string): string | null { - if (!endIso) return null; - const ms = Date.parse(endIso) - Date.parse(startIso); - if (!Number.isFinite(ms) || ms < 0) return null; - if (ms < 1000) return "<1s"; - const seconds = Math.round(ms / 1000); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; -} - -function formatTokens(count: number | undefined | null): string | null { - if (typeof count !== "number" || !Number.isFinite(count) || count <= 0) return null; - if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; - if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`; - return String(Math.round(count)); -} - -function formatCost(usd: number | undefined | null): string | null { - if (typeof usd !== "number" || !Number.isFinite(usd) || usd <= 0) return null; - if (usd < 0.01) return "<$0.01"; - return `$${usd.toFixed(2)}`; -} - -export const ChatTurnDivider = React.memo(function ChatTurnDivider({ - data, -}: { - data: TurnDividerData; -}) { - const duration = formatDuration(data.timestamp, data.endTimestamp); - const inputTok = formatTokens(data.inputTokens); - const outputTok = formatTokens(data.outputTokens); - const cacheTok = formatTokens(data.cacheReadTokens); - const cost = formatCost(data.costUsd); - const hasStats = duration || data.filesChanged || inputTok || outputTok || cost; - - const statusDotColor = data.status === "failed" - ? "bg-red-400/50" - : data.status === "interrupted" - ? "bg-amber-400/50" - : "bg-emerald-400/30"; - - if (!hasStats) return null; - - return ( -
-
-
- - {duration ? {duration} : null} - {data.filesChanged ? ( - - {data.filesChanged} file{data.filesChanged !== 1 ? "s" : ""} - {data.insertions ? +{data.insertions} : null} - {data.deletions ? -{data.deletions} : null} - - ) : null} - {inputTok || outputTok ? ( - - {inputTok ? `${inputTok} in` : ""} - {inputTok && outputTok ? " · " : ""} - {outputTok ? `${outputTok} out` : ""} - {cacheTok ? ` (${cacheTok} cached)` : ""} - - ) : null} - {cost ? {cost} : null} -
-
-
- ); -}); diff --git a/apps/desktop/src/renderer/components/cto/CtoPage.tsx b/apps/desktop/src/renderer/components/cto/CtoPage.tsx index 469f6f892..d3afbba9c 100644 --- a/apps/desktop/src/renderer/components/cto/CtoPage.tsx +++ b/apps/desktop/src/renderer/components/cto/CtoPage.tsx @@ -37,16 +37,16 @@ import { OnboardingWizard } from "./OnboardingWizard"; import { OnboardingBanner } from "./OnboardingBanner"; import { WorkerCreationWizard } from "./WorkerCreationWizard"; import { TimelineEntry } from "./shared/TimelineEntry"; -import { cardCls, compactHeaderCls, statChipCls, shellBodyCls, shellTabBarCls } from "./shared/designTokens"; +import { cardCls, shellBodyCls } from "./shared/designTokens"; /* ── Tab types ── */ -type TabId = "chat" | "team" | "linear" | "settings"; +type TabId = "chat" | "team" | "workflows" | "settings"; const TABS: { id: TabId; label: string; icon: React.ElementType; color: string }[] = [ { id: "chat", label: "Chat", icon: ChatCircle, color: "#A78BFA" }, { id: "team", label: "Team", icon: UsersThree, color: "#60A5FA" }, - { id: "linear", label: "Linear", icon: GitBranch, color: "#34D399" }, + { id: "workflows", label: "Workflows", icon: GitBranch, color: "#34D399" }, { id: "settings", label: "Settings", icon: Gear, color: "#F472B6" }, ]; @@ -318,7 +318,7 @@ export function CtoPage() { const syncHash = () => { const hash = window.location.hash.toLowerCase(); if (hash.includes("linear-sync")) { - setActiveTab("linear"); + setActiveTab("workflows"); } else if (hash.includes("team-setup")) { setActiveTab("team"); } @@ -670,118 +670,50 @@ export function CtoPage() { /> )} -
-
- {/* Compact header row */} -
- {/* Left: Avatar + title + subtitle */} -
-
- - {pageTitle.charAt(0).toUpperCase()} - -
-
- {pageTitle} - {pageSubtitle} -
-
- - {/* Center: Stats as compact chips */} -
- {[ - { - label: selectedWorker ? "Role" : "Focus", - value: focusSummary, - color: "#38BDF8", - }, - { - label: "Memory", - value: selectedWorker ? "revisions" : "durable", - color: "#A78BFA", - }, - { - label: "Team", - value: `${teamStats.active}/${teamStats.total}`, - color: "#34D399", - }, - { - label: "Sessions", - value: String(sessionLogs.length), - color: "#FBBF24", - }, - ].map((chip) => ( -
- {chip.label} - {chip.value} -
- ))} -
- - {/* Right: Bridge/brain status */} -
- {[ - { label: bridgeSummary, tone: openclawStatus?.state === "connected" ? "#34D399" : "#38BDF8" }, - { label: currentBrainSummary || "Brain not set", tone: selectedWorker ? "#60A5FA" : "#38BDF8" }, - ].map((item) => ( -
- {item.label} -
- ))} -
+ {/* Minimal single-row header */} +
+ {/* Left: Avatar + name */} +
+
+ + {pageTitle.charAt(0).toUpperCase()} +
+ {pageTitle} +
- {/* Tab bar */} -
-
- {TABS.map(({ id, label, icon: Icon, color }) => { - const active = activeTab === id; - return ( - - ); - })} -
-
+ {/* Center: Tab buttons */} +
+ {TABS.map(({ id, label, icon: Icon }) => { + const active = activeTab === id; + return ( + + ); + })}
+ + {/* Right: Model badge */} + {currentBrainSummary ? ( +
+ {currentBrainSummary} +
+ ) : null}
{/* Tab content */} @@ -907,7 +839,7 @@ export function CtoPage() {
{subordinateActivity.length === 0 ? ( -
No department activity recorded yet.
+
No department activity recorded yet.
) : subordinateActivity.map((entry) => ( } + {activeTab === "workflows" && } {/* Settings tab */} {activeTab === "settings" && ( diff --git a/apps/desktop/src/renderer/components/cto/OpenclawConnectionPanel.tsx b/apps/desktop/src/renderer/components/cto/OpenclawConnectionPanel.tsx index 98e93ae3e..38c7d0f1c 100644 --- a/apps/desktop/src/renderer/components/cto/OpenclawConnectionPanel.tsx +++ b/apps/desktop/src/renderer/components/cto/OpenclawConnectionPanel.tsx @@ -308,7 +308,7 @@ export function OpenclawConnectionPanel({
-
{NOTIFICATION_TYPES.map((notificationType) => (
-