diff --git a/.ade/.gitignore b/.ade/.gitignore index 19685b6ff..01224321c 100644 --- a/.ade/.gitignore +++ b/.ade/.gitignore @@ -20,6 +20,10 @@ cto/core-memory.json cto/daily/ cto/sessions.jsonl cto/subordinate-activity.jsonl +cto/openclaw-history.json +cto/openclaw-idempotency.json +cto/openclaw-outbox.json +cto/openclaw-routes.json cto/openclaw-device.json context/ memory/ diff --git a/.ade/cto/openclaw-history.json b/.ade/cto/openclaw-history.json deleted file mode 100644 index fe51488c7..000000000 --- a/.ade/cto/openclaw-history.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/.ade/cto/openclaw-idempotency.json b/.ade/cto/openclaw-idempotency.json deleted file mode 100644 index 0967ef424..000000000 --- a/.ade/cto/openclaw-idempotency.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/.ade/cto/openclaw-outbox.json b/.ade/cto/openclaw-outbox.json deleted file mode 100644 index fe51488c7..000000000 --- a/.ade/cto/openclaw-outbox.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/.ade/cto/openclaw-routes.json b/.ade/cto/openclaw-routes.json deleted file mode 100644 index e52cc3a0c..000000000 --- a/.ade/cto/openclaw-routes.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "byAgentId": {} -} diff --git a/.claude/commands/automate.md b/.claude/commands/automate.md index 5ba5f9d19..9dc1d00d1 100644 --- a/.claude/commands/automate.md +++ b/.claude/commands/automate.md @@ -14,6 +14,16 @@ You are the Test Automation agent for the ADE (Agentic Development Environment) **Arguments:** $ARGUMENTS +## Execution Mode: Autonomous + +This command runs end-to-end without user interaction. Do NOT: +- Ask the user to confirm, choose, or approve anything. +- Pause between phases to request direction. +- Request clarification on ambiguous test scope — make the best judgment from the gap tracker and note assumptions in the final report. +- Stop on non-fatal warnings — log them and continue. + +Only produce the Phase 7 summary and any fatal error messages (e.g., cannot create a meaningful test). Every decision is made by the agent based on the rules in this file. + --- ## Pipeline Overview @@ -61,6 +71,32 @@ Categorize changes: Copy their patterns exactly for: imports, setup/teardown, mocking, assertions, describe/it nesting. +### 1.2.5 Read the feature doc for context + +Before writing any test, skim the relevant internal feature doc so you know what behavior is load-bearing vs incidental. Docs live under `docs/features//`: + +| Changed source area | Feature doc | +|---|---| +| services/orchestrator/ or renderer missions/ | docs/features/missions/ | +| services/prs/ or renderer prs/ | docs/features/pull-requests/ | +| services/lanes/ or renderer lanes/ | docs/features/lanes/ | +| services/chat/ or services/ai/ or renderer chat/ | docs/features/chat/ + features/agents/ | +| services/cto/ or renderer cto/ | docs/features/cto/ + features/linear-integration/ | +| services/memory/ | docs/features/memory/ | +| services/automations/ or renderer automations/ | docs/features/automations/ | +| services/conflicts/ | docs/features/conflicts/ | +| services/computerUse/ | docs/features/computer-use/ | +| services/pty/ or sessions/ or processes/ or renderer terminals/ | docs/features/terminals-and-sessions/ | +| services/files/ or renderer files/ | docs/features/files-and-editor/ | +| services/sync/ or syncRemoteCommandService | docs/features/sync-and-multi-device/ | +| services/onboarding/ or services/config/ or renderer settings/ | docs/features/onboarding-and-settings/ | +| services/history/ | docs/features/history/ | +| services/context/ | docs/features/context-packs/ | + +Each `README.md` has a "Source file map" at the top, plus "gotchas / fragile areas" prose. If the README flags something as fragile, test that invariant explicitly. + +Cross-cutting: `docs/ARCHITECTURE.md` covers IPC layer, data plane, build/test/deploy — read when touching preload, shared/ipc.ts, or registerIpc. + ### 1.3 Key Test Infrastructure **Desktop app:** diff --git a/.claude/commands/finalize.md b/.claude/commands/finalize.md index 896fa3686..99edd4bde 100644 --- a/.claude/commands/finalize.md +++ b/.claude/commands/finalize.md @@ -14,6 +14,17 @@ It guarantees three outcomes: **Usage:** `/finalize` +## Execution Mode: Autonomous + +This command runs end-to-end without user interaction. Do NOT: +- Ask the user to confirm, choose, or approve anything. +- Pause between phases to request direction. +- Stop on non-fatal warnings — log them and continue. +- Request clarification on ambiguous simplifications — skip the risky ones and note in the final report. +- Ask before reverting your own work (e.g., Phase 3i drift check reverts simplifier edits silently). + +The only outputs are the Phase 4 summary and any error messages for genuinely fatal failures (typecheck/lint errors, build crashes, test failures the agent itself caused). Every decision is made by the agent based on the rules in this file. + ## Pipeline Overview ``` @@ -68,52 +79,109 @@ Spawn agents in parallel using the **Agent** tool: ### Simplifier agents (1-3 based on batch size) -Use `subagent_type: "code-simplifier"` for each batch. +Use `subagent_type: "code-simplifier:code-simplifier"` for each batch (note the full namespaced form — plain `"code-simplifier"` is not a valid agent type). Prompt each with: - The list of files in their batch - Branch context (what feature/area was changed) - Instructions: focus on recently modified code, don't refactor untouched code +- **Explicit safety rule**: before removing code that looks dead (unused helpers, "unused" local components, stale state), grep for references **including the file's colocated `*.test.ts(x)` neighbor**. Test expectations often lag behind feature refactors — removing "unused" code can silently break a test suite that will only light up in Phase 3e. When in doubt, leave it and note in the report. +- **Diff-only scope**: `git diff main -- ` first; if zero diff, do not edit (a previous run tried to simplify files it thought were modified, and wasted time on unchanged code). +- **Typecheck after every file**: `cd apps/desktop && npx tsc --noEmit -p . 2>&1 | head -20`. ### Doc updater agent +The internal docs live under `docs/` with this structure (rebuilt; do NOT confuse with the public Mintlify site at repo root `docs.json` + `*.mdx`): + +``` +docs/ +├── README.md # navigation map +├── PRD.md # product entry point — links to every feature +├── ARCHITECTURE.md # consolidated system architecture +├── OPTIMIZATION_OPPORTUNITIES.md # backlog (append-only) +└── features/ + ├── agents/ ├── memory/ + ├── automations/ ├── missions/ + ├── chat/ ├── onboarding-and-settings/ + ├── computer-use/ ├── project-home/ + ├── conflicts/ ├── pull-requests/ + ├── context-packs/ ├── sync-and-multi-device/ + ├── cto/ ├── terminals-and-sessions/ + ├── files-and-editor/ └── workspace-graph/ + ├── history/ + ├── lanes/ + └── linear-integration/ +``` + +Each `features//` contains a `README.md` (overview + source file map at top) plus 1–4 detail `*.md` files. + Spawn a general-purpose agent with this prompt: ``` You are the documentation updater for the ADE project. -Analyze all changes on the current branch vs main and update relevant documentation. +Analyze all changes on the current branch vs main and update relevant internal +docs under `docs/`. The public Mintlify site (docs.json + root-level .mdx files) +is out of scope — do NOT touch it. Step 1: Get changed files git diff main --name-only git diff main --stat | tail -30 -Step 2: Identify affected docs - -Map changed source directories to documentation: - -| Source Directory | Doc Location | -|-----------------|--------------| -| apps/desktop/src/main/services/orchestrator/ | docs/architecture/, docs/features/ | -| apps/desktop/src/main/services/prs/ | docs/features/ (PR-related) | -| apps/desktop/src/main/services/lanes/ | docs/features/ (lanes-related) | -| apps/desktop/src/main/services/memory/ | docs/features/ (memory-related) | -| apps/desktop/src/main/services/cto/ | docs/features/ (CTO/Linear-related) | -| apps/desktop/src/main/services/ai/ | docs/architecture/ (AI integration) | -| apps/desktop/src/renderer/components/ | docs/features/ (UI-related) | -| apps/mcp-server/ | docs/ (MCP-related) | -| .github/workflows/ | docs/ (CI/CD-related) | - -Step 3: Update docs -- Rewrite sections to reflect current reality (not what changed) -- Remove outdated information -- Update code examples, file paths, API references -- Do NOT add changelog sections or "Updated on X" notes -- Do NOT create new doc files unless absolutely necessary - -Step 4: Run doc validation +Step 2: Map changed source to internal docs + +| Source Directory | Doc Location | +|----------------------------------------------------|----------------------------------------------------| +| apps/desktop/src/main/services/orchestrator/ | docs/features/missions/ | +| apps/desktop/src/main/services/prs/ | docs/features/pull-requests/ | +| apps/desktop/src/main/services/lanes/ | docs/features/lanes/ | +| apps/desktop/src/main/services/memory/ | docs/features/memory/ | +| apps/desktop/src/main/services/cto/ | docs/features/cto/ (+ linear-integration/) | +| apps/desktop/src/main/services/ai/ | docs/features/chat/ + features/agents/ | +| apps/desktop/src/main/services/chat/ | docs/features/chat/ | +| apps/desktop/src/main/services/automations/ | docs/features/automations/ | +| apps/desktop/src/main/services/computerUse/ | docs/features/computer-use/ | +| apps/desktop/src/main/services/context/ | docs/features/context-packs/ | +| apps/desktop/src/main/services/conflicts/ | docs/features/conflicts/ | +| apps/desktop/src/main/services/files/ | docs/features/files-and-editor/ | +| apps/desktop/src/main/services/history/ | docs/features/history/ | +| apps/desktop/src/main/services/onboarding/ | docs/features/onboarding-and-settings/ | +| apps/desktop/src/main/services/pty/ | docs/features/terminals-and-sessions/ | +| apps/desktop/src/main/services/sessions/ | docs/features/terminals-and-sessions/ | +| apps/desktop/src/main/services/processes/ | docs/features/terminals-and-sessions/ | +| apps/desktop/src/main/services/sync/ | docs/features/sync-and-multi-device/ | +| apps/desktop/src/main/services/config/ | docs/features/onboarding-and-settings/ | +| apps/desktop/src/main/services/ipc/ | docs/ARCHITECTURE.md (IPC section) | +| apps/desktop/src/main/services/git/ | docs/ARCHITECTURE.md (Git engine section) + lanes/ | +| apps/desktop/src/preload/ | docs/ARCHITECTURE.md (IPC contract) | +| apps/desktop/src/shared/ | docs/ARCHITECTURE.md + touching feature's doc | +| apps/desktop/src/renderer/components// | docs/features// | +| apps/desktop/src/renderer/state/ | docs/ARCHITECTURE.md (UI framework) | +| apps/mcp-server/ | docs/ARCHITECTURE.md + features/linear-integration/| +| .github/workflows/ | docs/ARCHITECTURE.md (Build/Test/Deploy) | +| apps/ios/ | docs/features/sync-and-multi-device/ios-companion.md | +| apps/web/ | docs/ARCHITECTURE.md (Apps & Processes) | + +Step 3: Update docs in place +- Prefer editing existing docs over creating new ones. +- If a feature gets a genuinely new sub-concept worth its own page, add a new detail doc inside the existing features// folder. +- Keep each README.md's "Source file map" section current — it is the primary way an agent orients itself. +- Rewrite prose to reflect current reality (not a changelog of what changed). +- Remove outdated information. +- Do NOT add changelog sections, "Updated on X" notes, or dated markers. +- Do NOT modify docs/OPTIMIZATION_OPPORTUNITIES.md via this agent — it is append-only and human-curated. + +Step 4: Append-only — NEVER touch the public Mintlify site +- Do NOT modify docs.json or any *.mdx file at repo root. +- Do NOT modify ./chat/, ./tools/, ./missions/, ./changelog/, ./configuration/, ./computer-use/, ./context-packs/, ./getting-started/, ./guides/, ./automations/, ./lanes/, ./cto/ (these are Mintlify pages). + +Step 5: Run doc validation node scripts/validate-docs.mjs +This validator only covers the Mintlify site. For internal docs, self-check: + - Every features//README.md still has a "Source file map" section. + - PRD.md links resolve (grep for broken relative links). + Report what docs were updated and what was changed. ``` @@ -207,8 +275,41 @@ cd apps/web && npm run build node scripts/validate-docs.mjs ``` +This only validates the public Mintlify site (`docs.json` + `.mdx`). Also run these automated checks for the internal `docs/` tree: + +```bash +# Every features//README.md has a "Source file map" section. +for d in docs/features/*/README.md; do + grep -q "Source file map" "$d" || echo "MISSING map: $d" +done + +# PRD.md links resolve. +grep -oE "\[.*\]\([^)]+\.md\)" docs/PRD.md | \ + sed -E 's/.*\(([^)]+)\).*/\1/' | \ + while read -r p; do + test -f "docs/$p" || echo "BROKEN LINK: $p" + done +``` + +Both commands should produce empty output. Any `MISSING map:` or `BROKEN LINK:` line is a failure — fix the offending doc and re-run. Do not prompt the user; resolve autonomously. + All checks must pass. If any fail, fix and re-run only the failed step. +### 3i. Test-simplifier drift check (catch Phase 2 over-reach) + +When a simplifier agent removed "unused" code, the colocated test may still reference it — the test will only light up in Phase 3e. If a test failure appears **only in a file the simplifier touched (or its test sibling)**, treat it as suspect: + +```bash +# Files the simplifier touched this run: +# (Run once before Phase 2; diff after to see what changed.) +git diff main --name-only | sort > /tmp/finalize-branch-files.txt + +# After Phase 2, list what changed in this session on top of the prior branch state: +git diff --name-only | sort > /tmp/finalize-session-files.txt +``` + +If Phase 3e fails only inside files the simplifier touched, revert the simplifier's edits to those files and re-run. Do NOT rewrite the test suite in Phase 3 — tests that drift because the feature branch refactored UI are a separate follow-up. + --- ## Phase 4: Summary diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 2553284b3..59d8d5aae 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -248,7 +248,7 @@ async function createWindow(logger?: Logger): Promise { ? "'self' http://localhost:* http://127.0.0.1:*" : "'self' file: app:"; const cspWsSources = isDevMode ? " ws://localhost:* ws://127.0.0.1:*" : ""; - const cspImageSources = `${cspSources} https://avatars.githubusercontent.com https://*.githubusercontent.com https://github.githubassets.com https://opengraph.githubassets.com https://github.com https://vercel.com https://*.vercel.com https://img.shields.io`; + const cspImageSources = `${cspSources} https://avatars.githubusercontent.com https://*.githubusercontent.com https://github.githubassets.com https://opengraph.githubassets.com https://github.com https://vercel.com https://*.vercel.com https://img.shields.io https://*.s3.amazonaws.com`; const cspPolicy = [ `default-src ${cspSources}`, `base-uri 'self'`, @@ -1120,6 +1120,9 @@ app.whenReady().then(async () => { }); const sessionService = createSessionService({ db }); + sessionService.onChanged((event) => { + emitProjectEvent(projectRoot, IPC.sessionsChanged, event); + }); const reconciledSessions = sessionService.reconcileStaleRunningSessions({ status: "disposed", excludeToolTypes: ["claude-chat", "codex-chat", "opencode-chat", "cursor"], @@ -1460,16 +1463,23 @@ app.whenReady().then(async () => { }, }); - const processService = createProcessService({ - db, - projectId, - processLogsDir: adePaths.processLogsDir, - logger, - laneService, - projectConfigService, - broadcastEvent: (ev) => - emitProjectEvent(projectRoot, IPC.processesEvent, ev), - }); + const getLaneRuntimeEnv = async (laneId: string) => { + const lease = portAllocationService.getLease(laneId); + const lane = (await laneService.list({ includeArchived: false, includeStatus: false })).find( + (entry) => entry.id === laneId, + ); + const hostname = laneProxyService.getRoute(laneId)?.hostname + ?? laneProxyService.generateHostname(laneId, lane?.name); + const portStart = lease?.rangeStart ?? 3000; + const portEnd = lease?.rangeEnd ?? portStart; + return { + PORT: String(portStart), + PORT_RANGE_START: String(portStart), + PORT_RANGE_END: String(portEnd), + HOSTNAME: hostname, + PROXY_HOSTNAME: hostname, + }; + }; const onTrackedSessionEnded = ({ laneId, @@ -1515,6 +1525,7 @@ app.whenReady().then(async () => { sessionService, aiIntegrationService, projectConfigService, + getLaneRuntimeEnv, logger, broadcastData: (ev) => { emitProjectEvent(projectRoot, IPC.ptyData, ev); @@ -1531,6 +1542,19 @@ app.whenReady().then(async () => { loadPty, }); + const processService = createProcessService({ + db, + projectId, + logger, + laneService, + projectConfigService, + sessionService, + ptyService, + getLaneRuntimeEnv, + broadcastEvent: (ev) => + emitProjectEvent(projectRoot, IPC.processesEvent, ev), + }); + const sessionDeltaService = createSessionDeltaService({ db, projectId, diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts index ff690494a..0aaf8b384 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts @@ -1,6 +1,17 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi, beforeEach } from "vitest"; import { createCtoOperatorTools, type CtoOperatorToolDeps } from "./ctoOperatorTools"; +// Mock only execFileSync on node:child_process so searchCodebase can exercise it deterministically. +// Preserve the rest of the module (e.g. spawn, exec) for unrelated tests. +const execFileSyncMock = vi.fn(); +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFileSync: (...args: unknown[]) => execFileSyncMock(...args), + }; +}); + const baseSession = { id: "chat-1", laneId: "lane-1", @@ -1731,4 +1742,223 @@ describe("createCtoOperatorTools", () => { expect(result).toMatchObject({ success: false, error: expect.stringContaining("File service") }); }); }); + + // ── searchCodebase tool ───────────────────────────────────────── + describe("searchCodebase tool (execFileSync-backed rg)", () => { + beforeEach(() => { + execFileSyncMock.mockReset(); + }); + + function getTool() { + const deps = buildDeps(); + const tools = createCtoOperatorTools(deps); + const tool = tools.searchCodebase as any; + expect(tool, "searchCodebase tool must be registered").toBeTruthy(); + return tool; + } + + it("invokes execFileSync('rg', argv, opts) with the expected argv order and cwd", async () => { + execFileSyncMock.mockReturnValue(""); + const tool = getTool(); + + await tool.execute({ + pattern: "spawnChat", + fileGlob: "services/**/*.ts", + maxResults: 3, + contextLines: 2, + }); + + expect(execFileSyncMock).toHaveBeenCalledTimes(1); + const [cmd, argv, opts] = execFileSyncMock.mock.calls[0]; + expect(cmd, "command must be rg, not a shell string").toBe("rg"); + expect(Array.isArray(argv), "argv must be an array (execFileSync form, not shell)").toBe(true); + expect(argv).toEqual([ + "--no-heading", + "--line-number", + "--max-count=3", + "--context=2", + "--glob", + "services/**/*.ts", + "--", + "spawnChat", + ".", + ]); + const optsRec = opts as Record; + expect(optsRec.encoding).toBe("utf8"); + expect(optsRec.maxBuffer).toBe(512 * 1024); + expect(optsRec.timeout).toBe(10_000); + expect(typeof optsRec.cwd, "cwd must be a string path to ADE root").toBe("string"); + expect((optsRec.cwd as string).length, "cwd path must be non-empty").toBeGreaterThan(0); + }); + + it("defaults fileGlob to '*.ts' when not provided (executor-level fallback)", async () => { + execFileSyncMock.mockReturnValue(""); + const tool = getTool(); + + await tool.execute({ pattern: "foo", contextLines: 2 }); + + const argv = execFileSyncMock.mock.calls[0][1] as string[]; + const globIdx = argv.indexOf("--glob"); + expect(globIdx, "--glob must appear").toBeGreaterThanOrEqual(0); + expect(argv[globIdx + 1]).toBe("*.ts"); + }); + + it("empty/whitespace fileGlob falls back to '*.ts'", async () => { + execFileSyncMock.mockReturnValue(""); + const tool = getTool(); + + await tool.execute({ pattern: "foo", fileGlob: " ", contextLines: 2 }); + + const argv = execFileSyncMock.mock.calls[0][1] as string[]; + const globIdx = argv.indexOf("--glob"); + expect(argv[globIdx + 1]).toBe("*.ts"); + }); + + it("dedupes output lines by filename so duplicate-file matches do not exhaust maxResults", async () => { + // Same file a.ts has 3 matches, then b.ts has 1, then c.ts has 1. + // With maxResults=2 we should still include a.ts + b.ts (2 unique files) but NOT c.ts. + const rgOutput = [ + "a.ts:10:first", + "a.ts:20:second", + "a.ts:30:third", + "b.ts:5:first", + "c.ts:1:first", + ].join("\n"); + execFileSyncMock.mockReturnValue(rgOutput); + const tool = getTool(); + + const result = await tool.execute({ + pattern: "xyz", + maxResults: 2, + contextLines: 0, + }); + + expect(result.success).toBe(true); + // Output must contain a.ts and b.ts content but NOT c.ts (dedup stops before adding c.ts). + expect(result.output).toContain("a.ts:10:first"); + expect(result.output).toContain("a.ts:20:second"); + expect(result.output).toContain("a.ts:30:third"); + expect(result.output).toContain("b.ts:5:first"); + expect(result.output, "maxResults=2 should cut c.ts out").not.toContain("c.ts:1:first"); + expect(result.truncated, "hitting maxResults must mark result truncated").toBe(true); + }); + + it("marks truncated=true when unique-file count reaches maxResults", async () => { + const rgOutput = ["a.ts:1:m", "b.ts:1:m", "c.ts:1:m"].join("\n"); + execFileSyncMock.mockReturnValue(rgOutput); + const tool = getTool(); + + const result = await tool.execute({ + pattern: "m", + maxResults: 3, + contextLines: 0, + }); + + expect(result.success).toBe(true); + expect(result.truncated).toBe(true); + }); + + it("marks truncated=true when output exceeds 200 lines", async () => { + // 210 matches from distinct files so no dedup cap trips before the 200-line cap. + const lines: string[] = []; + for (let i = 0; i < 210; i += 1) { + lines.push(`file${i}.ts:1:match`); + } + execFileSyncMock.mockReturnValue(lines.join("\n")); + const tool = getTool(); + + const result = await tool.execute({ + pattern: "match", + maxResults: 30, + contextLines: 0, + }); + + expect(result.success).toBe(true); + expect(result.truncated, "output over 200 lines must be truncated").toBe(true); + expect(result.output.split("\n").length).toBeLessThanOrEqual(200); + }); + + it("returns success with matchCount=0 and empty output when rg exits with status 1 (no matches)", async () => { + const err: any = new Error("rg exit 1"); + err.status = 1; + execFileSyncMock.mockImplementation(() => { + throw err; + }); + const tool = getTool(); + + const result = await tool.execute({ pattern: "never-matches", contextLines: 0 }); + + expect(result).toMatchObject({ + success: true, + matchCount: 0, + truncated: false, + }); + expect(typeof result.output).toBe("string"); + }); + + it("returns success=false with error message for non-1 execution failures", async () => { + const err: any = new Error("boom"); + err.status = 2; + execFileSyncMock.mockImplementation(() => { + throw err; + }); + const tool = getTool(); + + const result = await tool.execute({ pattern: "[bad-regex", contextLines: 0 }); + + expect(result.success).toBe(false); + expect(typeof result.error).toBe("string"); + expect(result.error.length, "error message must not be empty").toBeGreaterThan(0); + }); + + it("truncates pattern to 500 chars before passing to argv", async () => { + execFileSyncMock.mockReturnValue(""); + const tool = getTool(); + const longPattern = "x".repeat(700); + + await tool.execute({ pattern: longPattern, contextLines: 0 }); + + const argv = execFileSyncMock.mock.calls[0][1] as string[]; + // The pattern sits at the position right after "--" separator. + const sep = argv.indexOf("--"); + expect(sep, "-- separator must be present").toBeGreaterThan(0); + const sentPattern = argv[sep + 1]; + expect(sentPattern.length, "pattern must be capped at 500 chars").toBe(500); + expect(sentPattern).toBe("x".repeat(500)); + }); + + it("truncates fileGlob to 200 chars before passing to argv", async () => { + execFileSyncMock.mockReturnValue(""); + const tool = getTool(); + const longGlob = "*".repeat(300); + + await tool.execute({ pattern: "p", fileGlob: longGlob, contextLines: 0 }); + + const argv = execFileSyncMock.mock.calls[0][1] as string[]; + const globIdx = argv.indexOf("--glob"); + expect(globIdx).toBeGreaterThanOrEqual(0); + const sentGlob = argv[globIdx + 1]; + expect(sentGlob.length, "fileGlob must be capped at 200 chars").toBe(200); + expect(sentGlob).toBe("*".repeat(200)); + }); + + it("passes dangerous shell characters through argv verbatim (execFileSync avoids shell injection)", async () => { + execFileSyncMock.mockReturnValue(""); + const tool = getTool(); + const evil = "foo; rm -rf / && echo pwned `whoami` $(id)"; + + await tool.execute({ pattern: evil, contextLines: 0 }); + + const [cmd, argv] = execFileSyncMock.mock.calls[0]; + expect(cmd, "must call rg directly, not through a shell").toBe("rg"); + expect(Array.isArray(argv), "argv must be an array (no shell string)").toBe(true); + const sep = (argv as string[]).indexOf("--"); + const sentPattern = (argv as string[])[sep + 1]; + expect(sentPattern, "dangerous chars must be passed through as a literal argv element").toBe(evil); + // Make sure no arg was concatenated into a shell string. + for (const a of argv as string[]) { + expect(typeof a).toBe("string"); + } + }); + }); }); diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index dbee718e0..334c0ec63 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -1,6 +1,7 @@ +import path from "node:path"; import { executableTool as tool, type ExecutableTool as Tool } from "./executableTool"; import { z } from "zod"; -import { getModelById, resolveChatProviderForDescriptor } from "../../../../shared/modelRegistry"; +import { getModelById, resolveModelDescriptor, resolveChatProviderForDescriptor } from "../../../../shared/modelRegistry"; import type { AgentChatCreateArgs, AgentChatInterruptArgs, @@ -677,7 +678,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { try { - const selectedModelId = modelId?.trim() || deps.defaultModelId || null; + // Resolve model: supports full IDs (anthropic/claude-sonnet-4-6), short IDs (sonnet), and aliases (opus) + const rawModelId = modelId?.trim() || null; + const descriptor = rawModelId ? resolveModelDescriptor(rawModelId) : null; + const selectedModelId = descriptor?.id ?? rawModelId ?? deps.defaultModelId ?? null; const resolved = deriveChatProvider({ modelId: selectedModelId }); const executionLaneId = await deps.resolveExecutionLane({ requestedLaneId: laneId?.trim() || undefined, @@ -2226,9 +2238,9 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { try { @@ -2240,6 +2252,37 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { + try { + await deps.laneService.rename({ laneId, name }); + return { success: true, laneId, name }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.archiveLane = tool({ + description: "Archive a lane — hides it from the default lane list but preserves all data and the worktree.", + inputSchema: z.object({ + laneId: z.string().trim().min(1).describe("ID of the lane to archive."), + }), + execute: async ({ laneId }) => { + try { + await deps.laneService.archive({ laneId }); + return { success: true, laneId }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + // --------------------------------------------------------------------------- // Worker Management // --------------------------------------------------------------------------- @@ -2382,7 +2425,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record gitGuard(() => deps.gitService!.commit({ laneId: resolveLaneId(laneId), message, stageAll })), }); @@ -3399,5 +3442,72 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { + try { + const { execFileSync } = await import("node:child_process"); + const adeRoot = path.resolve(__dirname, "../../../../.."); + const searchPattern = pattern.trim().slice(0, 500); + const globArg = (fileGlob?.trim() || "*.ts").slice(0, 200); + const args = [ + "--no-heading", + "--line-number", + "--max-count=3", + `--context=${contextLines}`, + "--glob", + globArg, + "--", + searchPattern, + ".", + ]; + const result = execFileSync("rg", args, { + cwd: adeRoot, + encoding: "utf8", + maxBuffer: 512 * 1024, + timeout: 10_000, + }).trim(); + const lines = result ? result.split("\n") : []; + const outputLines: string[] = []; + const seenFiles = new Set(); + for (const line of lines) { + const match = line.match(/^([^:]+):\d+:/); + if (match && !seenFiles.has(match[1])) { + if (seenFiles.size >= maxResults) break; + seenFiles.add(match[1]); + } + outputLines.push(line); + if (outputLines.length >= 200) break; + } + const truncated = outputLines.length < lines.length || seenFiles.size >= maxResults; + return { + success: true, + matchCount: outputLines.filter((l) => l.match(/^\S+:\d+:/)).length, + truncated, + output: outputLines.join("\n"), + }; + } catch (error: any) { + if (error?.status === 1) { + return { success: true, matchCount: 0, truncated: false, output: "No matches found." }; + } + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + return tools; } diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 8f560b287..616818509 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -1926,6 +1926,27 @@ describe("createAgentChatService", () => { const sessionsWithAutomation = await service.listSessions(undefined, { includeAutomation: true }); expect(sessionsWithAutomation.length).toBe(1); }); + + it("does not expose completion summaries as the session summary before the chat is ended", async () => { + const { service } = createService(); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + writePersistedChatState(session.id, { + ...readPersistedChatState(session.id), + completion: { + status: "completed", + summary: "Wrapped up the first pass and proposed a follow-up.", + }, + }); + + const sessions = await service.listSessions(); + expect(sessions[0]?.summary).toBeNull(); + }); }); describe("ensureIdentitySession", () => { @@ -2066,6 +2087,82 @@ describe("createAgentChatService", () => { expect(send).toHaveBeenCalledWith(expect.stringContaining("Assistant: Use the primary-hosted coordinator first.")); }); + it("reconstructs recent conversation tail for non-identity Claude sessions after resume", async () => { + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-non-identity", + slash_commands: [], + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + + yield { + type: "assistant", + session_id: "sdk-session-non-identity", + message: { + content: [{ type: "text", text: "We should keep the lane state intact." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-non-identity", + setPermissionMode, + } as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + const persisted = readPersistedChatState(session.id); + writePersistedChatState(session.id, { + ...persisted, + recentConversationEntries: [ + { role: "user", text: "Can you keep the lane warm?" }, + { role: "assistant", text: "Yes, I will keep the lane session alive." }, + ], + }); + + const resumed = createService().service; + await resumed.resumeSession({ sessionId: session.id }); + await new Promise((resolve) => setTimeout(resolve, 20)); + send.mockClear(); + + const result = await resumed.runSessionTurn({ + sessionId: session.id, + text: "What changed?", + timeoutMs: 15_000, + }); + + expect(result.sessionId).toBe(session.id); + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith(expect.stringContaining("Recent Conversation Tail")); + expect(send).toHaveBeenCalledWith(expect.stringContaining("User: Can you keep the lane warm?")); + expect(send).toHaveBeenCalledWith(expect.stringContaining("Assistant: Yes, I will keep the lane session alive.")); + expect(send).not.toHaveBeenCalledWith(expect.stringContaining("Continuity Summary")); + }); + it("persists a continuity snapshot and prewarms a fresh Claude session after identity session reset errors", async () => { const primarySend = vi.fn().mockResolvedValue(undefined); const recoverySend = vi.fn().mockResolvedValue(undefined); @@ -2641,6 +2738,36 @@ describe("createAgentChatService", () => { // -------------------------------------------------------------------------- describe("dispose", () => { + it("only writes the persisted chat summary when the session is explicitly disposed", async () => { + vi.mocked(streamText).mockReturnValue({ + fullStream: (async function* () { + yield { type: "finish", totalUsage: { inputTokens: 1, outputTokens: 1 } }; + })(), + } as any); + + const { service, sessionService } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "opencode", + model: "", + modelId: "opencode/anthropic/claude-sonnet-4-6", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Investigate the flaky login tests", + }); + + expect(sessionService.setSummary).not.toHaveBeenCalled(); + + await service.dispose({ sessionId: session.id }); + + expect(sessionService.setSummary).toHaveBeenCalledWith( + session.id, + expect.stringContaining("Session closed"), + ); + }); + it("disposes a session and marks it ended", async () => { const { service, sessionService } = createService(); const session = await service.createSession({ @@ -3211,6 +3338,76 @@ describe("createAgentChatService", () => { ])); }); + it("sends Codex image steer payloads as localImage input blocks", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Start working", + }, { awaitDispatch: true }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.event.type === "status" + && event.event.turnStatus === "started" + && event.event.turnId === "turn-1", + ); + + const imagePath = path.join(tmpRoot, "codex-steer-image.png"); + fs.writeFileSync(imagePath, "fake-image-bytes"); + + const result = await service.steer({ + sessionId: session.id, + text: "Use this screenshot while you keep going.", + attachments: [{ path: imagePath, type: "image" }], + }); + + expect(result.queued).toBe(false); + const steerRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/steer"); + expect(steerRequest).toBeTruthy(); + expect(steerRequest).toEqual(expect.objectContaining({ + method: "turn/steer", + params: expect.objectContaining({ + threadId: "thread-1", + expectedTurnId: "turn-1", + input: expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: "Use this screenshot while you keep going.", + }), + expect.objectContaining({ + type: "localImage", + path: expect.stringContaining("codex-steer-image.png"), + }), + ]), + }), + })); + + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event: expect.objectContaining({ + type: "user_message", + text: "Use this screenshot while you keep going.", + attachments: [{ path: imagePath, type: "image" }], + deliveryState: "delivered", + steerId: result.steerId, + turnId: "turn-1", + }), + }), + ])); + }); + it("marks active Codex subagents stopped on interrupt and ignores late child updates", async () => { const events: AgentChatEventEnvelope[] = []; const { service } = createService({ @@ -3433,7 +3630,7 @@ describe("createAgentChatService", () => { signal: new AbortController().signal, toolUseID: "tool-enter-plan", }); - expect(enterResult).toEqual({ behavior: "allow" }); + expect(enterResult).toMatchObject({ behavior: "allow" }); const entered = await service.getSessionSummary(sessionId); expect(entered?.permissionMode).toBe("plan"); @@ -4209,9 +4406,8 @@ describe("createAgentChatService", () => { try { const events: AgentChatEventEnvelope[] = []; let primaryStreamCall = 0; - let primaryClosed = false; + let releaseInterruptedTurn = false; const primarySend = vi.fn().mockResolvedValue(undefined); - const resumedSend = vi.fn().mockResolvedValue(undefined); const setPermissionMode = vi.fn().mockResolvedValue(undefined); const primarySession = { @@ -4241,40 +4437,32 @@ describe("createAgentChatService", () => { }, }; - while (!primaryClosed) { - await new Promise((resolve) => setTimeout(resolve, 1_000)); + if (primaryStreamCall === 2) { + while (!releaseInterruptedTurn) { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + return; } - throw new Error("aborted by user"); - })()), - close: vi.fn(() => { - primaryClosed = true; - }), - sessionId: "sdk-session-1", - setPermissionMode, - }; - - const resumedSession = { - send: resumedSend, - stream: vi.fn(() => (async function* () { - yield { - type: "system", - subtype: "init", - session_id: "sdk-session-1", - slash_commands: [], - }; - yield { - type: "assistant", - session_id: "sdk-session-1", - message: { - content: [{ type: "text", text: "You were asking about the new chat buttons." }], + if (primaryStreamCall === 3) { + yield { + type: "assistant", + session_id: "sdk-session-1", + message: { + content: [{ type: "text", text: "You were asking about the new chat buttons." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", usage: { input_tokens: 1, output_tokens: 1 }, - }, - }; - yield { - type: "result", - usage: { input_tokens: 1, output_tokens: 1 }, - }; + }; + return; + } + + while (true) { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } })()), close: vi.fn(), sessionId: "sdk-session-1", @@ -4282,7 +4470,6 @@ describe("createAgentChatService", () => { }; vi.mocked(unstable_v2_createSession).mockReturnValue(primarySession as any); - vi.mocked(unstable_v2_resumeSession).mockReturnValue(resumedSession as any); const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event), @@ -4303,6 +4490,11 @@ describe("createAgentChatService", () => { .then(() => null as Error | null) .catch((error) => error instanceof Error ? error : new Error(String(error))); await vi.advanceTimersByTimeAsync(120_000); + expect(events.find((event) => + event.event.type === "status" && event.event.turnStatus === "interrupted", + )).toBeDefined(); + releaseInterruptedTurn = true; + await vi.advanceTimersByTimeAsync(1_000); const timeoutError = await firstTurnError; expect(timeoutError?.message ?? "").toMatch(/Timed out waiting for session .* The turn was interrupted, but the chat stayed open\./i); @@ -4320,11 +4512,8 @@ describe("createAgentChatService", () => { timeoutMs: 15_000, }); - expect(unstable_v2_resumeSession).toHaveBeenCalledWith( - "sdk-session-1", - expect.objectContaining({ model: "sonnet" }), - ); - expect(resumedSend).toHaveBeenCalledTimes(1); + expect(unstable_v2_resumeSession).not.toHaveBeenCalled(); + expect(primarySend).toHaveBeenCalledTimes(3); expect(followUp.outputText).toContain("new chat buttons"); } finally { vi.useRealTimers(); @@ -4406,7 +4595,10 @@ describe("createAgentChatService", () => { timeoutMs: 500_000, }); - await vi.advanceTimersByTimeAsync(361_000); + for (let index = 0; index < 6; index += 1) { + await vi.advanceTimersByTimeAsync(60_000); + } + await vi.advanceTimersByTimeAsync(1_000); const result = await turn; expect(result.outputText).toContain("Finished after a long run."); @@ -4673,6 +4865,90 @@ describe("createAgentChatService", () => { await expect(sendPromise).resolves.toBeUndefined(); }); + it("emits a single interrupted status and done event without closing the Claude session", async () => { + const events: AgentChatEventEnvelope[] = []; + let streamCall = 0; + let warmupComplete = false; + let hangResolve: (() => void) | null = null; + const hangPromise = new Promise((resolve) => { hangResolve = resolve; }); + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const close = vi.fn(); + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { type: "system", subtype: "init", session_id: "sdk-single-interrupt", slash_commands: [] }; + warmupComplete = true; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + return; + } + + yield { + type: "stream_event", + event: { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "still working" }, + }, + }; + await hangPromise; + return; + })()); + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send, + stream, + close, + sessionId: "sdk-single-interrupt", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await vi.waitFor(() => { + expect(warmupComplete).toBe(true); + }); + + const sendPromise = service.sendMessage({ + sessionId: session.id, + text: "Please keep working", + }); + + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => event.event.type === "text", + ); + + await service.interrupt({ sessionId: session.id }); + + const interruptedStatuses = events.filter( + (event) => event.event.type === "status" && event.event.turnStatus === "interrupted", + ); + const interruptedDone = events.filter( + (event) => event.event.type === "done" && event.event.status === "interrupted", + ); + expect(interruptedStatuses).toHaveLength(1); + expect(interruptedDone).toHaveLength(1); + expect(close).not.toHaveBeenCalled(); + + hangResolve!(); + await expect(sendPromise).resolves.toBeUndefined(); + + expect(events.filter( + (event) => event.event.type === "status" && event.event.turnStatus === "interrupted", + )).toHaveLength(1); + expect(events.filter( + (event) => event.event.type === "done" && event.event.status === "interrupted", + )).toHaveLength(1); + }); + }); // -------------------------------------------------------------------------- @@ -4945,6 +5221,87 @@ describe("createAgentChatService", () => { ); expect(deliveredWithUpdatedText).toBeUndefined(); }); + + it("sends Claude image follow-ups as SDK user messages after an earlier text turn", async () => { + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-1", + slash_commands: [], + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + + yield { + type: "assistant", + message: { + content: [{ type: "text", text: streamCall === 2 ? "First turn done" : "Follow-up done" }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + + const mockSession = { + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-1", + setPermissionMode, + }; + + vi.mocked(unstable_v2_createSession).mockReturnValue(mockSession as any); + vi.mocked(unstable_v2_resumeSession).mockReturnValue(mockSession as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + const imagePath = path.join(tmpRoot, "follow-up.png"); + fs.writeFileSync(imagePath, "fake-image-bytes"); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Start with text only", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Now use this screenshot", + attachments: [{ path: imagePath, type: "image" }], + }); + + expect(send).toHaveBeenCalledTimes(3); + expect(String(send.mock.calls[1]?.[0] ?? "")).toContain("Start with text only"); + + const followUpPayload = send.mock.calls[2]?.[0] as Record; + expect(followUpPayload.type).toBe("user"); + expect(followUpPayload.session_id).toBe("sdk-session-1"); + expect(followUpPayload.parent_tool_use_id).toBeNull(); + + const message = followUpPayload.message as { role: string; content: Array> }; + expect(message.role).toBe("user"); + expect(message.content[0]?.type).toBe("text"); + expect(String(message.content[0]?.text ?? "")).toContain("Now use this screenshot"); + expect(message.content[1]?.type).toBe("image"); + expect((message.content[1]?.source as Record).type).toBe("base64"); + }); }); // -------------------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 4c50b55c0..a1af63518 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -4,7 +4,7 @@ import fs from "node:fs"; import path from "node:path"; import readline from "node:readline"; import { unstable_v2_createSession, unstable_v2_resumeSession } from "@anthropic-ai/claude-agent-sdk"; -import type { Query as ClaudeSDKQuery, SDKMessage, SDKUserMessage, Options as ClaudeSDKOptions, PermissionResult as ClaudePermissionResult } from "@anthropic-ai/claude-agent-sdk"; +import type { SDKMessage, SDKUserMessage, Options as ClaudeSDKOptions, PermissionResult as ClaudePermissionResult } from "@anthropic-ai/claude-agent-sdk"; type ClaudeV2Session = { send: (msg: string | Partial) => Promise; @@ -311,13 +311,17 @@ type CodexRuntime = { planModeFallbackNotified: boolean; }; +type QueuedSteer = { + steerId: string; + text: string; + attachments: AgentChatFileRef[]; + resolvedAttachments: ResolvedAgentChatFileRef[]; +}; + type ClaudeRuntime = { kind: "claude"; sdkSessionId: string | null; - activeQuery: ClaudeSDKQuery | null; v2Session: ClaudeV2Session | null; - /** Single stream generator kept alive across turns (never closed by for-await). */ - v2StreamGen: AsyncGenerator | null; /** Resolves when the subprocess is initialized (system:init received). */ v2WarmupDone: Promise | null; /** Resolves the current warmup race so waiters can stop blocking immediately. */ @@ -328,9 +332,11 @@ type ClaudeRuntime = { slashCommands: Array<{ name: string; description: string; argumentHint?: string }>; busy: boolean; activeTurnId: string | null; - pendingSteers: Array<{ steerId: string; text: string }>; + pendingSteers: QueuedSteer[]; approvals: Map; interrupted: boolean; + /** Set when early interrupt events have been emitted to avoid duplicate emission later. */ + interruptEventsEmitted: boolean; /** Set when a reasoning effort change is requested mid-turn; flushed when idle. */ pendingSessionReset?: boolean; turnMemoryPolicyState: TurnMemoryPolicyState | null; @@ -360,7 +366,7 @@ type OpenCodeRuntime = { activeTurnId: string | null; permissionMode: PermissionMode; pendingApprovals: Map; - pendingSteers: Array<{ steerId: string; text: string }>; + pendingSteers: QueuedSteer[]; interrupted: boolean; modelDescriptor: ModelDescriptor; textByPartId: Map; @@ -385,7 +391,7 @@ type CursorRuntime = { modelConfigId: string | null; currentModelId: string | null; availableModelIds: string[]; - pendingSteers: Array<{ steerId: string; text: string }>; + pendingSteers: QueuedSteer[]; permissionWaiters: Map; modeConfigId: string | null; currentModeId: string | null; @@ -875,6 +881,7 @@ const DEFAULT_COLLABORATION_MODES_LIST_TIMEOUT_MS = 1_500; const SESSION_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes const SESSION_CLEANUP_INTERVAL_MS = 60 * 1000; // check every 60 seconds const MAX_CONCURRENT_ACTIVE_RUNTIMES = 5; +const MAX_RECENT_CONVERSATION_ENTRIES = 50; const MAX_SESSION_MAP_ENTRIES = 200; function evictOldestEntries(map: Map, maxSize: number): void { @@ -2996,7 +3003,7 @@ export function createAgentChatService(args: { turnId: runtime.activeTurnId ?? undefined, }); } - return { behavior: "allow" }; + return { behavior: "allow", updatedInput: input }; } // ── ExitPlanMode interception ── @@ -3029,7 +3036,7 @@ export function createAgentChatService(args: { applyClaudePlanModeTransition(managed.session, "default"); persistChatState(managed); } - return { behavior: "allow" }; + return { behavior: "allow", updatedInput: input }; } const inputRecord = (input && typeof input === "object" && !Array.isArray(input)) ? input as Record : {}; @@ -3145,12 +3152,12 @@ export function createAgentChatService(args: { if (toolName === "AskUserQuestion") { if (hasClaudeAskUserAnswers(input)) { - return { behavior: "allow" }; + return { behavior: "allow", updatedInput: input }; } const request = buildClaudeAskUserPendingRequest(runtime, input, sdkOptions); if (!request) { - return { behavior: "allow" }; + return { behavior: "allow", updatedInput: input }; } const approvalItemId = request.itemId ?? request.requestId; @@ -3219,7 +3226,7 @@ export function createAgentChatService(args: { if (isMemorySearchToolName(toolName) && state) { state.explicitSearchPerformed = true; state.orientationSatisfied = true; - return { behavior: "allow" }; + return { behavior: "allow", updatedInput: input }; } if (state && state.classification === "required" && !state.orientationSatisfied && !state.explicitSearchPerformed) { if (isClaudeMutatingToolCall(toolName, input)) { @@ -3235,7 +3242,7 @@ export function createAgentChatService(args: { // Check session-wide overrides — user already said "Allow for Session" for this tool const normalizedForOverride = normalizeToolNameForApproval(toolName); if (runtime.approvalOverrides.has(normalizedForOverride)) { - return { behavior: "allow" }; + return { behavior: "allow", updatedInput: input }; } const approvalItemId = randomUUID(); @@ -3296,6 +3303,7 @@ export function createAgentChatService(args: { if (approved) { return { behavior: "allow", + updatedInput: input, ...(response.decision === "accept_for_session" && sdkOptions?.suggestions?.length ? { updatedPermissions: sdkOptions.suggestions } : {}), @@ -3310,7 +3318,7 @@ export function createAgentChatService(args: { }; } - return { behavior: "allow" }; + return { behavior: "allow", updatedInput: input }; }; const clearSubagentSnapshots = (sessionId: string): void => { @@ -3902,7 +3910,7 @@ export function createAgentChatService(args: { return selectPreferredReasoningTier(supported); }; - const buildRecentConversationContext = (managed: ManagedChatSession, limit = 6): string => { + const buildRecentConversationContext = (managed: ManagedChatSession, limit = 20): string => { const liveEntries = managed.recentConversationEntries.map((entry) => `${entry.role === "user" ? "User" : "Assistant"}: ${entry.text}`, ); @@ -4013,15 +4021,15 @@ export function createAgentChatService(args: { } managed.recentConversationEntries.push({ role, text, turnId }); - if (managed.recentConversationEntries.length > 12) { - managed.recentConversationEntries.splice(0, managed.recentConversationEntries.length - 12); + if (managed.recentConversationEntries.length > MAX_RECENT_CONVERSATION_ENTRIES) { + managed.recentConversationEntries.splice( + 0, + managed.recentConversationEntries.length - MAX_RECENT_CONVERSATION_ENTRIES, + ); } }; - const refreshReconstructionContext = ( - managed: ManagedChatSession, - options?: { includeConversationTail?: boolean }, - ): void => { + const refreshReconstructionContext = (managed: ManagedChatSession): void => { const sections: string[] = []; if (managed.session.identityKey === "cto" && ctoStateService) { @@ -4044,11 +4052,9 @@ export function createAgentChatService(args: { ].join("\n")); } - if (options?.includeConversationTail) { - const recentConversation = buildRecentConversationContext(managed); - if (recentConversation.length) { - sections.push(["Recent Conversation Tail", recentConversation].join("\n")); - } + const recentConversation = buildRecentConversationContext(managed); + if (recentConversation.length) { + sections.push(["Recent Conversation Tail", recentConversation].join("\n")); } const nextContext = sections.map((section) => section.trim()).filter((section) => section.length > 0).join("\n\n"); @@ -4782,7 +4788,7 @@ export function createAgentChatService(args: { || managed.runtime?.kind === "cursor") ) { teardownRuntime(managed, "project_close"); - refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); + refreshReconstructionContext(managed); } return launchContext; }; @@ -5111,10 +5117,6 @@ export function createAgentChatService(args: { if (report.summary.trim().length > 0) { setSessionPreview(managed, report.summary); } - const summary = report.status === "completed" - ? report.summary - : `${report.status}: ${report.summary}`; - sessionService.setSummary(managed.session.id, clipText(summary, 360)); persistChatState(managed); }; @@ -5185,25 +5187,10 @@ export function createAgentChatService(args: { if (event.report.summary.trim().length > 0) { setSessionPreview(managed, event.report.summary); } - const summary = event.report.status === "completed" - ? event.report.summary - : `${event.report.status}: ${event.report.summary}`; - sessionService.setSummary(managed.session.id, clipText(summary, 360)); } - if (event.type === "done") { - // Only set a fallback summary if no completion_report already provided one. - const hasCompletionSummary = managed.session.completion?.summary?.trim().length; - if (!hasCompletionSummary) { - const preview = managed.preview?.trim() ?? ""; - const summary = preview.length - ? (event.status === "completed" ? preview : `${event.status}: ${preview}`) - : (event.status === "completed" ? "Response ready" : `Turn ${event.status}`); - sessionService.setSummary(managed.session.id, summary); - } - // Fire AI-enhanced summary after each completed turn (not just on session end). - void maybeGenerateSessionSummary(managed, null); - } + // Session summaries are generated only when the chat is explicitly ended in ADE, + // so "done" events intentionally do not produce a summary here. const envelope: AgentChatEventEnvelope = { sessionId: managed.session.id, @@ -5640,11 +5627,8 @@ export function createAgentChatService(args: { // Mark interrupted so the streaming catch block takes the graceful path managed.runtime.interrupted = true; cancelClaudeWarmup(managed, managed.runtime, "teardown"); - managed.runtime.activeQuery?.close(); - managed.runtime.activeQuery = null; try { managed.runtime.v2Session?.close(); } catch { /* ignore */ } managed.runtime.v2Session = null; - managed.runtime.v2StreamGen = null; managed.runtime.v2WarmupDone = null; managed.runtime.activeSubagents.clear(); for (const pending of managed.runtime.approvals.values()) { @@ -6027,7 +6011,7 @@ export function createAgentChatService(args: { managed.todoItems = readLatestTranscriptTodoItems(managed); normalizeSessionNativePermissionControls(managed.session, resolveChatConfig()); managed.transcriptLimitReached = managed.transcriptBytesWritten >= MAX_CHAT_TRANSCRIPT_BYTES; - refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); + refreshReconstructionContext(managed); managedSessions.set(sessionId, managed); return managed; @@ -6325,6 +6309,7 @@ export function createAgentChatService(args: { runtime.busy = true; runtime.activeTurnId = turnId; runtime.interrupted = false; + runtime.interruptEventsEmitted = false; runtime.resolvedToolUseIds.clear(); setSessionActive(managed); @@ -6524,6 +6509,7 @@ export function createAgentChatService(args: { // image content blocks (streaming input format per SDK docs). const messageToSend = buildClaudeV2Message(basePromptText, resolvedAttachments, { baseDir: managed.laneWorktreePath, + sessionId: runtime.sdkSessionId, }); const turnPermissionMode = resolveClaudeTurnPermissionMode(managed); @@ -6629,7 +6615,7 @@ export function createAgentChatService(args: { }); } void maybeRefreshIdentityContinuitySummary(managed, "compaction"); - refreshReconstructionContext(managed, { includeConversationTail: true }); + refreshReconstructionContext(managed); } continue; } @@ -7135,7 +7121,6 @@ export function createAgentChatService(args: { runtime.resumeIdleWatchdog = null; flushOpenClaudeToolUses(runtime.interrupted ? "interrupted" : "completed"); // Note: v2Session is NOT closed here — it stays alive for the next turn - runtime.activeQuery = null; runtime.busy = false; runtime.activeTurnId = null; runtime.turnMemoryPolicyState = null; @@ -7153,16 +7138,20 @@ export function createAgentChatService(args: { const doneModel = buildDoneModelPayload(); const finalStatus = runtime.interrupted ? "interrupted" : "completed"; - emitChatEvent(managed, { type: "status", turnStatus: finalStatus, turnId }); - void emitTurnDiffSummaryIfChanged(managed, turnId); - emitChatEvent(managed, { - type: "done", - turnId, - status: finalStatus, - ...doneModel, - ...(usage ? { usage } : {}), - ...(costUsd != null ? { costUsd } : {}), - }); + if (!runtime.interruptEventsEmitted) { + emitChatEvent(managed, { type: "status", turnStatus: finalStatus, turnId }); + void emitTurnDiffSummaryIfChanged(managed, turnId); + emitChatEvent(managed, { + type: "done", + turnId, + status: finalStatus, + ...doneModel, + ...(usage ? { usage } : {}), + ...(costUsd != null ? { costUsd } : {}), + }); + } else { + void emitTurnDiffSummaryIfChanged(managed, turnId); + } if (assistantText.trim().length > 0) { appendWorkerActivityToCto(managed, { @@ -7186,7 +7175,6 @@ export function createAgentChatService(args: { clearClaudeTurnTimers(); runtime.pauseIdleWatchdog = null; runtime.resumeIdleWatchdog = null; - runtime.activeQuery = null; runtime.busy = false; runtime.activeTurnId = null; runtime.turnMemoryPolicyState = null; @@ -7197,23 +7185,27 @@ export function createAgentChatService(args: { : "failed"; flushOpenClaudeToolUses(finalToolStatus); - // Close V2 session on error so the next turn starts fresh - try { runtime.v2Session?.close(); } catch { /* ignore */ } - runtime.v2Session = null; - runtime.v2StreamGen = null; - runtime.v2WarmupDone = null; + // Only close V2 session on genuine errors. Interrupted turns keep the + // session alive for the next send()+stream() cycle. + if (!runtime.interrupted) { + try { runtime.v2Session?.close(); } catch { /* ignore */ } + runtime.v2Session = null; + runtime.v2WarmupDone = null; + } const doneModel = buildDoneModelPayload(); void emitTurnDiffSummaryIfChanged(managed, turnId); if (runtime.interrupted) { markSessionIdleWithFreshCache(managed); - emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); - emitChatEvent(managed, { - type: "done", - turnId, - status: "interrupted", - ...doneModel, - }); + if (!runtime.interruptEventsEmitted) { + emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); + emitChatEvent(managed, { + type: "done", + turnId, + status: "interrupted", + ...doneModel, + }); + } } else if (timeoutError) { markSessionIdleWithFreshCache(managed); const errorMessage = effectiveError instanceof Error ? effectiveError.message : String(effectiveError); @@ -7239,13 +7231,15 @@ export function createAgentChatService(args: { // System-triggered abort (dispose/teardown) that wasn't flagged as interrupted. // Treat as interruption to avoid surfacing raw SDK messages like "aborted by user". markSessionIdleWithFreshCache(managed); - emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); - emitChatEvent(managed, { - type: "done", - turnId, - status: "interrupted", - ...doneModel, - }); + if (!runtime.interruptEventsEmitted) { + emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); + emitChatEvent(managed, { + type: "done", + turnId, + status: "interrupted", + ...doneModel, + }); + } } else { markSessionIdleWithFreshCache(managed); const isAuthFailure = isClaudeRuntimeAuthError(effectiveError); @@ -7290,7 +7284,7 @@ export function createAgentChatService(args: { managed.runtimeInvalidated = true; clearLaneDirectiveKey(managed); void maybeRefreshIdentityContinuitySummary(managed, "provider_reset"); - refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); + refreshReconstructionContext(managed); prewarmClaudeV2Session(managed); } } @@ -9896,22 +9890,24 @@ export function createAgentChatService(args: { await runClaudeTurn(managed, { promptText, displayText: trimmed, - attachments: [], + attachments: nextSteer.attachments, + resolvedAttachments: nextSteer.resolvedAttachments, laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null, }); } else if (runtime.kind === "cursor") { await runCursorTurn(managed, { promptText, displayText: trimmed, - attachments: [], - resolvedAttachments: [], + attachments: nextSteer.attachments, + resolvedAttachments: nextSteer.resolvedAttachments, laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null, }); } else { await runTurn(managed, { promptText, displayText: trimmed, - attachments: [], + attachments: nextSteer.attachments, + resolvedAttachments: nextSteer.resolvedAttachments, laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null, }); } @@ -9926,6 +9922,8 @@ export function createAgentChatService(args: { sessionId: string, steerId: string, text: string, + attachments: AgentChatFileRef[] = [], + resolvedAttachments: ResolvedAgentChatFileRef[] = [], ): boolean => { if (runtime.pendingSteers.length >= MAX_PENDING_STEERS) { logger.warn("agent_chat.steer_queue_full", { sessionId, queueSize: runtime.pendingSteers.length }); @@ -9937,10 +9935,11 @@ export function createAgentChatService(args: { }); return false; } - runtime.pendingSteers.push({ steerId, text }); + runtime.pendingSteers.push({ steerId, text, attachments, resolvedAttachments }); emitChatEvent(managed, { type: "user_message", text, + ...(attachments.length ? { attachments } : {}), steerId, turnId: runtime.activeTurnId ?? undefined, deliveryState: "queued", @@ -10127,9 +10126,7 @@ export function createAgentChatService(args: { const runtime: ClaudeRuntime = { kind: "claude", sdkSessionId, - activeQuery: null, v2Session: null, - v2StreamGen: null, v2WarmupDone: null, v2WarmupCancel: null, v2WarmupCancelled: false, @@ -10140,6 +10137,7 @@ export function createAgentChatService(args: { pendingSteers: [], approvals: new Map(), interrupted: false, + interruptEventsEmitted: false, turnMemoryPolicyState: null, approvalOverrides: new Set(persisted?.approvalOverrides ?? []), pendingElicitations: new Map void>(), @@ -10556,7 +10554,7 @@ export function createAgentChatService(args: { }; normalizeSessionNativePermissionControls(managed.session, resolveChatConfig()); managed.transcriptLimitReached = managed.transcriptBytesWritten >= MAX_CHAT_TRANSCRIPT_BYTES; - refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); + refreshReconstructionContext(managed); // Init dedicated chat transcript file for persistence try { @@ -10751,7 +10749,7 @@ export function createAgentChatService(args: { managed.closed = false; managed.endedNotified = false; managed.ctoSessionStartedAt = managed.session.identityKey === "cto" ? nowIso() : null; - refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); + refreshReconstructionContext(managed); } if (managed.session.provider === "cursor" && managed.session.status === "active") { @@ -10839,7 +10837,6 @@ export function createAgentChatService(args: { if (managed.runtime?.kind === "claude" && !isBusyError) { managed.runtime.busy = false; managed.runtime.activeTurnId = null; - managed.runtime.activeQuery = null; } if (managed.runtime?.kind === "cursor" && !isBusyError) { managed.runtime.busy = false; @@ -11993,7 +11990,7 @@ export function createAgentChatService(args: { } }; - const steer = async ({ sessionId, text }: AgentChatSteerArgs): Promise => { + const steer = async ({ sessionId, text, attachments = [] }: AgentChatSteerArgs): Promise => { const trimmed = text.trim(); const steerId = randomUUID(); if (!trimmed.length) { @@ -12001,6 +11998,9 @@ export function createAgentChatService(args: { } const managed = ensureManagedSession(sessionId); + if (attachments.length && managed.session.provider !== "claude" && managed.session.provider !== "codex") { + throw new Error("Attachments are only supported for Claude and Codex follow-up messages right now."); + } // OpenCode runtime steer if (managed.runtime?.kind === "opencode") { @@ -12035,7 +12035,7 @@ export function createAgentChatService(args: { }); return { steerId, queued: false }; } - rt.pendingSteers.push({ steerId, text: trimmed }); + rt.pendingSteers.push({ steerId, text: trimmed, attachments: [], resolvedAttachments: [] }); emitChatEvent(managed, { type: "user_message", text: trimmed, @@ -12073,20 +12073,42 @@ export function createAgentChatService(args: { throw new Error("No active turn to steer."); } + const preparedSteer = prepareSendMessage({ + sessionId, + text: trimmed, + displayText: trimmed, + attachments, + }); + if (!preparedSteer) { + return { steerId, queued: false }; + } + + const input: Array> = [ + { + type: "text", + text: trimmed, + text_elements: [], + }, + ]; + for (const attachment of preparedSteer.resolvedAttachments) { + const stagedPath = stageAttachmentForCodexInput(attachment); + if (attachment.type === "image") { + input.push({ type: "localImage", path: stagedPath }); + continue; + } + const name = path.basename(attachment.path) || attachment.path; + input.push({ type: "mention", name, path: stagedPath }); + } + await runtime.request("turn/steer", { threadId: managed.session.threadId, expectedTurnId: runtime.activeTurnId, - input: [ - { - type: "text", - text: trimmed, - text_elements: [] - } - ] + input, }); emitChatEvent(managed, { type: "user_message", - text: trimmed, + text: preparedSteer.visibleText, + ...(preparedSteer.attachments.length ? { attachments: preparedSteer.attachments } : {}), steerId, deliveryState: "delivered", turnId: runtime.activeTurnId, @@ -12095,20 +12117,27 @@ export function createAgentChatService(args: { } const runtime = ensureClaudeSessionRuntime(managed); - if (runtime.busy) { - enqueueSteerOrDrop(managed, runtime, sessionId, steerId, trimmed); - return { steerId, queued: true }; - } - const preparedSteer = prepareSendMessage({ sessionId, text: trimmed, displayText: trimmed, - attachments: [], + attachments, }); if (!preparedSteer) { return { steerId, queued: false }; } + if (runtime.busy) { + enqueueSteerOrDrop( + managed, + runtime, + sessionId, + steerId, + preparedSteer.visibleText, + preparedSteer.attachments, + preparedSteer.resolvedAttachments, + ); + return { steerId, queued: true }; + } await executePreparedSendMessage(preparedSteer); return { steerId, queued: false }; }; @@ -12236,26 +12265,33 @@ export function createAgentChatService(args: { busy: runtime.busy, warmupInFlight: Boolean(runtime.v2WarmupDone), }); - // Set interrupted before closing the session so the streaming loop sees it - // and breaks cleanly rather than throwing from a closed session. + // Set interrupted before touching the runtime so the streaming loop can + // break cleanly without tearing the session down. runtime.interrupted = true; + const interruptedTurnId = runtime.activeTurnId; + if (runtime.busy && interruptedTurnId) { + runtime.interruptEventsEmitted = true; + runtime.busy = false; + runtime.activeTurnId = null; + emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId: interruptedTurnId }); + emitChatEvent(managed, { + type: "done", + turnId: interruptedTurnId, + status: "interrupted", + }); + } cancelClaudeWarmup(managed, runtime, "interrupt"); cancelQueuedSteers(managed, runtime, "interrupted"); - runtime.activeQuery?.interrupt().catch(() => {}); // Drain pending approvals so their promises settle instead of hanging forever for (const pending of runtime.approvals.values()) { pending.resolve({ decision: "cancel" }); } runtime.approvals.clear(); runtime.pendingElicitations.clear(); - // Close the V2 session on interrupt — it will be recreated on the next turn - try { runtime.v2Session?.close(); } catch { /* ignore */ } - runtime.v2Session = null; - runtime.v2StreamGen = null; // Emit subagent_result "stopped" for every active subagent so the UI // properly transitions them from "running" → "stopped" (matching Claude Code CLI behaviour). - const turnId = runtime.activeTurnId ?? undefined; + const turnId = interruptedTurnId ?? undefined; for (const { taskId } of runtime.activeSubagents.values()) { emitChatEvent(managed, { type: "subagent_result", @@ -12266,10 +12302,11 @@ export function createAgentChatService(args: { }); } runtime.activeSubagents.clear(); + persistChatState(managed); logger.info("agent_chat.turn_interrupt_completed", { sessionId, provider: "claude", - turnId: runtime.activeTurnId, + turnId: interruptedTurnId, busy: runtime.busy, }); }; @@ -12279,7 +12316,7 @@ export function createAgentChatService(args: { refreshManagedLaneLaunchContext(managed, { purpose: "resume this chat" }); const persisted = readPersistedState(sessionId); managed.session.capabilityMode = managed.session.capabilityMode ?? inferCapabilityMode(managed.session.provider); - refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); + refreshReconstructionContext(managed); if (managed.session.provider === "codex") { const runtime = await ensureCodexSessionRuntime(managed); @@ -12483,7 +12520,7 @@ export function createAgentChatService(args: { endedAt: row.endedAt, lastActivityAt: liveSession?.lastActivityAt ?? persisted?.updatedAt ?? row.endedAt ?? row.startedAt, lastOutputPreview: row.lastOutputPreview, - summary: row.summary ?? liveSession?.completion?.summary ?? persisted?.completion?.summary ?? null, + summary: row.summary ?? null, ...(hasLivePendingInput(liveManaged) ? { awaitingInput: true } : {}), ...(liveSession?.threadId || persisted?.threadId ? { threadId: liveSession?.threadId ?? persisted?.threadId } @@ -12554,7 +12591,7 @@ export function createAgentChatService(args: { enforceManagedLocalHarnessPermissionMode(managed); normalizeSessionNativePermissionControls(managed.session, resolveChatConfig()); managed.selectedExecutionLaneId = selectedExecutionLaneId ?? managed.selectedExecutionLaneId; - refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); + refreshReconstructionContext(managed); await refreshHeadShaStartForManagedExecutionLane(managed); persistChatState(managed); @@ -12620,7 +12657,7 @@ export function createAgentChatService(args: { const managed = ensureManagedSession(created.id); managed.selectedExecutionLaneId = selectedExecutionLaneId; - refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); + refreshReconstructionContext(managed); await refreshHeadShaStartForManagedExecutionLane(managed); persistChatState(managed); return managed.session; @@ -13083,6 +13120,8 @@ export function createAgentChatService(args: { if ( managed.runtime && !managed.closed + // Keep Claude sessions warm until the user explicitly ends them. + && managed.runtime.kind !== "claude" && now - managed.lastActivityTimestamp > SESSION_INACTIVITY_TIMEOUT_MS ) { teardownRuntime(managed, "idle_ttl"); @@ -13099,6 +13138,9 @@ export function createAgentChatService(args: { for (const [id, managed] of managedSessions) { if (id === excludeSessionId) continue; if (!managed.runtime) continue; + // Claude V2 runtimes keep their in-memory session state and should not be + // evicted behind the user's back. + if (managed.runtime.kind === "claude") continue; if (managed.lastActivityTimestamp < oldestTimestamp) { oldestTimestamp = managed.lastActivityTimestamp; oldest = managed; @@ -13172,7 +13214,7 @@ export function createAgentChatService(args: { if (managed.runtime && modelChanged) { teardownRuntime(managed, "model_switch"); - refreshReconstructionContext(managed, { includeConversationTail: true }); + refreshReconstructionContext(managed); } const currentTitle = sessionService.get(sessionId)?.title ?? null; @@ -13326,7 +13368,7 @@ export function createAgentChatService(args: { ) { managed.runtime.threadResumed = false; } - if (managed.runtime?.kind === "claude" && managed.runtime.v2Session && !managed.runtime.busy) { + if (managed.runtime?.kind === "claude" && managed.runtime.v2Session) { const turnPermissionMode = resolveClaudeTurnPermissionMode(managed); const control = getClaudeV2SessionControl(managed.runtime.v2Session); if (typeof control.setPermissionMode === "function") { @@ -13337,15 +13379,18 @@ export function createAgentChatService(args: { // bypassPermissions on a session not started with // --dangerously-skip-permissions), invalidate the V2 session // so it is recreated with the correct mode on the next turn. + // When busy, only log the failure — don't tear down the active session. logger.warn("agent_chat.v2_set_permission_mode_failed", { sessionId: managed.session.id, turnPermissionMode, error: String(permErr), }); - cancelClaudeWarmup(managed, managed.runtime, "session_reset"); - try { managed.runtime.v2Session?.close(); } catch { /* ignore */ } - managed.runtime.v2Session = null; - managed.runtime.v2WarmupDone = null; + if (!managed.runtime.busy) { + cancelClaudeWarmup(managed, managed.runtime, "session_reset"); + try { managed.runtime.v2Session?.close(); } catch { /* ignore */ } + managed.runtime.v2Session = null; + managed.runtime.v2WarmupDone = null; + } } } } @@ -13370,7 +13415,7 @@ export function createAgentChatService(args: { if (resetRuntimeForComputerUse && managed.runtime) { teardownRuntime(managed, "model_switch"); - refreshReconstructionContext(managed, { includeConversationTail: true }); + refreshReconstructionContext(managed); } if (title !== undefined) { diff --git a/apps/desktop/src/main/services/chat/buildClaudeV2Message.test.ts b/apps/desktop/src/main/services/chat/buildClaudeV2Message.test.ts index f412e6b93..2b74887c6 100644 --- a/apps/desktop/src/main/services/chat/buildClaudeV2Message.test.ts +++ b/apps/desktop/src/main/services/chat/buildClaudeV2Message.test.ts @@ -66,6 +66,8 @@ describe("buildClaudeV2Message", () => { expect(typeof result).toBe("object"); const msg = result as SDKUserMessagePartial; expect(msg.type).toBe("user"); + expect(msg.session_id).toBe(""); + expect(msg.parent_tool_use_id).toBeNull(); expect(msg.message.role).toBe("user"); // Should have two content blocks: text + image @@ -92,6 +94,8 @@ describe("buildClaudeV2Message", () => { const result = buildClaudeV2Message("Describe this image", attachments, { baseDir: tmpDir }); const msg = result as SDKUserMessagePartial; + expect(msg.session_id).toBe(""); + expect(msg.parent_tool_use_id).toBeNull(); const imgBlock = msg.message.content[1] as Record; const source = imgBlock.source as Record; @@ -110,6 +114,8 @@ describe("buildClaudeV2Message", () => { expect(typeof result).toBe("object"); const msg = result as SDKUserMessagePartial; + expect(msg.session_id).toBe(""); + expect(msg.parent_tool_use_id).toBeNull(); const content = msg.message.content; expect(content).toHaveLength(2); expect(content[0]).toEqual({ type: "text", text: "Look at this" }); @@ -133,6 +139,8 @@ describe("buildClaudeV2Message", () => { expect(typeof result).toBe("object"); const msg = result as SDKUserMessagePartial; + expect(msg.session_id).toBe(""); + expect(msg.parent_tool_use_id).toBeNull(); const content = msg.message.content; expect(content).toHaveLength(2); expect(content[0]).toEqual({ type: "text", text: "Check this diagram" }); @@ -157,6 +165,8 @@ describe("buildClaudeV2Message", () => { expect(typeof result).toBe("object"); const msg = result as SDKUserMessagePartial; + expect(msg.session_id).toBe(""); + expect(msg.parent_tool_use_id).toBeNull(); const content = msg.message.content; // 4 blocks: prompt text + file hint + image + file hint diff --git a/apps/desktop/src/main/services/chat/buildClaudeV2Message.ts b/apps/desktop/src/main/services/chat/buildClaudeV2Message.ts index 15fe11137..0cabfd0ad 100644 --- a/apps/desktop/src/main/services/chat/buildClaudeV2Message.ts +++ b/apps/desktop/src/main/services/chat/buildClaudeV2Message.ts @@ -66,6 +66,8 @@ export function inferAttachmentMediaType(attachment: AgentChatFileRef): string { */ export type SDKUserMessagePartial = { type: "user"; + session_id: string; + parent_tool_use_id: string | null; message: { role: "user"; content: Array>; @@ -81,7 +83,7 @@ export type SDKUserMessagePartial = { export function buildClaudeV2Message( promptText: string, attachments: ResolvedAgentChatFileRef[], - options: { baseDir?: string } = {}, + options: { baseDir?: string; sessionId?: string | null } = {}, ): string | SDKUserMessagePartial { const imageAttachments = attachments.filter((a) => a.type === "image"); if (!imageAttachments.length) { @@ -126,10 +128,13 @@ export function buildClaudeV2Message( } } - // Match the streaming input format from the SDK docs -- minimal fields, - // let the SDK fill in session_id, parent_tool_use_id, etc. + // Match the SDKUserMessage shape that session.send() accepts in V2. + // An empty session_id mirrors the SDK's own string-to-message conversion + // before the first init event establishes the concrete session ID. return { type: "user", + session_id: options.sessionId?.trim() ?? "", + parent_tool_use_id: null, message: { role: "user", content }, }; } diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index 00cd63894..6c5389111 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -2227,11 +2227,12 @@ function validateEffectiveConfig( issues.push({ path: `${p}.gracefulShutdownMs`, message: "gracefulShutdownMs must be > 0" }); } - const absCwd = path.isAbsolute(proc.cwd) ? proc.cwd : path.join(projectRoot, proc.cwd); - if (proc.cwd && !isDirectory(absCwd)) { - issues.push({ path: `${p}.cwd`, message: `cwd does not exist: ${proc.cwd}` }); - } else if (proc.cwd && !isPathWithinProjectRoot(projectRoot, absCwd)) { - issues.push({ path: `${p}.cwd`, message: `cwd must stay within the project root: ${proc.cwd}` }); + if (proc.cwd) { + try { + resolvePathWithinRoot(projectRoot, proc.cwd, { allowMissing: true }); + } catch { + issues.push({ path: `${p}.cwd`, message: `cwd must stay within the project root: ${proc.cwd}` }); + } } if (proc.readiness.type === "port") { diff --git a/apps/desktop/src/main/services/cto/ctoStateService.test.ts b/apps/desktop/src/main/services/cto/ctoStateService.test.ts index b5f183181..fa19af225 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.test.ts +++ b/apps/desktop/src/main/services/cto/ctoStateService.test.ts @@ -58,6 +58,7 @@ describe("ctoStateService", () => { expect(buildAdeGitignore()).not.toContain("cto/identity.yaml"); expect(buildAdeGitignore()).toContain("cto/core-memory.json"); expect(buildAdeGitignore()).toContain("cto/CURRENT.md"); + expect(buildAdeGitignore()).toContain("cto/openclaw-history.json"); fixture.db.close(); }); @@ -549,14 +550,15 @@ describe("ctoStateService", () => { expect(preview.sections[2]?.content).toContain("Immutable doctrine"); expect(preview.sections[2]?.content).toContain("Use memoryUpdateCore only when the standing project brief changes"); expect(preview.sections[2]?.content).toContain("Do not write ephemeral turn-by-turn status"); - // Knowledge section: ADE environment glossary, chat vs terminal disambiguation, task routing - expect(preview.sections[3]?.content).toContain("ADE environment glossary"); + // Knowledge section: ADE architecture, chat vs terminal disambiguation, task routing, model selection + expect(preview.sections[3]?.content).toContain("ADE Architecture"); expect(preview.sections[3]?.content).toContain("spawnChat"); expect(preview.sections[3]?.content).toContain("createTerminal"); expect(preview.sections[3]?.content).toContain("spawn_agent is an MCP tool"); - // Capabilities section: concrete tool list - expect(preview.sections[4]?.content).toContain("ADE operator tools"); - expect(preview.sections[4]?.content).toContain("listLanes, inspectLane, createLane"); + expect(preview.sections[3]?.content).toContain("Model Selection"); + // Capabilities section: organized tool reference with descriptions + expect(preview.sections[4]?.content).toContain("ADE Operator Tools"); + expect(preview.sections[4]?.content).toContain("listLanes"); expect(preview.sections[4]?.content).toContain("UI navigation is suggestion-only."); expect(preview.prompt).toContain("Immutable ADE doctrine"); expect(preview.prompt).toContain("Selected personality overlay"); diff --git a/apps/desktop/src/main/services/cto/ctoStateService.ts b/apps/desktop/src/main/services/cto/ctoStateService.ts index 94a4d3c03..033bd8bfc 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.ts +++ b/apps/desktop/src/main/services/cto/ctoStateService.ts @@ -67,8 +67,8 @@ const DURABLE_MEMORY_CATEGORY_ORDER: MemoryCategory[] = [ const IMMUTABLE_CTO_DOCTRINE = [ "You are the CTO for the current project inside ADE.", - "ADE is the local-first operating environment for this codebase. It manages lanes, chats, missions, workers, proofs, and connected systems like Linear.", - "You are not a generic assistant. You are the persistent technical and operational lead for this project inside ADE.", + "ADE (Autonomous Development Environment) is a local-first Electron desktop app that wraps your entire development workflow: git branching via lanes, AI chat sessions, terminal shells, PR management, mission orchestration, worker agents, conflict resolution, test execution, Linear integration, and more.", + "You are not a generic assistant. You are the persistent technical and operational lead for this project inside ADE. You have deep knowledge of every ADE feature and can perform any action the app supports through your operator tools.", "Answer identity questions as the project's CTO. Do not reframe yourself as Codex, Claude, or a detached chatbot.", "", "Your responsibilities:", @@ -77,6 +77,15 @@ const IMMUTABLE_CTO_DOCTRINE = [ "- Use ADE surfaces and delegation paths when they help move the project forward", "- Search the repo and project memory before asking the user for context that ADE already has", "- Be decisive when the tradeoff is clear, and escalate when a decision is risky or irreversible", + "- Execute user requests precisely — when a user asks for a specific model, lane, or configuration, honor the request exactly", + "- Proactively check project health, recent events, and worker status to stay aware of the project state", + "", + "Precision rules:", + "- When the user specifies a model (e.g. 'use opus', 'use gpt-5.4'), pass the exact modelId to spawnChat or other tools. Never silently fall back to a default.", + "- When the user asks to 'start a chat' or 'launch an agent', use spawnChat with the specified model and initial prompt. If the user explicitly asks for a terminal, CLI tool, or shell command, use createTerminal instead — both are valid, just match the intent.", + "- All ADE internals are fair game. The user can request any action: launching chats, opening terminals, running CLI tools, spawning agents, managing lanes, etc. Never refuse an action that ADE supports.", + "- When the user asks about something you can look up (lane status, PR checks, test results), call the tool first and report facts. Do not guess.", + "- When you are unsure which tool to use, consult the capability manifest in your system prompt before asking the user.", ].join("\n"); const CTO_MEMORY_OPERATING_MODEL = [ @@ -97,69 +106,335 @@ const CTO_MEMORY_OPERATING_MODEL = [ ].join("\n"); const CTO_ENVIRONMENT_KNOWLEDGE = [ - "ADE environment glossary:", - "- Lane: a git worktree with its own branch, directory, processes, and chat sessions. Lanes isolate parallel work streams. Tools: listLanes, inspectLane, createLane, deleteLane.", - "- Native ADE chat: a persistent chat session in the ADE UI with streaming, tool approval, and full service integration. Created with spawnChat. NOT a terminal.", - "- PTY terminal: a shell terminal session (may run any CLI command). Created with createTerminal. Indirect tool access, no ADE service integration.", - "- Mission: a structured task unit with runs, steps, and workers. Can be launched, steered, paused, and observed. Tools: startMission, launchMissionRun, steerMission, getMissionStatus.", - "- Worker: a named agent instance (engineer, QA, researcher) that runs in a lane executing missions or tasks. Tools: listWorkers, createWorker, wakeWorker, getWorkerStatus.", - "- Convergence: ADE's PR merge pipeline with automated validation, issue detection, and iterative resolution rounds. Tools: getPullRequestConvergence, startPullRequestConvergenceRound.", - "- Conflict resolution: ADE can predict, simulate, propose, and apply merge conflict resolutions. Tools: getConflictStatus, simulateMerge, requestConflictProposal, applyConflictProposal.", + "# ADE Architecture & Concepts", + "", + "## Core Concepts", + "", + "Lane: A git worktree with its own branch, working directory, processes, terminals, and chat sessions. Lanes isolate parallel work streams. Types:", + " - primary: the main checkout (repo root). Always exists.", + " - worktree: an isolated git worktree at .ade/worktrees//. Created by ADE for feature branches.", + " - attached: an external worktree the user linked to ADE.", + " Lanes have a parent (baseRef), can be stacked (child lanes), and carry metadata: color, icon, tags, status (dirty/ahead/behind/rebaseInProgress).", + " Tools: listLanes, inspectLane, createLane, deleteLane, renameLane, archiveLane.", + "", + "Native ADE Chat: A persistent AI chat session in the ADE UI with streaming responses, tool approval flow, file diff display, and full service integration. This is the primary way work gets done in ADE.", + " - Created with spawnChat({ laneId?, modelId?, reasoningEffort?, title?, initialPrompt? }).", + " - Supports any registered model (Claude, GPT, local models).", + " - Has a message composer with slash commands, file attachments, model selector.", + " - Chat sessions belong to a lane and can be listed, steered, interrupted, or ended.", + "", + "PTY Terminal: A shell terminal session (runs any CLI command). Created with createTerminal({ laneId, title?, startupCommand? }). No ADE tool integration — use for raw shell commands only.", + "", + "Mission: A structured, multi-step task unit with planning, execution runs, workers, and artifacts. Missions break down complex work into phases and steps.", + " - Lifecycle: draft → queued → planning → in_progress → completed/failed/cancelled.", + " - Missions can require intervention (human review at checkpoints).", + " - Tools: listMissions, startMission, getMissionStatus, launchMissionRun, steerMission, updateMission.", + "", + "Worker: A named agent instance (engineer, QA, researcher, etc.) that runs in a lane executing missions or tasks autonomously.", + " - Workers have a budget, heartbeat, and can be woken with specific tasks.", + " - Status: idle, active, running, paused.", + " - Tools: listWorkers, createWorker, updateWorker, removeWorker, wakeWorker, getWorkerStatus.", + "", + "Convergence: ADE's automated PR merge pipeline with validation, issue detection (CI failures, review threads, comments), and iterative AI resolution rounds.", + " - Tracks issues per PR with severity levels and automated fix attempts.", + " - Tools: getPullRequestConvergence, startPullRequestConvergenceRound, stopPullRequestConvergence, updatePullRequestConvergencePipeline.", + "", + "Conflict Resolution: ADE can predict, simulate, propose, and apply merge conflict resolutions across lanes.", + " - Risk matrix shows potential conflicts before they happen.", + " - AI-generated proposals can be applied or undone.", + " - Tools: getConflictStatus, getConflictRiskMatrix, simulateMerge, requestConflictProposal, applyConflictProposal.", + "", + "## ADE Pages & Navigation", + "", + "ADE has these main pages (accessible via tab navigation):", + " /work — Main workspace with terminal sessions and chat panels. This is where active development happens.", + " /lanes — Lane browser showing all lanes, their status, git actions, diffs, stacks, and PR panels.", + " /files — File explorer for browsing and editing project files.", + " /prs — Pull request management: list, detail view, convergence, queue, GitHub integration.", + " /missions — Mission control center: create missions, monitor runs, view artifacts and logs.", + " /cto — CTO settings page: your identity, core memory, team/workers, Linear integration.", + " /graph — Workspace dependency graph visualization showing lane relationships.", + " /history — Operation history timeline showing all past actions.", + " /automations — Automation rule builder: create rules triggered by events (PR opened, test failed, etc.).", + " /settings — App settings: AI providers, GitHub token, Linear integration, keybindings, usage budgets, MCP servers.", + " When an action should be opened in ADE, return a navigation suggestion. Never silently switch tabs.", + "", + "## Model Selection", "", - "Critical distinction — chats vs terminals:", - "- To launch a native ADE chat session: call spawnChat({ laneId?, title?, initialPrompt? }) directly. Do NOT use ToolSearch to find it — just call it. This creates a full ADE work chat with UI, streaming, tool approval, and supervision.", - "- To open a terminal: call createTerminal({ laneId, title?, startupCommand? }) directly.", - "- spawn_agent is an MCP tool for Claude CLI subprocesses in tracked terminals. It is NOT the same as spawnChat. Never use spawn_agent when the user asks for 'a chat' or 'a new session'.", + "ADE supports multiple AI providers and models. When spawning chats or configuring workers, use the correct modelId:", + " Anthropic models (via Claude CLI): anthropic/claude-opus-4-6 (shortId: opus), anthropic/claude-sonnet-4-6 (shortId: sonnet), anthropic/claude-haiku-4-5 (shortId: haiku).", + " OpenAI models (via Codex CLI): openai/gpt-5.4-codex (shortId: gpt-5.4-codex), openai/gpt-5.4-mini-codex, openai/gpt-5.3-codex, openai/gpt-5.3-codex-spark, openai/gpt-5.2-codex, openai/gpt-5.1-codex-max, openai/gpt-5.1-codex-mini.", + " Local models: ollama/llama-3.3, lmstudio/* (discovered at runtime).", + " Reasoning effort (for supported models): low, medium, high, max (opus), xhigh (openai).", + " IMPORTANT: When the user says 'use opus' → modelId: 'anthropic/claude-opus-4-6'. 'Use sonnet' → 'anthropic/claude-sonnet-4-6'. 'Use gpt-5.4' → 'openai/gpt-5.4-codex'. Always pass the full modelId, never just the shortId, to spawnChat and other tools.", + "", + "## Critical Distinctions", + "", + "Chats vs Terminals — both are valid, match the user's intent:", + " - spawnChat: Creates a native ADE chat session with AI, streaming, tool approval, and service integration. Use when the user wants an AI agent, a chat, or AI-powered work.", + " - createTerminal: Opens a shell (PTY) for raw CLI commands. Use when the user wants a terminal, shell, or to run a specific CLI tool.", + " - spawn_agent is an MCP tool for Claude CLI subprocesses in tracked terminals. It differs from spawnChat — when the user says 'start a chat' or 'launch an agent', prefer spawnChat. But if the user explicitly wants a CLI agent or terminal-based tool, createTerminal or spawn_agent are fine.", + " - Example: 'Launch a chat with opus' → spawnChat({ modelId: 'anthropic/claude-opus-4-6', ... }). 'Open a terminal' → createTerminal. 'Run npm test' → createTerminal({ startupCommand: 'npm test' }).", "", "Tool calling convention:", - "- ADE tools are available as MCP tools. When you see them in your tool list, they may be prefixed (e.g., mcp__ade__spawnChat). Call them directly by name — do not search for them with ToolSearch.", - "- If a tool from the manifest below is not in your immediate tool list, it may still be available. Try calling it directly before concluding it does not exist.", + " - ADE tools are available as MCP tools. They may be prefixed (e.g., mcp__ade__spawnChat). Call them directly by name.", + " - If a tool from the manifest below is not in your immediate tool list, try calling it directly before concluding it does not exist.", + "", + "## PR Lifecycle in ADE", + "", + " 1. Create a lane for the feature branch (createLane).", + " 2. Work in the lane (spawnChat with initialPrompt, or manually via terminals).", + " 3. Commit and push (gitCommit, gitPush).", + " 4. Create PR from lane (createPrFromLane).", + " 5. Monitor PR status (getPullRequestStatus), checks, reviews.", + " 6. If issues: run convergence rounds (startPullRequestConvergenceRound) for automated fixes.", + " 7. Request reviewers (requestPrReviewers), respond to feedback.", + " 8. Land PR when ready (landPullRequest).", + "", + "## Git Operations in ADE", + "", + " All git operations are lane-scoped. Pass a laneId (defaults to the CTO's current lane if omitted).", + " Stage & commit: gitCommit({ laneId, message, stageAll: true }).", + " Push/pull/fetch: gitPush, gitPull, gitFetch.", + " Branch management: gitListBranches, gitCheckoutBranch({ branch, create: true }).", + " Stash: gitStashPush, gitStashPop, gitStashList.", + " Conflict handling: gitGetConflictState, gitRebaseContinue, gitRebaseAbort, gitMergeAbort.", + " History: gitListRecentCommits({ laneId, limit }).", + " Status: gitStatus({ laneId }) returns branch info, ahead/behind counts, dirty state.", + "", + "## Linear Integration", + "", + " ADE integrates with Linear for issue tracking and workflow automation.", + " - List and inspect Linear issues: listLinearIssues, getLinearIssue.", + " - Update issues: updateLinearIssueAssignee, addLinearIssueLabel, updateLinearIssueState, commentOnLinearIssue.", + " - Route issues to work: routeLinearIssueToCto (handle yourself), routeLinearIssueToMission (auto-plan), routeLinearIssueToWorker (delegate).", + " - Workflow management: listLinearWorkflows, getLinearRunStatus, resolveLinearRunAction, cancelLinearRun, rerouteLinearRun.", + "", + "## Automation System", + "", + " ADE automations are event-driven rules that trigger actions when conditions are met.", + " - List rules: listAutomations. Trigger manually: triggerAutomation. View history: listAutomationRuns.", + " - Rules can be configured in /automations or /settings.", + "", + "## Memory System", + "", + " ADE has a 4-layer memory model (detailed in the Memory and Continuity section).", + " - memorySearch: retrieve stored decisions, patterns, conventions, gotchas.", + " - memoryAdd: store durable lessons for future sessions.", + " - memoryUpdateCore: update the standing project brief (summary, conventions, preferences, focus, notes).", + " - memoryPin / memoryDelete: manage individual memory items.", "", - "Task routing:", - "- 'Start a chat' or 'launch an agent' → spawnChat with an initialPrompt describing the task.", - "- 'Check PR status' → getPullRequestStatus or getPullRequestConvergence.", - "- 'Start work on [feature]' → create/find a lane, then spawnChat or startMission.", - "- 'Open a terminal' → createTerminal.", - "- 'Commit and push' → gitCommit then gitPush.", - "- 'Check for conflicts' → getConflictStatus or getConflictRiskMatrix.", - "- 'Resolve merge conflicts' → getConflictStatus, requestConflictProposal, applyConflictProposal.", - "- 'Steer an active agent' → steerChat({ sessionId, instruction }).", - "- 'How is the project doing?' → getProjectHealthSummary.", - "- 'What happened recently?' → getRecentEvents.", - "- 'Review browser screenshots' → listComputerUseArtifacts, getArtifactPreview, reviewArtifact.", - "- 'How much are we spending?' → getProjectBudgetStatus or getWorkerCostBreakdown.", - "- 'Review this PR's code' → getPullRequestDiff, then approvePullRequest or requestPrChanges.", + "## Tests", + "", + " ADE discovers test suites from the project config and can run them per-lane.", + " - listTestSuites: see available test commands.", + " - runTests({ laneId, suiteId }): execute tests and get results.", + " - listTestRuns, getTestLog: review test history and output.", + "", + "## Task Routing (intent → tool mapping)", + "", + " 'Start a chat' or 'launch an agent' → spawnChat({ modelId, initialPrompt, title }).", + " 'Start a chat with opus/sonnet/gpt-5.4/haiku' → spawnChat({ modelId: '', ... }). Always map the name to the full ID.", + " 'Check PR status' → getPullRequestStatus or getPullRequestConvergence.", + " 'Start work on [feature]' → create/find a lane, then spawnChat or startMission.", + " 'Open a terminal' → createTerminal.", + " 'Run the tests' → listTestSuites to find available suites, then runTests.", + " 'Commit and push' → gitCommit then gitPush.", + " 'Check for conflicts' → getConflictStatus or getConflictRiskMatrix.", + " 'Resolve merge conflicts' → getConflictStatus, requestConflictProposal, applyConflictProposal.", + " 'Steer an active agent' → steerChat({ sessionId, instruction }).", + " 'How is the project doing?' → getProjectHealthSummary.", + " 'What happened recently?' → getRecentEvents.", + " 'List all lanes' or 'show me the branches' → listLanes.", + " 'Create a new branch for [feature]' → createLane({ name, description }).", + " 'Read a file' → readWorkspaceFile({ filePath }).", + " 'Search the code for [pattern]' → searchWorkspaceText({ query }) or searchCodebase({ pattern }).", + " 'What model is this using?' → report the current session's model from your identity state.", + " 'Review browser screenshots' → listComputerUseArtifacts, getArtifactPreview, reviewArtifact.", + " 'How much are we spending?' → getProjectBudgetStatus or getWorkerCostBreakdown.", + " 'Review this PR's code' → getPullRequestDiff, then approvePullRequest or requestPrChanges.", + " 'Show me the Linear issues' → listLinearIssues.", + " 'What processes are running?' → listManagedProcesses.", + " 'Start the dev server' → startManagedProcess({ processId }) or createTerminal with the startup command.", + " 'Rename this lane' → renameLane({ laneId, name }).", + " 'Archive a lane' → archiveLane({ laneId }).", + " 'Show me recent commits' → gitListRecentCommits.", + " 'Create a PR for this lane' → createPrFromLane({ laneId, title, body }).", ].join("\n"); // Keep in sync with ctoOperatorTools.ts tool registrations const CTO_CAPABILITY_MANIFEST = [ - "ADE operator tools (complete list):", - "- Lanes: listLanes, inspectLane, createLane, deleteLane", - "- Chats: listChats, spawnChat, sendChatMessage, interruptChat, resumeChat, endChat, getChatStatus, getChatTranscript", - "- Chat steering: steerChat, cancelSteer, handoffChat, listSubagents, approveToolUse", - "- Missions: listMissions, startMission, getMissionStatus, updateMission, launchMissionRun, resolveMissionIntervention, getMissionRunView, getMissionLogs, listMissionWorkerDigests, steerMission", - "- Workers: listWorkers, createWorker, updateWorker, removeWorker, updateWorkerStatus, wakeWorker, getWorkerStatus", - "- Git: gitStatus, gitCommit, gitPush, gitPull, gitFetch, gitListRecentCommits, gitListBranches, gitCheckoutBranch, gitStashPush, gitStashPop, gitStashList, gitGetConflictState, gitRebaseContinue, gitRebaseAbort, gitMergeAbort", - "- PRs: listPullRequests, getPullRequestStatus, commentOnPullRequest, updatePullRequestTitle, updatePullRequestBody, createPrFromLane, landPullRequest, closePullRequest, requestPrReviewers, getPullRequestDiff, approvePullRequest, requestPrChanges", - "- Convergence: getPullRequestConvergence, updatePullRequestConvergencePipeline, updatePullRequestConvergenceRuntime, startPullRequestConvergenceRound, stopPullRequestConvergence", - "- Conflicts: getConflictStatus, getConflictRiskMatrix, simulateMerge, runConflictPrediction, listConflictProposals, requestConflictProposal, applyConflictProposal, undoConflictProposal", - "- Files: listFileWorkspaces, readWorkspaceFile, searchWorkspaceText", - "- Context: getContextStatus, generateContextDocs", - "- Processes: listManagedProcesses, startManagedProcess, stopManagedProcess, getManagedProcessLog", - "- Tests: listTestSuites, runTests, stopTestRun, listTestRuns, getTestLog", - "- Terminals: createTerminal", - "- Linear: listLinearWorkflows, getLinearRunStatus, resolveLinearRunAction, cancelLinearRun, commentOnLinearIssue, updateLinearIssueState, routeLinearIssueToCto, routeLinearIssueToMission, routeLinearIssueToWorker, rerouteLinearRun, listLinearIssues, getLinearIssue, updateLinearIssueAssignee, addLinearIssueLabel", - "- Automations: listAutomations, triggerAutomation, listAutomationRuns", - "- Events: getRecentEvents", - "- Project health: getProjectHealthSummary", - "- Computer use: listComputerUseArtifacts, getArtifactPreview, reviewArtifact", - "- Budget: getProjectBudgetStatus, getWorkerCostBreakdown", - "- Memory: memorySearch, memoryAdd, memoryUpdateCore, memoryPin, memoryDelete", + "# ADE Operator Tools (complete reference)", + "", + "## Lanes (workspace isolation)", + " listLanes — List all lanes with status, branch info, ahead/behind counts.", + " inspectLane — Get detailed info for a single lane (worktree path, status, stack chain).", + " createLane — Create a new lane (git worktree + branch). Params: name, description, baseRef, parentLaneId.", + " deleteLane — Remove a lane and its worktree. Params: laneId.", + " renameLane — Change a lane's display name. Params: laneId, name.", + " archiveLane — Archive a lane (hides from default view, preserves data). Params: laneId.", + "", + "## Chats (AI work sessions)", + " listChats — List all chat sessions, optionally filtered by lane.", + " spawnChat — Create a new ADE chat session. THIS IS THE PRIMARY WAY TO LAUNCH AI AGENTS. Params: laneId, modelId (use full ID like 'anthropic/claude-sonnet-4-6'), reasoningEffort, title, initialPrompt, openInUi. The modelId is critical — always pass it when the user specifies a model.", + " sendChatMessage — Send a follow-up message to an existing chat. Params: sessionId, text.", + " interruptChat — Stop a running turn in a chat. Params: sessionId.", + " resumeChat — Resume a paused chat session. Params: sessionId.", + " endChat — Terminate a chat session. Params: sessionId.", + " getChatStatus — Get the current status of a chat (running, idle, ended). Params: sessionId.", + " getChatTranscript — Read the conversation history of a chat. Params: sessionId, limit.", + "", + "## Chat Steering (supervise active agents)", + " steerChat — Inject a steering instruction into an active chat session. Params: sessionId, instruction.", + " cancelSteer — Cancel a pending steer instruction. Params: sessionId.", + " handoffChat — Hand off a chat to a different agent identity. Params: sessionId, targetIdentityKey, reason.", + " listSubagents — List sub-agents spawned by a chat. Params: sessionId.", + " approveToolUse — Approve or deny a pending tool use in a supervised chat. Params: sessionId, toolUseId, decision (accept/accept_for_session/decline/cancel).", + "", + "## Missions (structured multi-step tasks)", + " listMissions — List all missions with status and summary.", + " startMission — Create and start a new mission. Params: title, description, laneId.", + " getMissionStatus — Get detailed mission status, progress, and outcomes. Params: missionId.", + " updateMission — Update mission title, description, or configuration. Params: missionId.", + " launchMissionRun — Launch or re-launch a mission execution run. Params: missionId.", + " resolveMissionIntervention — Resolve a human intervention checkpoint. Params: missionId, resolution.", + " getMissionRunView — Get the detailed run view with phase/step progress. Params: missionId.", + " getMissionLogs — Read mission execution logs. Params: missionId.", + " listMissionWorkerDigests — Get summaries of worker activity in a mission. Params: missionId.", + " steerMission — Inject a steering directive into a running mission. Params: missionId, instruction.", + "", + "## Workers (autonomous agent instances)", + " listWorkers — List all worker agents with status and budget info.", + " createWorker — Create a new worker agent. Params: name, description, role, laneId.", + " updateWorker — Update worker config (name, description, role, model prefs). Params: agentId.", + " removeWorker — Delete a worker agent. Params: agentId.", + " updateWorkerStatus — Change worker status (active, paused, idle). Params: agentId, status.", + " wakeWorker — Wake a worker with a specific task or issue. Params: agentId, taskKey, issueKey, message.", + " getWorkerStatus — Get detailed worker status with recent activity. Params: agentId.", + "", + "## Git (version control)", + " gitStatus — Branch info, ahead/behind, dirty state for a lane.", + " gitCommit — Create a commit. Params: laneId, message, stageAll (default true).", + " gitPush — Push commits to remote. Params: laneId, force.", + " gitPull — Pull from remote. Params: laneId.", + " gitFetch — Fetch remote refs. Params: laneId.", + " gitListRecentCommits — Show recent commits. Params: laneId, limit (default 20).", + " gitListBranches — List all branches. Params: laneId.", + " gitCheckoutBranch — Switch or create branch. Params: laneId, branch, create.", + " gitStashPush — Stash working changes. Params: laneId, message.", + " gitStashPop — Pop latest stash. Params: laneId.", + " gitStashList — List stashes. Params: laneId.", + " gitGetConflictState — Check for merge/rebase conflicts. Params: laneId.", + " gitRebaseContinue — Continue rebase after conflict resolution. Params: laneId.", + " gitRebaseAbort — Abort in-progress rebase. Params: laneId.", + " gitMergeAbort — Abort in-progress merge. Params: laneId.", + "", + "## Pull Requests", + " listPullRequests — List all tracked PRs with status.", + " getPullRequestStatus — Detailed PR status: checks, reviews, merge readiness. Params: prId.", + " commentOnPullRequest — Add a comment to a PR. Params: prId, body.", + " updatePullRequestTitle — Change PR title. Params: prId, title.", + " updatePullRequestBody — Change PR description. Params: prId, body.", + " createPrFromLane — Create a GitHub PR from a lane. Params: laneId, title, body, draft.", + " landPullRequest — Merge/land a PR. Params: prId, mergeMethod.", + " closePullRequest — Close a PR without merging. Params: prId.", + " requestPrReviewers — Request reviewers for a PR. Params: prId, reviewers.", + " getPullRequestDiff — Get the full diff for code review. Params: prId.", + " approvePullRequest — Approve a PR review. Params: prId, body.", + " requestPrChanges — Request changes on a PR. Params: prId, body.", + "", + "## Convergence (automated PR resolution)", + " getPullRequestConvergence — Get convergence status, issues, and round history. Params: prId.", + " updatePullRequestConvergencePipeline — Update pipeline settings for a PR. Params: prId.", + " updatePullRequestConvergenceRuntime — Update runtime state (enable/disable auto-converge). Params: prId.", + " startPullRequestConvergenceRound — Start an AI resolution round for PR issues. Params: prId.", + " stopPullRequestConvergence — Stop an active convergence run. Params: prId.", + "", + "## Conflict Resolution", + " getConflictStatus — Check merge conflict status for a lane. Params: laneId.", + " getConflictRiskMatrix — Risk matrix across all lanes (predicts conflicts before they happen).", + " simulateMerge — Dry-run merge between two lanes. Params: sourceLaneId, targetLaneId.", + " runConflictPrediction — Batch conflict prediction across all lanes.", + " listConflictProposals — List AI-generated resolution proposals. Params: laneId.", + " requestConflictProposal — Request AI resolution for a conflict. Params: laneId, filePath.", + " applyConflictProposal — Apply a resolution proposal. Params: laneId, proposalId.", + " undoConflictProposal — Revert an applied proposal. Params: laneId, proposalId.", + "", + "## Files", + " listFileWorkspaces — List file workspaces (one per lane).", + " readWorkspaceFile — Read a file's contents. Params: filePath, laneId.", + " searchWorkspaceText — Search for text patterns in workspace files. Params: query, laneId.", + " searchCodebase — Search the ADE codebase itself for patterns (for self-debugging). Params: pattern, fileGlob.", + "", + "## Context & Documentation", + " getContextStatus — Check what ADE context docs exist and staleness.", + " generateContextDocs — Generate context packs for workers or export.", + "", + "## Processes (managed dev servers, builds, etc.)", + " listManagedProcesses — List defined processes and their runtime status.", + " startManagedProcess — Start a defined process. Params: processId, laneId.", + " stopManagedProcess — Stop a running process. Params: processId, laneId.", + " getManagedProcessLog — Read process log output. Params: processId, laneId.", + "", + "## Tests", + " listTestSuites — List available test suite definitions.", + " runTests — Run a test suite in a lane. Params: laneId, suiteId.", + " stopTestRun — Stop a running test. Params: runId.", + " listTestRuns — List recent test runs with pass/fail status.", + " getTestLog — Read test run output. Params: runId.", + "", + "## Terminals", + " createTerminal — Open a shell terminal in a lane. Params: laneId, title, startupCommand.", + "", + "## Linear Integration", + " listLinearWorkflows — List active Linear workflow runs.", + " getLinearRunStatus — Get status of a specific Linear workflow run. Params: runId.", + " resolveLinearRunAction — Approve/reject a Linear workflow action. Params: runId, action.", + " cancelLinearRun — Cancel a Linear workflow run. Params: runId.", + " rerouteLinearRun — Reroute a Linear run to a different handler. Params: runId, target.", + " commentOnLinearIssue — Add a comment to a Linear issue. Params: issueId, body.", + " updateLinearIssueState — Move a Linear issue to a new state. Params: issueId, stateId.", + " routeLinearIssueToCto — Route a Linear issue to yourself (the CTO) for handling.", + " routeLinearIssueToMission — Auto-create a mission from a Linear issue. Params: issueId.", + " routeLinearIssueToWorker — Delegate a Linear issue to a worker agent. Params: issueId, agentId.", + " listLinearIssues — Search/list Linear issues. Params: projectSlug, query, limit.", + " getLinearIssue — Get full detail of a Linear issue. Params: issueId.", + " updateLinearIssueAssignee — Assign/unassign a Linear issue. Params: issueId, assigneeId.", + " addLinearIssueLabel — Add a label to a Linear issue. Params: issueId, labelName.", + "", + "## Automations", + " listAutomations — List all automation rules.", + " triggerAutomation — Manually trigger an automation rule. Params: id, dryRun.", + " listAutomationRuns — List recent automation run history.", + "", + "## Events & Health", + " getRecentEvents — Unified feed of recent project events (sessions, worker activity, tests, PRs, missions, chats). Params: since, limit.", + " getProjectHealthSummary — Aggregate dashboard: mission counts, worker utilization, test pass rates, PR status, budget burn.", + "", + "## Computer Use", + " listComputerUseArtifacts — List browser screenshots and artifacts from computer use sessions.", + " getArtifactPreview — Preview a specific artifact. Params: artifactId.", + " reviewArtifact — Review and approve/reject a computer use artifact. Params: artifactId, decision.", + "", + "## Budget & Cost", + " getProjectBudgetStatus — Get project-wide budget and spending snapshot.", + " getWorkerCostBreakdown — Get cost breakdown per worker agent. Params: agentId, monthKey.", + "", + "## Memory", + " memorySearch — Search stored decisions, patterns, conventions, gotchas. Params: query.", + " memoryAdd — Store a durable lesson/decision for future sessions. Params: category, content.", + " memoryUpdateCore — Update the standing project brief (summary, conventions, preferences, focus, notes). Params: patch.", + " memoryPin — Pin an important memory item. Params: memoryId.", + " memoryDelete — Remove a memory item. Params: memoryId.", + "", + "# Operating Rules", "", - "Operating rules:", "- Internal ADE actions run through service-backed tools even when no renderer click occurs.", - "- UI navigation is suggestion-only. When an action should be opened in ADE, return an explicit navigation suggestion instead of silently switching tabs.", + "- UI navigation is suggestion-only. When an action should open in ADE, return an explicit navigation suggestion instead of silently switching tabs.", "- Treat ADE as your operating environment. Do not describe yourself as blocked on renderer button clicks when an internal tool can do the work.", + "- When multiple tools exist for similar purposes, prefer the higher-level one (e.g., createPrFromLane over manual git commands).", + "- Always default laneId to the CTO's current lane if the user doesn't specify one.", + "- For model-specific requests, always resolve the user's model name to the full modelId before calling spawnChat.", ].join("\n"); function asStringArray(value: unknown): string[] { diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 530ee59b5..f1fd44655 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -3629,13 +3629,25 @@ export function registerIpc({ } const expectedHostname = ctx.laneProxyService.generateHostname(laneId, lane.name); + const health = ctx.runtimeDiagnosticsService + ? await ctx.runtimeDiagnosticsService.checkLaneHealth(laneId).catch(() => null) + : null; + const validatedRespondingPort = + Number.isInteger(health?.respondingPort) && + (health?.respondingPort as number) > 0 && + (health?.respondingPort as number) >= lease.rangeStart && + (health?.respondingPort as number) <= lease.rangeEnd + ? (health?.respondingPort as number) + : null; + const targetPort = validatedRespondingPort ?? lease.rangeStart; const currentRoute = ctx.laneProxyService.getRoute(laneId); if ( !currentRoute || - currentRoute.targetPort !== lease.rangeStart || - currentRoute.hostname !== expectedHostname + currentRoute.targetPort !== targetPort || + currentRoute.hostname !== expectedHostname || + currentRoute.status !== "active" ) { - ctx.laneProxyService.addRoute(laneId, lease.rangeStart, lane.name); + ctx.laneProxyService.addRoute(laneId, targetPort, lane.name); } return ctx.laneProxyService.getPreviewInfo(laneId); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 9f552a6e0..88d9ee705 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -1480,7 +1480,7 @@ export function createLaneService({ } }, - async importBranch(args: { branchRef: string; name?: string; description?: string; parentLaneId?: string | null; baseBranch?: string }): Promise { + async importBranch(args: { branchRef: string; name?: string; description?: string; baseBranch?: string }): Promise { const rawRef = (args.branchRef ?? "").trim(); if (!rawRef) throw new Error("branchRef is required"); if (rawRef.includes("\0")) throw new Error("Invalid branchRef"); @@ -1533,128 +1533,10 @@ export function createLaneService({ }); worktreeAdded = true; - // --- Detect real parent lane via git merge-base --- - const parentLaneIdRaw = typeof args.parentLaneId === "string" ? args.parentLaneId.trim() : ""; - const explicitParentLaneId = parentLaneIdRaw.length ? parentLaneIdRaw : null; - let parentLaneId: string | null = null; - - // Try to detect the true parent by finding which lane's HEAD shares the - // most recent common ancestor with the imported branch. - try { - const importedHeadSha = await getHeadSha(worktreePath); - if (importedHeadSha) { - const activeRows = getAllLaneRows(false); - let bestLaneId: string | null = null; - let bestScore = Infinity; - let bestTieBreak: string | null = null; - - for (const row of activeRows) { - // Skip the lane we are currently importing (same id won't exist yet, - // but skip by branch_ref match just in case). - if (row.branch_ref === branchRef) continue; - - const laneWorktree = row.worktree_path; - if (!laneWorktree) continue; - - const laneHeadSha = await getHeadSha(laneWorktree); - if (!laneHeadSha) continue; - - const mbResult = await runGit( - ["merge-base", laneHeadSha, importedHeadSha], - { cwd: projectRoot, timeoutMs: 10_000 }, - ); - if (mbResult.exitCode !== 0) continue; - const mergeBaseSha = mbResult.stdout.trim(); - if (!mergeBaseSha) continue; - - const importedCountResult = await runGit( - ["rev-list", "--count", `${mergeBaseSha}..${importedHeadSha}`], - { cwd: projectRoot, timeoutMs: 10_000 }, - ); - if (importedCountResult.exitCode !== 0) continue; - const importedDistance = parseInt(importedCountResult.stdout.trim(), 10); - if (isNaN(importedDistance)) continue; - - const candidateCountResult = await runGit( - ["rev-list", "--count", `${mergeBaseSha}..${laneHeadSha}`], - { cwd: projectRoot, timeoutMs: 10_000 }, - ); - if (candidateCountResult.exitCode !== 0) continue; - const candidateDistance = parseInt(candidateCountResult.stdout.trim(), 10); - if (isNaN(candidateDistance)) continue; - - const score = importedDistance + candidateDistance; - const tieBreak = `${row.id}\0${row.branch_ref}`; - if ( - score < bestScore - || (score === bestScore && tieBreak.localeCompare(bestTieBreak ?? "") < 0) - ) { - bestScore = score; - bestLaneId = row.id; - bestTieBreak = tieBreak; - } - } - - // Also score against defaultBaseRef (main) directly — if main is - // equally good or better, there is no real parent lane. - if (bestLaneId) { - let mainScore = Infinity; - try { - // Prefer the remote-tracking ref so the comparison uses the - // latest fetched state rather than a potentially stale local tip. - let mainShaRes = await runGit( - ["rev-parse", `origin/${defaultBaseRef}`], - { cwd: projectRoot, timeoutMs: 10_000 }, - ); - if (mainShaRes.exitCode !== 0) { - mainShaRes = await runGit( - ["rev-parse", defaultBaseRef], - { cwd: projectRoot, timeoutMs: 10_000 }, - ); - } - const mainSha = mainShaRes.exitCode === 0 ? mainShaRes.stdout.trim() : null; - if (mainSha) { - const mbRes = await runGit( - ["merge-base", mainSha, importedHeadSha], - { cwd: projectRoot, timeoutMs: 10_000 }, - ); - if (mbRes.exitCode === 0 && mbRes.stdout.trim()) { - const mb = mbRes.stdout.trim(); - const d1 = await runGit(["rev-list", "--count", `${mb}..${importedHeadSha}`], { cwd: projectRoot, timeoutMs: 10_000 }); - const d2 = await runGit(["rev-list", "--count", `${mb}..${mainSha}`], { cwd: projectRoot, timeoutMs: 10_000 }); - if (d1.exitCode === 0 && d2.exitCode === 0) { - mainScore = parseInt(d1.stdout.trim(), 10) + parseInt(d2.stdout.trim(), 10); - } - } - } - } catch { - // If main scoring fails, fall through to lane-based parent. - } - - if (mainScore <= bestScore) { - // The branch is based on main (or closer to it), no parent lane - // — unless the caller explicitly provided one. - parentLaneId = explicitParentLaneId ?? null; - } else { - if (explicitParentLaneId && explicitParentLaneId !== bestLaneId) { - logger.warn("laneService.importBranch.parent_mismatch", { - explicitParentLaneId, - detectedParentLaneId: bestLaneId, - }); - } - parentLaneId = bestLaneId; - } - } - } - } catch (err) { - logger.warn("laneService.importBranch.parent_detection_failed", { error: err instanceof Error ? err.message : String(err) }); - } - - // Fallback: use only the explicit parent when detection yielded nothing. - if (!parentLaneId) { - parentLaneId = explicitParentLaneId; - } - + // Imported branches are always root lanes. No caller passes + // parentLaneId — if a child lane is wanted, the "child" creation + // mode is used instead. + const parentLaneId: string | null = null; const parent = parentLaneId ? getLaneRow(parentLaneId) : null; if (parentLaneId && !parent) throw new Error(`Parent lane not found: ${parentLaneId}`); if (parent && parent.status === "archived") throw new Error("Parent lane is archived"); diff --git a/apps/desktop/src/main/services/lanes/oauthRedirectService.test.ts b/apps/desktop/src/main/services/lanes/oauthRedirectService.test.ts index da9746dc7..2ea9d997e 100644 --- a/apps/desktop/src/main/services/lanes/oauthRedirectService.test.ts +++ b/apps/desktop/src/main/services/lanes/oauthRedirectService.test.ts @@ -3,6 +3,22 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { createOAuthRedirectService } from "./oauthRedirectService"; import type { OAuthRedirectEvent, ProxyRoute } from "../../../shared/types"; +// Mock node:http — we replace `http.request` so the oauth service's +// defaultRequestUpstream can be exercised deterministically. All other existing +// tests inject a fake `requestUpstream`, so they never touch `http.request`. +const httpRequestMock = vi.fn(); +vi.mock("node:http", async () => { + const actual = await vi.importActual("node:http"); + return { + ...actual, + default: { + ...actual, + request: (...args: unknown[]) => httpRequestMock(...args), + }, + request: (...args: unknown[]) => httpRequestMock(...args), + }; +}); + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -50,6 +66,13 @@ function makeRoute( }; } +function locationFrom(res: any): string | null { + const call = res.writeHead.mock.calls.at(-1); + const headers = call?.[1] as Record | undefined; + const location = headers?.location; + return typeof location === "string" ? location : Array.isArray(location) ? location[0] ?? null : null; +} + // --------------------------------------------------------------------------- // Test suite // --------------------------------------------------------------------------- @@ -59,6 +82,7 @@ describe("oauthRedirectService", () => { let routes: ProxyRoute[]; let logger: ReturnType; let forwardToPort: ReturnType; + let requestUpstream: ReturnType; let svc: ReturnType; beforeEach(() => { @@ -66,6 +90,7 @@ describe("oauthRedirectService", () => { routes = []; logger = createLogger(); forwardToPort = vi.fn(); + requestUpstream = vi.fn(); svc = createOAuthRedirectService({ logger, @@ -74,6 +99,7 @@ describe("oauthRedirectService", () => { getProxyPort: () => 8080, getHostnameSuffix: () => ".localhost", forwardToPort, + requestUpstream: requestUpstream as any, }); }); @@ -394,6 +420,105 @@ describe("oauthRedirectService", () => { expect(sessions[0].status).toBe("failed"); expect(sessions[0].error).toBeDefined(); }); + + it("rewrites auth starts onto the stable ADE callback URL", async () => { + routes.push(makeRoute("lane-1", 3001)); + requestUpstream.mockResolvedValue({ + statusCode: 307, + headers: { + location: + "https://accounts.google.com/o/oauth2/v2/auth?state=raw-state&redirect_uri=http%3A%2F%2Flane-1.localhost%3A8080%2Fapi%2Fauth%2Fgoogle%2Fcallback&scope=openid", + "set-cookie": ["versic-oauth-state=raw-state; Path=/; HttpOnly"], + }, + body: Buffer.alloc(0), + }); + + const req = mockReq("/api/auth/google", "lane-1.localhost:8080"); + const res = mockRes(); + + expect(svc.handleRequest(req, res)).toBe(true); + + await vi.waitFor(() => { + expect(res.writeHead).toHaveBeenCalled(); + }); + + const rewrittenLocation = locationFrom(res); + expect(rewrittenLocation).toContain("redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Foauth%2Fcallback"); + expect(rewrittenLocation).toContain("state=ade%3A"); + expect(svc.listSessions()).toHaveLength(1); + expect(svc.listSessions()[0].status).toBe("pending"); + }); + + it("replays the callback response back on the lane host after routing through the stable callback", async () => { + routes.push(makeRoute("lane-1", 3001)); + requestUpstream + .mockResolvedValueOnce({ + statusCode: 307, + headers: { + location: + "https://accounts.google.com/o/oauth2/v2/auth?state=raw-state&redirect_uri=http%3A%2F%2Flane-1.localhost%3A8080%2Fapi%2Fauth%2Fgoogle%2Fcallback&scope=openid", + "set-cookie": [ + "versic-oauth-state=raw-state; Path=/; HttpOnly", + "versic-oauth-redirect=%2Fdashboard; Path=/", + ], + }, + body: Buffer.alloc(0), + }) + .mockResolvedValueOnce({ + statusCode: 302, + headers: { + location: "/dashboard", + "set-cookie": ["versic-access-token=test-token; Path=/; HttpOnly"], + }, + body: Buffer.alloc(0), + }); + + const startReq = mockReq("/api/auth/google", "lane-1.localhost:8080"); + const startRes = mockRes(); + expect(svc.handleRequest(startReq, startRes)).toBe(true); + await vi.waitFor(() => { + expect(startRes.writeHead).toHaveBeenCalled(); + }); + + const rewrittenLocation = locationFrom(startRes)!; + const encodedState = new URL(rewrittenLocation).searchParams.get("state"); + expect(encodedState).toBeTruthy(); + + const callbackReq = mockReq( + `/oauth/callback?code=test-code&state=${encodeURIComponent(encodedState!)}`, + ); + const callbackRes = mockRes(); + expect(svc.handleRequest(callbackReq, callbackRes)).toBe(true); + + await vi.waitFor(() => { + expect(callbackRes.writeHead).toHaveBeenCalled(); + }); + + expect(requestUpstream).toHaveBeenCalledTimes(2); + expect(requestUpstream.mock.calls[1][0].overridePath).toContain("/api/auth/google/callback"); + expect(requestUpstream.mock.calls[1][0].overridePath).toContain("state=raw-state"); + expect(requestUpstream.mock.calls[1][0].overrideHeaders.cookie).toContain("versic-oauth-state=raw-state"); + expect(requestUpstream.mock.calls[1][0].overrideHeaders.host).toBe("lane-1.localhost:8080"); + + const finalizeLocation = locationFrom(callbackRes); + expect(finalizeLocation).toContain("lane-1.localhost:8080/__ade/oauth/finalize?token="); + const finalizeToken = new URL(finalizeLocation!).searchParams.get("token"); + expect(finalizeToken).toBeTruthy(); + + const finalizeReq = mockReq( + `/__ade/oauth/finalize?token=${encodeURIComponent(finalizeToken!)}`, + "lane-1.localhost:8080", + ); + const finalizeRes = mockRes(); + expect(svc.handleRequest(finalizeReq, finalizeRes)).toBe(true); + + expect(finalizeRes.writeHead).toHaveBeenCalledWith(302, { + location: "/dashboard", + "set-cookie": ["versic-access-token=test-token; Path=/; HttpOnly"], + }); + expect(svc.listSessions()).toHaveLength(1); + expect(svc.listSessions()[0].status).toBe("completed"); + }); }); // ========================================================================= @@ -575,17 +700,12 @@ describe("oauthRedirectService", () => { // ========================================================================= describe("redirect URI generation", () => { - it("generates generic URIs with all callback paths", () => { + it("generates one stable ADE-managed callback URI for the generic helper", () => { const infos = svc.generateRedirectUris(); expect(infos).toHaveLength(1); expect(infos[0].provider).toBe("Generic"); - expect(infos[0].uris).toEqual([ - "http://localhost:8080/oauth/callback", - "http://localhost:8080/auth/callback", - "http://localhost:8080/api/auth/callback", - "http://localhost:8080/callback", - ]); - expect(infos[0].instructions).toBeTruthy(); + expect(infos[0].uris).toEqual(["http://localhost:8080/oauth/callback"]); + expect(infos[0].instructions).toContain("ADE-managed callback URL"); }); it("Google provider returns specific URI and instructions", () => { @@ -600,18 +720,15 @@ describe("oauthRedirectService", () => { const infos = svc.generateRedirectUris("github"); expect(infos).toHaveLength(1); expect(infos[0].provider).toBe("GitHub"); - expect(infos[0].uris).toEqual(["http://localhost:8080/auth/callback"]); + expect(infos[0].uris).toEqual(["http://localhost:8080/oauth/callback"]); expect(infos[0].instructions).toContain("GitHub OAuth App"); }); - it("Auth0 provider returns specific URIs and instructions", () => { + it("Auth0 provider returns the stable ADE callback URI and instructions", () => { const infos = svc.generateRedirectUris("auth0"); expect(infos).toHaveLength(1); expect(infos[0].provider).toBe("Auth0"); - expect(infos[0].uris).toEqual([ - "http://localhost:8080/oauth/callback", - "http://localhost:8080/auth/callback", - ]); + expect(infos[0].uris).toEqual(["http://localhost:8080/oauth/callback"]); expect(infos[0].instructions).toContain("Auth0"); expect(infos[0].instructions).toContain("Allowed Callback URLs"); }); @@ -631,11 +748,11 @@ describe("oauthRedirectService", () => { custom.dispose(); }); - it("unknown provider falls back to generic URIs", () => { + it("unknown provider falls back to the generic stable callback URI", () => { const infos = svc.generateRedirectUris("okta"); expect(infos).toHaveLength(1); expect(infos[0].provider).toBe("okta"); - expect(infos[0].uris).toHaveLength(4); + expect(infos[0].uris).toEqual(["http://localhost:8080/oauth/callback"]); }); }); @@ -719,9 +836,409 @@ describe("oauthRedirectService", () => { "/oauth/callback", "/auth/callback", "/api/auth/callback", + "/api/auth/google/callback", "/callback", ]); expect(status.activeSessions).toEqual([]); }); }); + + // ========================================================================= + // 10. defaultRequestUpstream — timeout, abort/close listeners, body buffering + // ========================================================================= + // + // These tests exercise the internal defaultRequestUpstream closure by NOT + // injecting a `requestUpstream` and driving flow through `handleRequest`. + // All http.request calls go through our mock so we can simulate upstream + // responses, timeouts, and client-side events deterministically. + describe("defaultRequestUpstream (private helper — driven through handleRequest)", () => { + let nativeSvc: ReturnType; + let nativeRoutes: ProxyRoute[]; + let nativeEvents: OAuthRedirectEvent[]; + + function makeIncomingReq(opts: { + url: string; + host: string; + method: string; + }): any { + const req: any = new EventEmitter(); + req.url = opts.url; + req.headers = { host: opts.host }; + req.method = opts.method; + req.complete = false; + return req; + } + + /** + * Build a fake upstream ClientRequest returned from http.request(). + * `onEnd` fires whenever the service calls `upstreamReq.end(…)`. + * `emitResponse(res)` delivers a fake upstream IncomingMessage to the callback. + * `emitError(err)` fires the "error" listener the service registered. + */ + function makeFakeUpstream(): { + upstreamReq: any; + onEnd: ReturnType; + emitResponse: (statusCode: number, headers: Record, body?: Buffer | string) => void; + emitError: (err: Error) => void; + setCallback: (cb: (res: any) => void) => void; + callback?: (res: any) => void; + } { + const onEnd = vi.fn(); + const upstreamReq: any = new EventEmitter(); + upstreamReq.end = onEnd; + const harness: any = { upstreamReq, onEnd }; + harness.setCallback = (cb: (res: any) => void) => { + harness.callback = cb; + }; + harness.emitResponse = (statusCode: number, headers: Record, body: Buffer | string = "") => { + const upstreamRes: any = new EventEmitter(); + upstreamRes.statusCode = statusCode; + upstreamRes.headers = headers; + // Invoke service-registered callback. + harness.callback?.(upstreamRes); + // Deliver body data then end. + const buf = Buffer.isBuffer(body) ? body : Buffer.from(body); + upstreamRes.emit("data", buf); + upstreamRes.emit("end"); + }; + harness.emitError = (err: Error) => { + upstreamReq.emit("error", err); + }; + return harness; + } + + beforeEach(() => { + httpRequestMock.mockReset(); + nativeRoutes = []; + nativeEvents = []; + nativeSvc = createOAuthRedirectService({ + logger: createLogger(), + broadcastEvent: (ev) => nativeEvents.push(ev), + getRoutes: () => nativeRoutes, + getProxyPort: () => 8080, + getHostnameSuffix: () => ".localhost", + forwardToPort: vi.fn(), + // deliberately NO requestUpstream — drives defaultRequestUpstream + }); + }); + + afterEach(() => { + nativeSvc.dispose(); + vi.useRealTimers(); + }); + + // ----------------------------------------------------------------------- + // GET-path (auth-start) covers: + // * GET bypasses body buffering → upstreamReq.end() called immediately w/ no args + // * 30s timeout rejects with TimeoutError and aborts the AbortController + // * After success, a late timer tick is a no-op (settled guard) + // ----------------------------------------------------------------------- + + it("GET request bypasses body buffering: upstreamReq.end() called immediately with no args", () => { + nativeRoutes.push(makeRoute("lane-1", 3001)); + const fake = makeFakeUpstream(); + httpRequestMock.mockImplementation((_options: any, cb: (res: any) => void) => { + fake.setCallback(cb); + return fake.upstreamReq; + }); + + const req = makeIncomingReq({ + url: "/api/auth/google", + host: "lane-1.localhost:8080", + method: "GET", + }); + const res = mockRes(); + + expect(nativeSvc.handleRequest(req, res)).toBe(true); + + expect(httpRequestMock).toHaveBeenCalledTimes(1); + // First end() call should be a single no-arg invocation (no body for GET). + expect(fake.onEnd).toHaveBeenCalledTimes(1); + expect(fake.onEnd.mock.calls[0]).toEqual([]); + }); + + it("upstream request signal is an AbortSignal (so aborts cancel the outbound socket)", () => { + nativeRoutes.push(makeRoute("lane-1", 3001)); + const fake = makeFakeUpstream(); + httpRequestMock.mockImplementation((_options: any, cb: (res: any) => void) => { + fake.setCallback(cb); + return fake.upstreamReq; + }); + + const req = makeIncomingReq({ + url: "/api/auth/google", + host: "lane-1.localhost:8080", + method: "GET", + }); + nativeSvc.handleRequest(req, mockRes()); + + const options = httpRequestMock.mock.calls[0][0]; + expect(options, "http.request options must be defined").toBeTruthy(); + expect(options.signal, "AbortSignal must be wired to the upstream request").toBeInstanceOf(AbortSignal); + expect(options.signal.aborted, "signal starts un-aborted").toBe(false); + }); + + it("30s timeout rejects with TimeoutError and aborts the AbortController signal", async () => { + vi.useFakeTimers(); + nativeRoutes.push(makeRoute("lane-1", 3001)); + const fake = makeFakeUpstream(); + let capturedSignal: AbortSignal | undefined; + httpRequestMock.mockImplementation((options: any, cb: (res: any) => void) => { + capturedSignal = options.signal; + fake.setCallback(cb); + // Simulate http: when aborted via signal, the request emits an error. + options.signal?.addEventListener("abort", () => { + const reason = (options.signal as any).reason; + fake.emitError(reason instanceof Error ? reason : new Error("aborted")); + }); + return fake.upstreamReq; + }); + + const req = makeIncomingReq({ + url: "/api/auth/google", + host: "lane-1.localhost:8080", + method: "GET", + }); + const res = mockRes(); + expect(nativeSvc.handleRequest(req, res)).toBe(true); + + // Advance 30 seconds → timeout fires. + await vi.advanceTimersByTimeAsync(30_000); + // Let any microtask follow-ups flush. + await vi.runAllTimersAsync(); + + expect(capturedSignal, "signal must have been captured").toBeTruthy(); + expect(capturedSignal!.aborted, "AbortController.abort must fire on timeout").toBe(true); + const reason = (capturedSignal as any).reason; + expect(reason, "abort reason should be the TimeoutError").toBeInstanceOf(Error); + expect(reason.name).toBe("TimeoutError"); + expect(reason.message).toMatch(/timed out after 30/i); + + // handleRequest catches and writes a 502 error page. + expect(res.writeHead).toHaveBeenCalledWith(502, { "Content-Type": "text/html" }); + }); + + it("resolves successfully on upstream response and a subsequent 30s timer tick is a no-op (settled flag)", async () => { + vi.useFakeTimers(); + nativeRoutes.push(makeRoute("lane-1", 3001)); + const fake = makeFakeUpstream(); + httpRequestMock.mockImplementation((_options: any, cb: (res: any) => void) => { + fake.setCallback(cb); + return fake.upstreamReq; + }); + + const req = makeIncomingReq({ + url: "/api/auth/google", + host: "lane-1.localhost:8080", + method: "GET", + }); + const res = mockRes(); + expect(nativeSvc.handleRequest(req, res)).toBe(true); + + // Upstream responds immediately — status 200 w/ a non-redirect location (parsedRedirect is null) + // so the service just replays the response via sendUpstreamResponse. + fake.emitResponse(200, {}, "hello"); + await vi.runAllTimersAsync(); + + // First writeHead call should be the success replay (status 200), not 502. + expect(res.writeHead).toHaveBeenCalled(); + const firstCall = res.writeHead.mock.calls[0]; + expect(firstCall[0], "success should not produce a 502").not.toBe(502); + + const writeHeadCountBefore = res.writeHead.mock.calls.length; + + // Fire the 30s timer that the timeout scheduled — cleanup should have + // cleared it, so we shouldn't see any additional writeHead(502) coming + // from a "late" timeout path. + await vi.advanceTimersByTimeAsync(60_000); + await vi.runAllTimersAsync(); + + expect(res.writeHead.mock.calls.length, "settled flag must prevent double-settle from late timeout").toBe(writeHeadCountBefore); + }); + + // ----------------------------------------------------------------------- + // Non-GET path (managed callback) covers: + // * POST buffers chunks and ends upstream with a concatenated Buffer + // * Client 'aborted' rejects with AbortError and aborts the signal + // * Client 'close' before complete rejects with AbortError + // * Client 'close' after completion is a no-op + // ----------------------------------------------------------------------- + + /** + * Set up a pending OAuth start for lane-1 so that subsequent callbacks go + * through handleManagedCallback (which calls sendUpstreamRequest with the + * incoming req directly — allowing POST method etc). + * + * Returns the encoded state to be passed back in the callback. + */ + async function primePendingStart(laneId: string, targetPort: number): Promise { + nativeRoutes.push(makeRoute(laneId, targetPort)); + const fake = makeFakeUpstream(); + httpRequestMock.mockImplementationOnce((_options: any, cb: (res: any) => void) => { + fake.setCallback(cb); + return fake.upstreamReq; + }); + + const startReq = makeIncomingReq({ + url: "/api/auth/google", + host: `${laneId}.localhost:8080`, + method: "GET", + }); + const startRes = mockRes(); + expect(nativeSvc.handleRequest(startReq, startRes)).toBe(true); + + fake.emitResponse(307, { + location: + "https://accounts.google.com/o/oauth2/v2/auth?state=raw-state&redirect_uri=http%3A%2F%2Flane-1.localhost%3A8080%2Fapi%2Fauth%2Fgoogle%2Fcallback&scope=openid", + }); + + // Wait for the auth-start promise to settle (microtask flush). + await vi.waitFor(() => { + expect(startRes.writeHead).toHaveBeenCalled(); + }); + + const rewrittenLocation = locationFrom(startRes); + expect(rewrittenLocation, "auth-start must rewrite to the stable callback").toBeTruthy(); + const encodedState = new URL(rewrittenLocation!).searchParams.get("state"); + expect(encodedState, "rewritten URL must embed an ADE state").toBeTruthy(); + return encodedState!; + } + + it("POST callback buffers chunks and sends a concatenated Buffer to upstreamReq.end()", async () => { + const encodedState = await primePendingStart("lane-1", 3001); + + // Now set up the upstream for the managed callback call. + const fake = makeFakeUpstream(); + httpRequestMock.mockImplementationOnce((_options: any, cb: (res: any) => void) => { + fake.setCallback(cb); + return fake.upstreamReq; + }); + + const callbackReq = makeIncomingReq({ + url: `/oauth/callback?code=xyz&state=${encodeURIComponent(encodedState)}`, + host: "lane-1.localhost:8080", + method: "POST", + }); + const callbackRes = mockRes(); + expect(nativeSvc.handleRequest(callbackReq, callbackRes)).toBe(true); + + // For POST, the service should NOT have called end() yet — it's waiting for body end. + expect(fake.onEnd, "POST must not call end() before client 'end' fires").not.toHaveBeenCalled(); + + // Feed body chunks (mix Buffer + string to exercise both branches). + callbackReq.emit("data", Buffer.from("hello ")); + callbackReq.emit("data", "world"); + callbackReq.emit("end"); + + // Service synchronously forwards end() with the concatenated buffer. + expect(fake.onEnd).toHaveBeenCalledTimes(1); + const endArg = fake.onEnd.mock.calls[0][0]; + expect(Buffer.isBuffer(endArg), "upstream end() should receive a Buffer").toBe(true); + expect((endArg as Buffer).toString("utf8")).toBe("hello world"); + + // Complete the upstream so the flow can fully settle (no dangling timers). + fake.emitResponse(200, {}, "ok"); + await vi.waitFor(() => { + expect(callbackRes.writeHead).toHaveBeenCalled(); + }); + }); + + it("client 'aborted' event rejects with AbortError and aborts the AbortController signal", async () => { + const encodedState = await primePendingStart("lane-1", 3001); + + const fake = makeFakeUpstream(); + let capturedSignal: AbortSignal | undefined; + httpRequestMock.mockImplementationOnce((options: any, cb: (res: any) => void) => { + capturedSignal = options.signal; + fake.setCallback(cb); + return fake.upstreamReq; + }); + + const callbackReq = makeIncomingReq({ + url: `/oauth/callback?code=xyz&state=${encodeURIComponent(encodedState)}`, + host: "lane-1.localhost:8080", + method: "POST", + }); + const callbackRes = mockRes(); + expect(nativeSvc.handleRequest(callbackReq, callbackRes)).toBe(true); + + callbackReq.emit("aborted"); + + await vi.waitFor(() => { + expect(callbackRes.writeHead).toHaveBeenCalledWith(502, { "Content-Type": "text/html" }); + }); + + expect(capturedSignal!.aborted, "AbortController must have been aborted").toBe(true); + const reason = (capturedSignal as any).reason; + expect(reason, "abort reason should be an AbortError").toBeInstanceOf(Error); + expect(reason.name).toBe("AbortError"); + }); + + it("client 'close' event before req.complete rejects with AbortError", async () => { + const encodedState = await primePendingStart("lane-1", 3001); + + const fake = makeFakeUpstream(); + let capturedSignal: AbortSignal | undefined; + httpRequestMock.mockImplementationOnce((options: any, cb: (res: any) => void) => { + capturedSignal = options.signal; + fake.setCallback(cb); + return fake.upstreamReq; + }); + + const callbackReq = makeIncomingReq({ + url: `/oauth/callback?code=xyz&state=${encodeURIComponent(encodedState)}`, + host: "lane-1.localhost:8080", + method: "POST", + }); + // Explicitly mark the request as incomplete → close should reject. + callbackReq.complete = false; + const callbackRes = mockRes(); + expect(nativeSvc.handleRequest(callbackReq, callbackRes)).toBe(true); + + callbackReq.emit("close"); + + await vi.waitFor(() => { + expect(callbackRes.writeHead).toHaveBeenCalledWith(502, { "Content-Type": "text/html" }); + }); + + expect(capturedSignal!.aborted).toBe(true); + expect((capturedSignal as any).reason).toBeInstanceOf(Error); + expect((capturedSignal as any).reason.name).toBe("AbortError"); + }); + + it("client 'close' fired AFTER the upstream response has settled is a no-op", async () => { + const encodedState = await primePendingStart("lane-1", 3001); + + const fake = makeFakeUpstream(); + httpRequestMock.mockImplementationOnce((_options: any, cb: (res: any) => void) => { + fake.setCallback(cb); + return fake.upstreamReq; + }); + + const callbackReq = makeIncomingReq({ + url: `/oauth/callback?code=xyz&state=${encodeURIComponent(encodedState)}`, + host: "lane-1.localhost:8080", + method: "POST", + }); + const callbackRes = mockRes(); + expect(nativeSvc.handleRequest(callbackReq, callbackRes)).toBe(true); + + // Drive body end → upstream end → response. + callbackReq.emit("end"); + fake.emitResponse(200, {}, "done"); + + // Wait for the managed-callback promise to settle (it writes a 302 redirect). + await vi.waitFor(() => { + expect(callbackRes.writeHead).toHaveBeenCalled(); + }); + const callsBefore = callbackRes.writeHead.mock.calls.length; + + // Now simulate the usual late "close" that fires when the client socket tears down. + callbackReq.complete = true; + callbackReq.emit("close"); + + // No second writeHead should happen (the settled flag + listener cleanup prevent it). + expect(callbackRes.writeHead.mock.calls.length, "late 'close' must be a no-op after settlement").toBe(callsBefore); + }); + }); }); diff --git a/apps/desktop/src/main/services/lanes/oauthRedirectService.ts b/apps/desktop/src/main/services/lanes/oauthRedirectService.ts index a3df47350..4a0a23d6e 100644 --- a/apps/desktop/src/main/services/lanes/oauthRedirectService.ts +++ b/apps/desktop/src/main/services/lanes/oauthRedirectService.ts @@ -1,6 +1,6 @@ +import http from "node:http"; import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import { URL } from "node:url"; -import type http from "node:http"; import type { OAuthRedirectConfig, OAuthRedirectStatus, @@ -14,6 +14,48 @@ import type { Logger } from "../logging/logger"; const STATE_PREFIX = "ade"; const STATE_SEP = ":"; +const FIXED_CALLBACK_PATH = "/oauth/callback"; +const FINALIZE_CALLBACK_PATH = "/__ade/oauth/finalize"; +const OAUTH_SESSION_TTL_MS = 10 * 60 * 1000; +const AUTH_START_PATH_PREFIXES = ["/api/auth/", "/auth/", "/oauth/"]; +const HOP_BY_HOP_RESPONSE_HEADERS = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +type UpstreamResponse = { + statusCode: number; + headers: http.IncomingHttpHeaders; + body: Buffer; +}; + +type PendingOAuthStartSession = { + encodedState: string; + laneId: string; + laneHostname: string; + targetPort: number; + originalState: string; + originalCallbackPath: string; + cookiePairs: string[]; + createdAtMs: number; + provider?: string; + sessionId: string; +}; + +type PendingFinalizeSession = { + token: string; + laneId: string; + laneHostname: string; + response: UpstreamResponse; + createdAtMs: number; + sessionId: string; +}; const DEFAULT_CONFIG: OAuthRedirectConfig = { enabled: true, @@ -21,6 +63,7 @@ const DEFAULT_CONFIG: OAuthRedirectConfig = { "/oauth/callback", "/auth/callback", "/api/auth/callback", + "/api/auth/google/callback", "/callback", ], routingMode: "state-parameter", @@ -29,10 +72,13 @@ const DEFAULT_CONFIG: OAuthRedirectConfig = { /** * OAuth Redirect Handling Service (Phase 5 W5). * - * Intercepts OAuth callbacks on the lane proxy, extracts the lane ID - * from the `state` parameter, and forwards the callback to the correct - * lane's dev server. Zero configuration required for the common case — - * just encode your OAuth state via `encodeState()` and ADE handles routing. + * The stable ADE-managed callback flow works like this: + * 1. A sign-in starts from the lane preview URL. + * 2. ADE rewrites the provider redirect_uri to a single stable proxy callback. + * 3. ADE stores the lane-bound cookies needed for the callback. + * 4. The provider returns to the stable callback. + * 5. ADE forwards the callback to the correct lane app and replays the final + * response back on the lane preview host so cookies remain isolated. */ export function createOAuthRedirectService({ logger, @@ -42,6 +88,7 @@ export function createOAuthRedirectService({ getProxyPort, getHostnameSuffix, forwardToPort, + requestUpstream, }: { logger: Logger; config?: Partial; @@ -58,9 +105,19 @@ export function createOAuthRedirectService({ res: http.ServerResponse, targetPort: number, ) => void; + /** Optional injectable request helper for tests and advanced proxy flows. */ + requestUpstream?: (args: { + req: http.IncomingMessage; + targetPort: number; + overridePath?: string; + overrideHeaders?: http.OutgoingHttpHeaders; + }) => Promise; }) { + void getHostnameSuffix; const cfg: OAuthRedirectConfig = { ...DEFAULT_CONFIG, ...userConfig }; const sessions = new Map(); + const pendingStarts = new Map(); + const pendingFinalize = new Map(); const stateSecret = randomBytes(32); // --------------------------------------------------------------------------- @@ -99,10 +156,15 @@ export function createOAuthRedirectService({ try { const signature = rest.slice(0, signatureEnd); - const laneId = Buffer.from(rest.slice(signatureEnd + STATE_SEP.length, laneEnd), "base64url").toString("utf-8"); + const laneId = Buffer.from( + rest.slice(signatureEnd + STATE_SEP.length, laneEnd), + "base64url", + ).toString("utf-8"); const originalState = rest.slice(laneEnd + STATE_SEP.length); if (!laneId.trim() || !signature) { - logger.debug("oauth_redirect.decode_error", { reason: "empty laneId or signature" }); + logger.debug("oauth_redirect.decode_error", { + reason: "empty laneId or signature", + }); return null; } @@ -133,6 +195,11 @@ export function createOAuthRedirectService({ return cfg.callbackPaths.some((p) => normalized === p.toLowerCase()); } + function isPotentialAuthStartPath(urlPath: string): boolean { + const normalized = urlPath.split("?")[0].toLowerCase(); + return AUTH_START_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix)); + } + function extractStateParam(req: http.IncomingMessage): string | null { try { const url = new URL( @@ -155,42 +222,22 @@ export function createOAuthRedirectService({ } } - function completeSessionFromResponse( - session: OAuthSession, - res: http.ServerResponse, - ): (status: OAuthSessionStatus, error?: string) => void { - let finished = false; - - const finalize = (status: OAuthSessionStatus, error?: string) => { - if (finished) return; - finished = true; - completeSession(session, status, error); - if (status === "completed") { - broadcastEvent({ - type: "oauth-callback-routed", - session, - status: buildStatus(), - }); - } - }; - - res.once("finish", () => { - if ((res.statusCode ?? 200) >= 400) { - finalize( - "failed", - `OAuth callback forwarding failed with status ${res.statusCode}.`, - ); - return; - } - finalize("completed"); - }); - - res.once("close", () => { - if (finished || res.writableEnded) return; - finalize("failed", "OAuth callback connection closed before completion."); - }); + function normalizeHostHeader(hostHeader: string): string { + const trimmed = hostHeader.trim(); + if (!trimmed.length) return ""; + if (trimmed.startsWith("[")) { + const end = trimmed.indexOf("]"); + return (end >= 0 ? trimmed.slice(1, end) : trimmed).toLowerCase(); + } + return trimmed.split(":")[0].toLowerCase(); + } - return finalize; + function findRouteByHostHeader(hostHeader: string | undefined): ProxyRoute | null { + const hostname = normalizeHostHeader(hostHeader ?? ""); + if (!hostname) return null; + return getRoutes().find( + (route) => route.hostname.toLowerCase() === hostname && route.status === "active", + ) ?? null; } // --------------------------------------------------------------------------- @@ -200,13 +247,15 @@ export function createOAuthRedirectService({ function createSession( laneId: string, callbackPath: string, + options?: { status?: OAuthSessionStatus; provider?: string }, ): OAuthSession { const id = `oauth-${randomUUID()}`; const session: OAuthSession = { id, laneId, - status: "active", + status: options?.status ?? "active", callbackPath, + ...(options?.provider ? { provider: options.provider } : {}), createdAt: new Date().toISOString(), }; sessions.set(id, session); @@ -218,6 +267,16 @@ export function createOAuthRedirectService({ return session; } + function markSessionActive(session: OAuthSession): void { + if (session.status === "active") return; + session.status = "active"; + broadcastEvent({ + type: "oauth-callback-routed", + session, + status: buildStatus(), + }); + } + function completeSession( session: OAuthSession, status: OAuthSessionStatus, @@ -235,6 +294,48 @@ export function createOAuthRedirectService({ }); } + function sessionById(sessionId: string): OAuthSession | null { + return sessions.get(sessionId) ?? null; + } + + function completeSessionFromResponse( + session: OAuthSession, + res: http.ServerResponse, + ): (status: OAuthSessionStatus, error?: string) => void { + let finished = false; + + const finalize = (status: OAuthSessionStatus, error?: string) => { + if (finished) return; + finished = true; + completeSession(session, status, error); + if (status === "completed") { + broadcastEvent({ + type: "oauth-callback-routed", + session, + status: buildStatus(), + }); + } + }; + + res.once("finish", () => { + if ((res.statusCode ?? 200) >= 400) { + finalize( + "failed", + `OAuth callback forwarding failed with status ${res.statusCode}.`, + ); + return; + } + finalize("completed"); + }); + + res.once("close", () => { + if (finished || res.writableEnded) return; + finalize("failed", "OAuth callback connection closed before completion."); + }); + + return finalize; + } + function buildStatus(): OAuthRedirectStatus { return { enabled: cfg.enabled, @@ -246,8 +347,36 @@ export function createOAuthRedirectService({ }; } + function removeExpiredPendingSessions(): void { + const cutoff = Date.now() - OAUTH_SESSION_TTL_MS; + for (const [encodedState, session] of pendingStarts.entries()) { + if (session.createdAtMs >= cutoff) continue; + pendingStarts.delete(encodedState); + const tracked = sessionById(session.sessionId); + if (tracked && tracked.status !== "completed" && tracked.status !== "failed") { + completeSession( + tracked, + "failed", + "OAuth callback did not return before the ADE session expired.", + ); + } + } + for (const [token, session] of pendingFinalize.entries()) { + if (session.createdAtMs >= cutoff) continue; + pendingFinalize.delete(token); + const tracked = sessionById(session.sessionId); + if (tracked && tracked.status !== "completed" && tracked.status !== "failed") { + completeSession( + tracked, + "failed", + "OAuth finalize response expired before the browser completed the redirect.", + ); + } + } + } + // --------------------------------------------------------------------------- - // Error page + // Error pages // --------------------------------------------------------------------------- function esc(s: string): string { @@ -275,6 +404,430 @@ code{background:#0B0A0F;padding:2px 6px;font-size:12px;color:#A78BFA;font-family `; } + 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 timeoutMs = 30_000; + const controller = new AbortController(); + let settled = false; + let timeoutHandle: ReturnType | null = null; + let bodyChunks: Buffer[] | null = null; + const shouldBufferBody = args.req.method !== "GET" && args.req.method !== "HEAD"; + + const cleanup = () => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + args.req.off("aborted", onRequestAborted); + args.req.off("close", onRequestClose); + args.req.off("error", onRequestError); + if (shouldBufferBody) { + args.req.off("data", onRequestData); + args.req.off("end", onRequestEnd); + } + }; + + const settleResolve = (value: UpstreamResponse) => { + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }; + + const settleReject = (error: Error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + + const abortWithTimeout = () => { + const timeoutError = new Error("Upstream OAuth request timed out after 30 seconds."); + timeoutError.name = "TimeoutError"; + settled = true; + cleanup(); + controller.abort(timeoutError); + reject(timeoutError); + }; + + const onRequestAborted = () => { + if (settled) return; + const abortError = new Error("Client request was aborted before the upstream OAuth request completed."); + abortError.name = "AbortError"; + settleReject(abortError); + controller.abort(abortError); + }; + + const onRequestClose = () => { + if (settled || args.req.complete) return; + const closeError = new Error("Client request closed before the upstream OAuth request completed."); + closeError.name = "AbortError"; + settleReject(closeError); + controller.abort(closeError); + }; + + const onRequestError = (error: Error) => { + settleReject(error); + controller.abort(error); + }; + + const onRequestData = (chunk: Buffer | string) => { + if (!bodyChunks) bodyChunks = []; + bodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }; + + const onRequestEnd = () => { + if (settled) return; + upstreamReq.end(bodyChunks ? Buffer.concat(bodyChunks) : undefined); + }; + + timeoutHandle = setTimeout(abortWithTimeout, timeoutMs); + const upstreamReq = http.request( + { + hostname: "127.0.0.1", + port: args.targetPort, + path: args.overridePath ?? args.req.url, + method: args.req.method, + headers, + signal: controller.signal, + }, + (upstreamRes) => { + const chunks: Buffer[] = []; + upstreamRes.on("data", (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + upstreamRes.on("end", () => { + settleResolve({ + statusCode: upstreamRes.statusCode ?? 502, + headers: upstreamRes.headers, + body: Buffer.concat(chunks), + }); + }); + upstreamRes.once("error", settleReject); + }, + ); + + upstreamReq.once("error", settleReject); + + if (!shouldBufferBody) { + upstreamReq.end(); + return; + } + + args.req.once("aborted", onRequestAborted); + args.req.once("close", onRequestClose); + args.req.once("error", onRequestError); + args.req.on("data", onRequestData); + args.req.once("end", onRequestEnd); + }); + } + + 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 +837,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 urlPath = (req.url ?? "").split("?")[0]; + 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 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 +923,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 +980,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 +1011,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 +1090,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..15cd73b8d 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,13 @@ 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", + actionType: "refresh-preview", + }); } else if (!route) { issues.push({ type: "proxy-route-missing", @@ -180,6 +228,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..57647a02f 100644 --- a/apps/desktop/src/main/services/processes/processService.test.ts +++ b/apps/desktop/src/main/services/processes/processService.test.ts @@ -25,13 +25,11 @@ function makeMinimalConfig(processes: Array<{ command: p.command, cwd: p.cwd ?? ".", env: {}, - readiness: { type: "immediate" as const }, - restart: { policy: "never" as const }, + autostart: false, + restart: "never" as const, + gracefulShutdownMs: 1000, dependsOn: [], - healthCheck: null, - icon: null, - color: null, - description: null, + readiness: { type: "none" as const }, })); return { effective: { @@ -65,32 +63,171 @@ function makeLaneSummary(tmpDir: string, laneId: string) { }; } -/** Wait for a process to fully exit by polling its runtime status. */ -async function waitForExit( - service: ReturnType, - laneId: string, - processId: string, - timeoutMs = 5000, -): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const runtimes = service.listRuntime(laneId); - const rt = runtimes.find((r) => r.processId === processId); - if (rt && (rt.status === "stopped" || rt.status === "crashed")) return; - await new Promise((r) => setTimeout(r, 50)); - } +function createPtyHarness(tmpDir: string) { + const sessionStore = new Map(); + const dataListeners = new Set<(event: { laneId: string; ptyId: string; sessionId: string; data: string }) => void>(); + const exitListeners = new Set<(event: { laneId: string; ptyId: string; sessionId: string; exitCode: number | null }) => void>(); + + const sessionService = { + get: vi.fn((sessionId: string) => sessionStore.get(sessionId) ?? null), + } as any; + + const ptyService = { + create: vi.fn(async (args: any) => { + const transcriptPath = path.join(tmpDir, ".ade", "transcripts", `${args.sessionId}.log`); + fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); + fs.writeFileSync(transcriptPath, "", "utf8"); + const ptyId = `pty-${args.sessionId}`; + sessionStore.set(args.sessionId, { + id: args.sessionId, + laneId: args.laneId, + ptyId, + transcriptPath, + }); + return { + ptyId, + sessionId: args.sessionId, + pid: 4321, + }; + }), + dispose: vi.fn((args: { ptyId: string; sessionId?: string }) => { + const sessionId = args.sessionId ?? Array.from(sessionStore.values()).find((session) => session.ptyId === args.ptyId)?.id; + if (!sessionId) return; + const session = sessionStore.get(sessionId); + if (!session) return; + for (const listener of exitListeners) { + listener({ + laneId: session.laneId, + ptyId: args.ptyId, + sessionId, + exitCode: null, + }); + } + session.ptyId = null; + }), + onData: vi.fn((listener: (event: { laneId: string; ptyId: string; sessionId: string; data: string }) => void) => { + dataListeners.add(listener); + return () => { + dataListeners.delete(listener); + }; + }), + onExit: vi.fn((listener: (event: { laneId: string; ptyId: string; sessionId: string; exitCode: number | null }) => void) => { + exitListeners.add(listener); + return () => { + exitListeners.delete(listener); + }; + }), + } as any; + + const emitData = (sessionId: string, data: string) => { + const session = sessionStore.get(sessionId); + if (!session?.ptyId) throw new Error(`No live PTY for session ${sessionId}`); + fs.appendFileSync(session.transcriptPath, data, "utf8"); + for (const listener of dataListeners) { + listener({ + laneId: session.laneId, + ptyId: session.ptyId, + sessionId, + data, + }); + } + }; + + const emitExit = (sessionId: string, exitCode: number | null) => { + const session = sessionStore.get(sessionId); + if (!session?.ptyId) throw new Error(`No live PTY for session ${sessionId}`); + for (const listener of exitListeners) { + listener({ + laneId: session.laneId, + ptyId: session.ptyId, + sessionId, + exitCode, + }); + } + session.ptyId = null; + }; + + return { sessionService, ptyService, emitData, emitExit }; } -describe("processService start logging", () => { +describe("processService PTY-backed run commands", () => { + it("injects lane runtime env into PTY-backed run commands", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-process-env-")); + const dbPath = path.join(tmpDir, "kv.sqlite"); + const projectId = "proj-env"; + const logger = createLogger(); + const db = await openKvDb(dbPath, createLogger()); + const now = "2026-03-24T12:00:00.000Z"; + const { ptyService, sessionService } = createPtyHarness(tmpDir); + + 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: ["npx", "sst", "dev", "--mode=mono"] }, + ]); + + const service = createProcessService({ + db, + projectId, + logger, + laneService: { + getLaneWorktreePath: () => tmpDir, + list: async () => [makeLaneSummary(tmpDir, "lane-env")], + } as any, + projectConfigService: { + get: () => config, + getEffective: () => config.effective, + getExecutableConfig: () => config.effective, + } as any, + sessionService, + ptyService, + 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" }); + expect(ptyService.create).toHaveBeenCalledWith(expect.objectContaining({ + env: expect.objectContaining({ + PORT: "3001", + PORT_RANGE_START: "3001", + PORT_RANGE_END: "3099", + HOSTNAME: "lane-env.localhost", + PROXY_HOSTNAME: "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"); - const logsDir = path.join(tmpDir, "logs"); const projectId = "proj-startlog"; const logger = createLogger(); - const db = await openKvDb(dbPath, createLogger()); const now = "2026-03-24T12:00:00.000Z"; + const { ptyService, sessionService } = createPtyHarness(tmpDir); + 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], @@ -110,7 +247,6 @@ describe("processService start logging", () => { const service = createProcessService({ db, projectId, - processLogsDir: logsDir, logger, laneService: { getLaneWorktreePath: () => tmpDir, @@ -121,15 +257,14 @@ describe("processService start logging", () => { getEffective: () => config.effective, getExecutableConfig: () => config.effective, } as any, + sessionService, + ptyService, broadcastEvent: () => {}, }); try { const runtime = await service.start({ laneId: "lane-ok", processId: "echo-proc" }); - expect(runtime.status).toMatch(/starting|running|stopped/); - - // Wait for echo to complete before asserting / closing db - await waitForExit(service, "lane-ok", "echo-proc"); + expect(runtime.status).toBe("running"); const infoCalls = logger.info.mock.calls.filter( (call: any[]) => call[0] === "process.start", @@ -149,16 +284,16 @@ describe("processService start logging", () => { } }); - it("transitions to crashed status when the spawned process exits with non-zero code", async () => { + it("transitions to crashed status when the PTY-backed command exits with non-zero code", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-process-error-")); const dbPath = path.join(tmpDir, "kv.sqlite"); - const logsDir = path.join(tmpDir, "logs"); const projectId = "proj-crash"; const logger = createLogger(); const events: any[] = []; - const db = await openKvDb(dbPath, createLogger()); const now = "2026-03-24T12:00:00.000Z"; + const { ptyService, sessionService, emitExit } = createPtyHarness(tmpDir); + 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], @@ -178,7 +313,6 @@ describe("processService start logging", () => { const service = createProcessService({ db, projectId, - processLogsDir: logsDir, logger, laneService: { getLaneWorktreePath: () => tmpDir, @@ -189,17 +323,19 @@ describe("processService start logging", () => { getEffective: () => config.effective, getExecutableConfig: () => config.effective, } as any, + sessionService, + ptyService, broadcastEvent: (ev: any) => events.push(ev), }); try { - await service.start({ laneId: "lane-err", processId: "fail-proc" }); + const runtime = await service.start({ laneId: "lane-err", processId: "fail-proc" }); + expect(runtime.sessionId).toBeTruthy(); - // Wait for the process to exit - await waitForExit(service, "lane-err", "fail-proc"); + emitExit(String(runtime.sessionId), 42); const runtimes = service.listRuntime("lane-err"); - const current = runtimes.find((r) => r.processId === "fail-proc"); + const current = runtimes.find((row) => row.processId === "fail-proc"); expect(current).toBeTruthy(); expect(current!.status).toBe("crashed"); expect(current!.lastExitCode).toBe(42); @@ -211,6 +347,7 @@ describe("processService start logging", () => { expect(runRow).toBeTruthy(); expect(runRow!.exit_code).toBe(42); expect(runRow!.termination_reason).toBe("crashed"); + expect(events.some((event) => event.type === "runtime" && event.runtime.status === "crashed")).toBe(true); } finally { service.disposeAll(); db.close(); @@ -221,12 +358,12 @@ describe("processService start logging", () => { it("rejects process cwd values that escape the lane workspace", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-process-cwd-")); const dbPath = path.join(tmpDir, "kv.sqlite"); - const logsDir = path.join(tmpDir, "logs"); const projectId = "proj-cwd"; const logger = createLogger(); - const db = await openKvDb(dbPath, createLogger()); const now = "2026-03-24T12:00:00.000Z"; + const { ptyService, sessionService } = createPtyHarness(tmpDir); + 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], @@ -246,7 +383,6 @@ describe("processService start logging", () => { const service = createProcessService({ db, projectId, - processLogsDir: logsDir, logger, laneService: { getLaneWorktreePath: () => tmpDir, @@ -257,13 +393,16 @@ describe("processService start logging", () => { getEffective: () => config.effective, getExecutableConfig: () => config.effective, } as any, + sessionService, + ptyService, broadcastEvent: () => {}, }); try { await expect(service.start({ laneId: "lane-cwd", processId: "escape-proc" })).rejects.toThrow( - /cwd escapes lane workspace/, + /cwd must stay within the lane workspace/, ); + expect(ptyService.create).not.toHaveBeenCalled(); } finally { service.disposeAll(); db.close(); diff --git a/apps/desktop/src/main/services/processes/processService.ts b/apps/desktop/src/main/services/processes/processService.ts index b33a826a5..27759d898 100644 --- a/apps/desktop/src/main/services/processes/processService.ts +++ b/apps/desktop/src/main/services/processes/processService.ts @@ -2,8 +2,6 @@ import fs from "node:fs"; import path from "node:path"; import net from "node:net"; import { randomUUID } from "node:crypto"; -import { spawn, type ChildProcessByStdio } from "node:child_process"; -import type { Readable } from "node:stream"; import type { EffectiveProjectConfig, LaneOverlayOverrides, @@ -15,36 +13,36 @@ import type { ProcessRuntime, ProcessRuntimeStatus, ProcessStackArgs, + StackButtonDefinition, StackStartOrder, - StackButtonDefinition } from "../../../shared/types"; +import { stripAnsi } from "../../utils/ansiStrip"; import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; import type { createProjectConfigService } from "../config/projectConfigService"; import type { createLaneService } from "../lanes/laneService"; +import type { createPtyService } from "../pty/ptyService"; +import type { createSessionService } from "../sessions/sessionService"; import { matchLaneOverlayPolicies } from "../config/laneOverlayMatcher"; -import { isWithinDir, fileSizeOrZero, nowIso, resolvePathWithinRoot } from "../shared/utils"; +import { nowIso, resolvePathWithinRoot } from "../shared/utils"; type ManagedTerminationReason = "stopped" | "killed" | "crashed" | "restart"; type ManagedProcessEntry = { laneId: string; runtime: ProcessRuntime; - child: ChildProcessByStdio | null; - processGroupId: number | null; definition: ProcessDefinition | null; runId: string | null; stopIntent: ManagedTerminationReason | null; - logStream: fs.WriteStream | null; - logBytesWritten: number; - logLimitReached: boolean; + sessionId: string | null; + ptyId: string | null; + transcriptPath: string | null; readinessRegex: RegExp | null; readinessTimeout: NodeJS.Timeout | null; readinessInterval: NodeJS.Timeout | null; healthFailures: number; healthInterval: NodeJS.Timeout | null; restartAttempts: number; - gracefulKillTimeout: NodeJS.Timeout | null; }; const READINESS_TIMEOUT_MS = 15_000; @@ -52,8 +50,6 @@ const HEALTH_CHECK_INTERVAL_MS = 2_500; const HEALTH_DEGRADED_AFTER_FAILURES = 2; const RESTART_BACKOFF_BASE_MS = 400; const RESTART_BACKOFF_MAX_MS = 30_000; -const MAX_PROCESS_LOG_BYTES = 10 * 1024 * 1024; -const PROCESS_LOG_LIMIT_NOTICE = "\n[ADE] process log limit reached (10MB). Further output omitted.\n"; function clampMaxBytes(maxBytes: number | undefined, fallback: number): number { if (typeof maxBytes !== "number" || !Number.isFinite(maxBytes)) return fallback; @@ -69,7 +65,7 @@ function readTail(filePath: string, maxBytes: number): string { try { const buf = Buffer.alloc(size - start); fs.readSync(fd, buf, 0, buf.length, start); - return buf.toString("utf8"); + return stripAnsi(buf.toString("utf8")); } finally { fs.closeSync(fd); } @@ -139,128 +135,34 @@ function checkPortReady(port: number): Promise { }); } -function isAliveSignalError(error: unknown): boolean { - return Boolean(error && typeof error === "object" && "code" in error && (error as { code?: string }).code === "EPERM"); -} - -function isProcessIdAlive(pid: number | null): boolean { - if (pid == null || !Number.isInteger(pid) || pid <= 0) return false; - try { - process.kill(pid, 0); - return true; - } catch (error) { - return isAliveSignalError(error); - } -} - -function isProcessGroupAlive(processGroupId: number | null): boolean { - if (process.platform === "win32") return false; - if (processGroupId == null || !Number.isInteger(processGroupId) || processGroupId <= 0) return false; - try { - process.kill(-processGroupId, 0); - return true; - } catch (error) { - return isAliveSignalError(error); - } -} - function keyFor(laneId: string, processId: string): string { return `${laneId}:${processId}`; } -function resolveSafeProcessLogPath(processLogsDir: string, laneId: string, processId: string): string { - const laneSegment = laneId.trim(); - const processSegment = processId.trim(); - if (!laneSegment.length || !processSegment.length) { - throw new Error("laneId and processId are required."); - } - if (laneSegment.includes("\0") || processSegment.includes("\0")) { - throw new Error("Invalid process log path."); - } - if ( - laneSegment.includes("/") || - laneSegment.includes("\\") || - processSegment.includes("/") || - processSegment.includes("\\") - ) { - throw new Error("Invalid process log path."); - } - const resolved = path.resolve(processLogsDir, laneSegment, `${processSegment}.log`); - if (!isWithinDir(processLogsDir, resolved)) { - throw new Error("Invalid process log path."); - } - return resolved; -} - export function createProcessService({ db, projectId, - processLogsDir, logger, laneService, projectConfigService, - broadcastEvent + sessionService, + ptyService, + getLaneRuntimeEnv, + broadcastEvent, }: { db: AdeDb; projectId: string; - processLogsDir: string; logger: Logger; laneService: ReturnType; projectConfigService: ReturnType; + sessionService: ReturnType; + ptyService: Pick, "create" | "dispose" | "onData" | "onExit">; + getLaneRuntimeEnv?: (laneId: string) => Promise> | Record; broadcastEvent: (ev: ProcessEvent) => void; }) { const entries = new Map(); - - const processLogPath = (laneId: string, processId: string) => - resolveSafeProcessLogPath(processLogsDir, laneId, processId); - - const rotateProcessLogIfNeeded = (filePath: string) => { - const currentSize = fileSizeOrZero(filePath); - if (currentSize < MAX_PROCESS_LOG_BYTES) return; - const rotatedPath = `${filePath}.1`; - try { - fs.rmSync(rotatedPath, { force: true }); - } catch { - // ignore - } - try { - fs.renameSync(filePath, rotatedPath); - } catch { - // ignore - } - }; - - const writeProcessLogChunk = (entry: ManagedProcessEntry, chunk: string | Buffer) => { - if (!entry.logStream || entry.logLimitReached) return; - const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8"); - const remaining = MAX_PROCESS_LOG_BYTES - entry.logBytesWritten; - if (remaining <= 0) { - entry.logLimitReached = true; - try { - entry.logStream.write(PROCESS_LOG_LIMIT_NOTICE); - } catch { - // ignore - } - return; - } - if (data.length > remaining) { - try { - entry.logStream.write(data.subarray(0, remaining)); - entry.logBytesWritten += remaining; - entry.logLimitReached = true; - entry.logStream.write(PROCESS_LOG_LIMIT_NOTICE); - } catch { - // ignore - } - return; - } - try { - entry.logStream.write(data); - entry.logBytesWritten += data.length; - } catch { - // ignore - } - }; + const sessionToEntryKey = new Map(); + const ptyToEntryKey = new Map(); const persistRuntime = (runtime: ProcessRuntime) => { db.run( @@ -286,8 +188,8 @@ export function createProcessService({ runtime.endedAt, runtime.lastExitCode, runtime.readiness, - runtime.updatedAt - ] + runtime.updatedAt, + ], ); }; @@ -303,8 +205,8 @@ export function createProcessService({ broadcastEvent({ type: "runtime", runtime: { ...runtime } }); }; - const emitLog = (laneId: string, processId: string, stream: "stdout" | "stderr", chunk: string) => { - broadcastEvent({ type: "log", laneId, processId, stream, chunk, ts: nowIso() }); + const emitLog = (laneId: string, processId: string, chunk: string) => { + broadcastEvent({ type: "log", laneId, processId, stream: "stdout", chunk, ts: nowIso() }); }; const upsertRunStart = (runId: string, laneId: string, processId: string, startedAt: string, logPath: string) => { @@ -313,7 +215,7 @@ export function createProcessService({ insert into process_runs(id, project_id, lane_id, process_key, started_at, ended_at, exit_code, termination_reason, log_path) values (?, ?, ?, ?, ?, null, null, 'stopped', ?) `, - [runId, projectId, laneId, processId, startedAt, logPath] + [runId, projectId, laneId, processId, startedAt, logPath], ); }; @@ -322,7 +224,7 @@ export function createProcessService({ endedAt, exitCode, reason, - runId + runId, ]); }; @@ -345,39 +247,13 @@ export function createProcessService({ entry.healthFailures = 0; }; - const clearKillTimer = (entry: ManagedProcessEntry) => { - if (entry.gracefulKillTimeout) { - clearTimeout(entry.gracefulKillTimeout); - entry.gracefulKillTimeout = null; - } - }; - - const signalManagedProcess = (entry: ManagedProcessEntry, signal: NodeJS.Signals) => { - const processGroupId = process.platform !== "win32" ? entry.processGroupId : null; - if (processGroupId != null) { - try { - process.kill(-processGroupId, signal); - return; - } catch { - // Fall through to direct child signaling if the process group is gone. - } - } - - if (entry.child) { - try { - entry.child.kill(signal); - return; - } catch { - // ignore - } - } - - if (entry.runtime.pid == null) return; - try { - process.kill(entry.runtime.pid, signal); - } catch { - // ignore - } + const bindLiveSession = (entry: ManagedProcessEntry, args: { sessionId: string | null; ptyId: string | null }) => { + if (entry.sessionId) sessionToEntryKey.delete(entry.sessionId); + if (entry.ptyId) ptyToEntryKey.delete(entry.ptyId); + entry.sessionId = args.sessionId; + entry.ptyId = args.ptyId; + if (args.sessionId) sessionToEntryKey.set(args.sessionId, keyFor(entry.laneId, entry.runtime.processId)); + if (args.ptyId) ptyToEntryKey.set(args.ptyId, keyFor(entry.laneId, entry.runtime.processId)); }; const ensureEntry = (laneId: string, processId: string, definition: ProcessDefinition | null): ManagedProcessEntry => { @@ -404,55 +280,50 @@ export function createProcessService({ where project_id = ? and lane_id = ? and process_key = ? limit 1 `, - [projectId, laneId, processId] + [projectId, laneId, processId], ); const hadActiveStatus = - persisted?.status === "running" || - persisted?.status === "starting" || - persisted?.status === "stopping" || - persisted?.status === "degraded"; - - const persistedPid = persisted?.pid ?? null; - const recoveredProcessGroupId = isProcessGroupAlive(persistedPid) ? persistedPid : null; - const recoveredPidAlive = isProcessIdAlive(persistedPid); - const recoveredActive = hadActiveStatus && (recoveredProcessGroupId != null || recoveredPidAlive); + persisted?.status === "running" + || persisted?.status === "starting" + || persisted?.status === "stopping" + || persisted?.status === "degraded"; + const now = nowIso(); const runtime: ProcessRuntime = { laneId, processId, - status: recoveredActive ? persisted?.status ?? "running" : hadActiveStatus ? "exited" : persisted?.status ?? "stopped", + status: hadActiveStatus ? "exited" : persisted?.status ?? "stopped", readiness: persisted?.readiness ?? "unknown", - pid: recoveredActive ? persistedPid : null, - startedAt: recoveredActive ? persisted?.started_at ?? null : hadActiveStatus ? null : persisted?.started_at ?? null, - endedAt: recoveredActive ? null : hadActiveStatus ? now : persisted?.ended_at ?? null, + pid: null, + sessionId: null, + ptyId: null, + startedAt: persisted?.started_at ?? null, + endedAt: hadActiveStatus ? now : persisted?.ended_at ?? null, exitCode: persisted?.exit_code ?? null, lastExitCode: persisted?.exit_code ?? null, - lastEndedAt: recoveredActive ? persisted?.ended_at ?? null : hadActiveStatus ? now : persisted?.ended_at ?? null, + lastEndedAt: hadActiveStatus ? now : persisted?.ended_at ?? null, uptimeMs: null, ports: definition?.readiness.type === "port" ? [definition.readiness.port] : [], - logPath: processLogPath(laneId, processId), - updatedAt: persisted?.updated_at ?? now + logPath: null, + updatedAt: persisted?.updated_at ?? now, }; const entry: ManagedProcessEntry = { laneId, runtime, - child: null, - processGroupId: recoveredProcessGroupId, definition, runId: null, stopIntent: null, - logStream: null, - logBytesWritten: 0, - logLimitReached: false, + sessionId: null, + ptyId: null, + transcriptPath: null, readinessRegex: null, readinessTimeout: null, readinessInterval: null, healthFailures: 0, healthInterval: null, restartAttempts: 0, - gracefulKillTimeout: null }; entries.set(k, entry); persistRuntime(runtime); @@ -496,16 +367,13 @@ export function createProcessService({ if (entry.runtime.status === "starting") entry.runtime.status = "running"; emitRuntime(entry); - // Periodically re-check readiness for port-based processes so we can reflect degraded/recovered states. clearHealthTimers(entry); if (entry.definition?.readiness.type === "port") { const port = entry.definition.readiness.port; entry.healthInterval = setInterval(() => { - if (!entry.child) return; if (entry.runtime.status !== "running" && entry.runtime.status !== "degraded") return; void checkPortReady(port) .then((ok) => { - if (!entry.child) return; if (ok) { if (entry.runtime.readiness !== "ready" || entry.runtime.status === "degraded") { entry.runtime.readiness = "ready"; @@ -580,33 +448,29 @@ export function createProcessService({ const handleProcessExit = (entry: ManagedProcessEntry, processId: string, exitCode: number | null) => { clearReadinessTimers(entry); clearHealthTimers(entry); - clearKillTimer(entry); - const endedAt = nowIso(); - - if (entry.logStream) { - writeProcessLogChunk(entry, `\n# process ended at ${endedAt} exit=${exitCode ?? "null"}\n`); - try { - entry.logStream.end(); - } catch { - // ignore - } - entry.logStream = null; - } + const endedAt = nowIso(); const stopIntent = entry.stopIntent; const reason = stopIntent ?? (exitCode === 0 ? "stopped" : "crashed"); const runtimeStatus: ProcessRuntimeStatus = reason === "crashed" ? "crashed" : "exited"; + const activeSessionId = entry.sessionId; + const activePtyId = entry.ptyId; + + if (activeSessionId) sessionToEntryKey.delete(activeSessionId); + if (activePtyId) ptyToEntryKey.delete(activePtyId); - entry.child = null; entry.stopIntent = null; - entry.processGroupId = null; + entry.ptyId = null; entry.runtime.pid = null; + entry.runtime.ptyId = null; + entry.runtime.sessionId = activeSessionId; entry.runtime.status = runtimeStatus; entry.runtime.readiness = "unknown"; entry.runtime.endedAt = endedAt; entry.runtime.lastEndedAt = endedAt; entry.runtime.exitCode = exitCode; entry.runtime.lastExitCode = exitCode; + entry.runtime.logPath = entry.transcriptPath; emitRuntime(entry); if (entry.runId) { @@ -625,11 +489,10 @@ export function createProcessService({ const policy = entry.definition?.restart ?? "never"; const shouldAutoRestart = - policy === "always" || - ((policy === "on-failure" || policy === "on_crash") && (exitCode == null || exitCode !== 0)); + policy === "always" + || ((policy === "on-failure" || policy === "on_crash") && (exitCode == null || exitCode !== 0)); if (reason === "crashed" || reason === "stopped") { - // Reset restart backoff if the process stayed up for a while. const startedAt = entry.runtime.startedAt; const startedAtMs = startedAt ? Date.parse(startedAt) : NaN; const endedAtMs = Date.parse(endedAt); @@ -651,75 +514,28 @@ export function createProcessService({ } }; - const monitorRecoveredStop = ( - entry: ManagedProcessEntry, - processId: string, - forceKillAtMs: number, - startedAtMs = Date.now(), - sentSigKill = false - ) => { - clearKillTimer(entry); - entry.gracefulKillTimeout = setTimeout(() => { - const alive = entry.processGroupId != null ? isProcessGroupAlive(entry.processGroupId) : isProcessIdAlive(entry.runtime.pid); - if (!alive) { - handleProcessExit(entry, processId, entry.runtime.lastExitCode ?? null); - return; - } - - const shouldForceKill = !sentSigKill && Date.now() - startedAtMs >= forceKillAtMs; - if (shouldForceKill) { - signalManagedProcess(entry, "SIGKILL"); - } - - monitorRecoveredStop( - entry, - processId, - forceKillAtMs, - startedAtMs, - sentSigKill || shouldForceKill - ); - }, 250); - }; - - const attachProcessStreams = ( - entry: ManagedProcessEntry, - laneId: string, - processId: string, - child: ChildProcessByStdio - ) => { - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - - const onChunk = (stream: "stdout" | "stderr", chunk: string) => { - writeProcessLogChunk(entry, chunk); - emitLog(laneId, processId, stream, chunk); - if (entry.definition?.readiness.type === "logRegex" && entry.runtime.status === "starting" && entry.readinessRegex && entry.readinessRegex.test(chunk)) { - markReadinessReady(entry); - } - }; - - child.stdout.on("data", (chunk: string) => onChunk("stdout", chunk)); - child.stderr.on("data", (chunk: string) => onChunk("stderr", chunk)); - }; - const handleProcessStartFailure = (args: { entry: ManagedProcessEntry; laneId: string; definition: ProcessDefinition; runId: string; + sessionId: string; startedAt: string; endedAt: string; - logPath: string; - cwd: string; + logPath: string | null; error: unknown; }) => { - const { entry, laneId, definition, runId, startedAt, endedAt, logPath, cwd, error } = args; + const { entry, laneId, definition, runId, sessionId, startedAt, endedAt, logPath, error } = args; + sessionToEntryKey.delete(sessionId); + if (entry.ptyId) ptyToEntryKey.delete(entry.ptyId); - entry.child = null; - entry.processGroupId = null; entry.runId = null; entry.stopIntent = null; + entry.ptyId = null; + entry.transcriptPath = logPath; entry.runtime.pid = null; + entry.runtime.sessionId = sessionId; + entry.runtime.ptyId = null; entry.runtime.status = "crashed"; entry.runtime.readiness = "unknown"; entry.runtime.startedAt = startedAt; @@ -730,13 +546,12 @@ export function createProcessService({ entry.runtime.logPath = logPath; emitRuntime(entry); - upsertRunStart(runId, laneId, definition.id, startedAt, logPath); + upsertRunStart(runId, laneId, definition.id, startedAt, logPath ?? ""); upsertRunEnd(runId, endedAt, null, "crashed"); logger.warn("process.start_failed", { laneId, processId: definition.id, - cwd, command: definition.command, envPath: process.env.PATH ?? "", envShell: process.env.SHELL ?? "", @@ -748,13 +563,15 @@ export function createProcessService({ const startByDefinition = async ( laneId: string, definition: ProcessDefinition, - opts: { skipTrust?: boolean; overlay?: LaneOverlayOverrides } = {} + opts: { skipTrust?: boolean; overlay?: LaneOverlayOverrides } = {}, ): Promise => { if (!opts.skipTrust) projectConfigService.getExecutableConfig(); const entry = ensureEntry(laneId, definition.id, definition); - if (entry.child && isProcessActive(entry.runtime.status)) return { ...entry.runtime }; + if (entry.ptyId && entry.sessionId && isProcessActive(entry.runtime.status)) return { ...entry.runtime }; - if (!definition.command.length) throw new Error(`Process '${definition.id}' has an empty command`); + if (!definition.command.length || !definition.command[0]?.trim()) { + throw new Error(`Process '${definition.id}' has an empty command`); + } const laneRoot = laneService.getLaneWorktreePath(laneId); const configuredCwd = opts.overlay?.cwd?.trim() ? opts.overlay.cwd : definition.cwd; @@ -762,106 +579,94 @@ 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. - // Many CLI tools (SST, Vite, Next.js) check isTTY and suppress colors; - // these vars override that heuristic so log output stays readable. FORCE_COLOR: "1", TERM: "xterm-256color", + ...laneRuntimeEnv, ...definition.env, - ...(opts.overlay?.env ?? {}) + ...(opts.overlay?.env ?? {}), }; - fs.mkdirSync(path.dirname(processLogPath(laneId, definition.id)), { recursive: true }); const startedAt = nowIso(); const runId = randomUUID(); - const logPath = processLogPath(laneId, definition.id); - rotateProcessLogIfNeeded(logPath); - const logStream = fs.createWriteStream(logPath, { flags: "a" }); + const sessionId = randomUUID(); - let child: ChildProcessByStdio; - try { - child = spawn(definition.command[0]!, definition.command.slice(1), { - cwd, - env, - detached: process.platform !== "win32", - shell: false, - stdio: ["ignore", "pipe", "pipe"] - }); - } catch (err) { - const endedAt = nowIso(); - try { - logStream.write(`\n[process start failure] ${String(err)}\n`); - } catch { - // ignore - } - try { - logStream.end(); - } catch { - // ignore - } - handleProcessStartFailure({ - entry, - laneId, - definition, - runId, - startedAt, - endedAt, - logPath, - cwd, - error: err, - }); - throw err; - } - - entry.child = child; - entry.processGroupId = process.platform !== "win32" ? (child.pid ?? null) : null; + bindLiveSession(entry, { sessionId, ptyId: null }); + entry.transcriptPath = null; entry.definition = definition; - entry.stopIntent = null; entry.runId = runId; - entry.logStream = logStream; - entry.logBytesWritten = fileSizeOrZero(logPath); - entry.logLimitReached = entry.logBytesWritten >= MAX_PROCESS_LOG_BYTES; - writeProcessLogChunk(entry, `\n# process start ${startedAt} cmd=${JSON.stringify(definition.command)} cwd=${cwd}\n`); + entry.stopIntent = null; entry.runtime.status = "starting"; entry.runtime.readiness = "unknown"; - entry.runtime.pid = child.pid ?? null; + entry.runtime.pid = null; + entry.runtime.sessionId = sessionId; + entry.runtime.ptyId = null; entry.runtime.startedAt = startedAt; entry.runtime.endedAt = null; entry.runtime.exitCode = null; entry.runtime.ports = definition.readiness.type === "port" ? [definition.readiness.port] : []; - upsertRunStart(runId, laneId, definition.id, startedAt, logPath); emitRuntime(entry); - setupReadinessChecks(entry, definition); - attachProcessStreams(entry, laneId, definition.id, child); - child.on("spawn", () => { - entry.runtime.pid = child.pid ?? null; + try { + const result = await ptyService.create({ + sessionId, + laneId, + cwd, + cols: 120, + rows: 32, + title: definition.name, + tracked: true, + toolType: "run-shell", + command: definition.command[0], + args: definition.command.slice(1), + env, + }); + + bindLiveSession(entry, { sessionId: result.sessionId, ptyId: result.ptyId }); + const session = sessionService.get(result.sessionId); + entry.transcriptPath = session?.transcriptPath?.trim() || null; + entry.runtime.pid = result.pid; + entry.runtime.sessionId = result.sessionId; + entry.runtime.ptyId = result.ptyId; + entry.runtime.logPath = entry.transcriptPath; + upsertRunStart(runId, laneId, definition.id, startedAt, entry.transcriptPath ?? ""); emitRuntime(entry); - }); - child.on("error", (err) => { - logger.warn("process.child_error", { laneId, processId: definition.id, err: String(err) }); - writeProcessLogChunk(entry, `\n[process error] ${String(err)}\n`); - }); - child.on("close", (code) => { - logger.info("process.exit", { laneId, processId: definition.id, code }); - handleProcessExit(entry, definition.id, code ?? null); - }); + setupReadinessChecks(entry, definition); - logger.info("process.start", { - laneId, - processId: definition.id, - cwd, - command: definition.command, - runId, - envPath: process.env.PATH ?? "", - envShell: process.env.SHELL ?? "", - }); - return { ...entry.runtime }; + logger.info("process.start", { + laneId, + processId: definition.id, + cwd, + command: definition.command, + runId, + envPath: process.env.PATH ?? "", + envShell: process.env.SHELL ?? "", + }); + return { ...entry.runtime }; + } catch (error) { + handleProcessStartFailure({ + entry, + laneId, + definition, + runId, + sessionId, + startedAt, + endedAt: nowIso(), + logPath: sessionService.get(sessionId)?.transcriptPath?.trim() || null, + error, + }); + throw error; + } }; const startById = async (laneId: string, processId: string, opts: { skipTrust?: boolean } = {}) => { @@ -877,37 +682,17 @@ export function createProcessService({ return await startByDefinition(laneId, definition, { ...opts, overlay }); }; - const stopById = async (laneId: string, processId: string, intent: ManagedTerminationReason, force: boolean): Promise => { + const stopById = async (laneId: string, processId: string, intent: ManagedTerminationReason): Promise => { const config = projectConfigService.get(); ensureEntriesForLane(laneId, config.effective); const entry = entries.get(keyFor(laneId, processId)); if (!entry) throw new Error(`Process not found: ${processId}`); - if (!entry.child && entry.processGroupId == null && entry.runtime.pid == null) return { ...entry.runtime }; + if (!entry.ptyId || !entry.sessionId || !isProcessActive(entry.runtime.status)) return { ...entry.runtime }; - clearKillTimer(entry); entry.stopIntent = intent; entry.runtime.status = "stopping"; emitRuntime(entry); - - if (force) { - signalManagedProcess(entry, "SIGKILL"); - if (!entry.child) { - monitorRecoveredStop(entry, processId, 0); - } - return { ...entry.runtime }; - } - - const shutdownMs = Math.max(250, entry.definition?.gracefulShutdownMs ?? 7000); - signalManagedProcess(entry, "SIGTERM"); - if (entry.child) { - entry.gracefulKillTimeout = setTimeout(() => { - if (!entry.child && entry.processGroupId == null) return; - signalManagedProcess(entry, "SIGKILL"); - }, shutdownMs); - } else { - monitorRecoveredStop(entry, processId, shutdownMs); - } - + ptyService.dispose({ ptyId: entry.ptyId, sessionId: entry.sessionId }); return { ...entry.runtime }; }; @@ -934,7 +719,7 @@ export function createProcessService({ const byId = new Map(config.effective.processes.map((p) => [p.id, p] as const)); const known = applyProcessFilter(processIds.filter((id) => byId.has(id)), overlay); const ordered = startOrder === "dependency" ? resolveDependencyOrder(known, byId).reverse() : sortByDefinitions(known, byId).reverse(); - await Promise.all(ordered.map((id) => stopById(laneId, id, "stopped", false).catch(() => {}))); + await Promise.all(ordered.map((id) => stopById(laneId, id, "stopped").catch(() => {}))); }; const stackById = (config: EffectiveProjectConfig, stackId: string): StackButtonDefinition => { @@ -943,6 +728,37 @@ export function createProcessService({ return stack; }; + const unsubscribePtyData = ptyService.onData((event) => { + const entryKey = ptyToEntryKey.get(event.ptyId) ?? sessionToEntryKey.get(event.sessionId); + if (!entryKey) return; + const entry = entries.get(entryKey); + if (!entry) return; + emitLog(entry.laneId, entry.runtime.processId, event.data); + if ( + entry.definition?.readiness.type === "logRegex" + && entry.runtime.status === "starting" + && entry.readinessRegex + && entry.readinessRegex.test(event.data) + ) { + markReadinessReady(entry); + } + }); + + const unsubscribePtyExit = ptyService.onExit((event) => { + const entryKey = ptyToEntryKey.get(event.ptyId) ?? sessionToEntryKey.get(event.sessionId); + if (!entryKey) return; + const entry = entries.get(entryKey); + if (!entry) return; + logger.info("process.exit", { + laneId: entry.laneId, + processId: entry.runtime.processId, + sessionId: event.sessionId, + ptyId: event.ptyId, + code: event.exitCode, + }); + handleProcessExit(entry, entry.runtime.processId, event.exitCode ?? null); + }); + return { listDefinitions(): ProcessDefinition[] { return projectConfigService.get().effective.processes; @@ -970,17 +786,19 @@ export function createProcessService({ }, async stop(arg: ProcessActionArgs): Promise { - return await stopById(arg.laneId, arg.processId, "stopped", false); + return await stopById(arg.laneId, arg.processId, "stopped"); }, async restart(arg: ProcessActionArgs): Promise { const entry = entries.get(keyFor(arg.laneId, arg.processId)); - if (!entry?.child) return await startById(arg.laneId, arg.processId); - return await stopById(arg.laneId, arg.processId, "restart", false); + if (!entry?.ptyId || !entry.sessionId || !isProcessActive(entry.runtime.status)) { + return await startById(arg.laneId, arg.processId); + } + return await stopById(arg.laneId, arg.processId, "restart"); }, async kill(arg: ProcessActionArgs): Promise { - return await stopById(arg.laneId, arg.processId, "killed", true); + return await stopById(arg.laneId, arg.processId, "killed"); }, async startStack(arg: ProcessStackArgs): Promise { @@ -1013,26 +831,27 @@ export function createProcessService({ }, getLogTail({ laneId, processId, maxBytes }: { laneId: string; processId: string; maxBytes?: number }): string { - const safePath = processLogPath(laneId, processId); - return readTail(safePath, clampMaxBytes(maxBytes, 180_000)); + const entry = entries.get(keyFor(laneId, processId)); + const transcriptPath = entry?.transcriptPath ?? entry?.runtime.logPath ?? null; + if (!transcriptPath) return ""; + return readTail(transcriptPath, clampMaxBytes(maxBytes, 180_000)); }, disposeAll() { + unsubscribePtyData(); + unsubscribePtyExit(); for (const entry of entries.values()) { clearReadinessTimers(entry); clearHealthTimers(entry); - clearKillTimer(entry); entry.stopIntent = "killed"; - signalManagedProcess(entry, "SIGKILL"); - if (entry.logStream) { + if (entry.ptyId && entry.sessionId) { try { - entry.logStream.end(); + ptyService.dispose({ ptyId: entry.ptyId, sessionId: entry.sessionId }); } catch { // ignore } - entry.logStream = null; } } - } + }, }; } 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.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 628d42d82..0cb956f6b 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -324,7 +324,7 @@ describe("ptyService", () => { }); describe("create", () => { - it("creates a PTY and returns ptyId and sessionId", async () => { + it("creates a PTY and returns ptyId, sessionId, and pid", async () => { const { service } = createHarness(); const result = await service.create({ laneId: "lane-1", @@ -334,6 +334,52 @@ describe("ptyService", () => { }); expect(result.ptyId).toBe("uuid-1"); expect(result.sessionId).toBe("uuid-2"); + expect(result.pid).toBe(12345); + }); + + it("can spawn a direct command with merged lane env", async () => { + const harness = createHarness(); + const getLaneRuntimeEnv = vi.fn(async () => ({ + PORT: "3100", + HOSTNAME: "lane-1.localhost", + })); + const ptyService = createPtyService({ + projectRoot: "/tmp/test-project", + transcriptsDir: "/tmp/transcripts", + chatSessionsDir: "/tmp/chat-sessions", + laneService: harness.laneService as any, + sessionService: harness.sessionService as any, + getLaneRuntimeEnv, + logger: harness.logger as any, + broadcastData: vi.fn(), + broadcastExit: vi.fn(), + onSessionEnded: vi.fn(), + onSessionRuntimeSignal: vi.fn(), + loadPty: harness.loadPty as any, + }); + + await ptyService.create({ + laneId: "lane-1", + title: "Direct command", + cols: 80, + rows: 24, + command: "npm", + args: ["run", "dev"], + env: { CUSTOM_FLAG: "1" }, + }); + + const ptyLib = harness.loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + expect(ptyLib.spawn).toHaveBeenCalledWith( + "npm", + ["run", "dev"], + expect.objectContaining({ + env: expect.objectContaining({ + PORT: "3100", + HOSTNAME: "lane-1.localhost", + CUSTOM_FLAG: "1", + }), + }), + ); }); it("registers the session via sessionService.create", async () => { diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 01580f207..d6080f1e3 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -41,7 +41,7 @@ export const PTY_AI_TITLE_DEBOUNCE_MS = 6000; const CLI_USER_TITLE_TOOL_TYPES = new Set(["claude", "codex"]); function shouldScheduleOutputSnippetTitle(tool: TerminalToolType | null): boolean { - if (!tool || tool === "shell") return false; + if (!tool || tool === "shell" || tool === "run-shell") return false; return !CLI_USER_TITLE_TOOL_TYPES.has(tool); } @@ -104,6 +104,10 @@ type RuntimeStateEntry = { idleTimer: ReturnType | null; }; +type PtyDataListener = (event: PtyDataEvent & { laneId: string }) => void; + +type PtyExitListener = (event: PtyExitEvent & { laneId: string }) => void; + type ShellSpec = { file: string; args: string[] }; function resolveShellCandidates(): ShellSpec[] { @@ -161,28 +165,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; @@ -273,6 +285,7 @@ export function createPtyService({ sessionService, aiIntegrationService, projectConfigService, + getLaneRuntimeEnv, logger, broadcastData, broadcastExit, @@ -287,6 +300,7 @@ export function createPtyService({ sessionService: ReturnType; aiIntegrationService?: ReturnType; projectConfigService?: ReturnType; + getLaneRuntimeEnv?: (laneId: string) => Promise> | Record; logger: Logger; broadcastData: (ev: PtyDataEvent) => void; broadcastExit: (ev: PtyExitEvent) => void; @@ -302,6 +316,8 @@ export function createPtyService({ }) { const ptys = new Map(); const runtimeStates = new Map(); + const dataListeners = new Set(); + const exitListeners = new Set(); /** Timers for auto-closing tool-typed PTYs when the CLI tool exits back to shell prompt */ const toolAutoCloseTimers = new Map>(); @@ -615,7 +631,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 +639,117 @@ 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; + let fd: number | null = null; + try { + fd = fs.openSync(candidate.filePath, "r"); + const buf = Buffer.alloc(1024); + const bytesRead = fs.readSync(fd, buf, 0, 1024, 0); + 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; + } finally { + if (fd !== null) { + try { + fs.closeSync(fd); + } catch { + // Ignore close errors while scanning best-effort session metadata. + } + } + } + } + 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 +759,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", { @@ -704,7 +852,7 @@ export function createPtyService({ } }); - broadcastExit({ ptyId, sessionId: entry.sessionId, exitCode }); + emitPtyExit(entry, { ptyId, sessionId: entry.sessionId, exitCode }); ptys.delete(ptyId); }; @@ -792,6 +940,30 @@ export function createPtyService({ } }; + const emitPtyData = (entry: PtyEntry, event: PtyDataEvent) => { + broadcastData(event); + const enriched = { ...event, laneId: entry.laneId }; + for (const listener of dataListeners) { + try { + listener(enriched); + } catch { + // ignore listener failures + } + } + }; + + const emitPtyExit = (entry: Pick, event: PtyExitEvent) => { + broadcastExit(event); + const enriched = { ...event, laneId: entry.laneId }; + for (const listener of exitListeners) { + try { + listener(enriched); + } catch { + // ignore listener failures + } + } + }; + return { async create(args: PtyCreateArgs): Promise { const { laneId, title } = args; @@ -880,9 +1052,16 @@ export function createPtyService({ .catch(() => {}); } + const launchEnv = { + ...process.env, + ...((await getLaneRuntimeEnv?.(laneId)) ?? {}), + ...(args.env ?? {}) + }; const shellCandidates = resolveShellCandidates(); let pty: IPty; let selectedShell: ShellSpec | null = null; + const directCommand = typeof args.command === "string" ? args.command.trim() : ""; + const directArgs = Array.isArray(args.args) ? args.args.filter((value): value is string => typeof value === "string") : []; try { const ptyLib = loadPty(); const opts: IWindowsPtyForkOptions = { @@ -890,29 +1069,37 @@ export function createPtyService({ cols, rows, cwd, - env: { ...process.env } + env: launchEnv }; let lastErr: unknown = null; let created: IPty | null = null; - for (const shell of shellCandidates) { + if (directCommand) { try { - created = ptyLib.spawn(shell.file, shell.args, opts); - selectedShell = shell; - break; + created = ptyLib.spawn(directCommand, directArgs, opts); } catch (err) { lastErr = err; - logger.warn("pty.spawn_retry", { - ptyId, - sessionId, - shell: shell.file, - cwd, - toolType: toolTypeHint, - startupCommandPresent: Boolean(startupCommand), - envShell: process.env.SHELL ?? "", - envPath: process.env.PATH ?? "", - resourcesPath: process.resourcesPath ?? "", - err: String(err), - }); + } + } else { + for (const shell of shellCandidates) { + try { + created = ptyLib.spawn(shell.file, shell.args, opts); + selectedShell = shell; + break; + } catch (err) { + lastErr = err; + logger.warn("pty.spawn_retry", { + ptyId, + sessionId, + shell: shell.file, + cwd, + toolType: toolTypeHint, + startupCommandPresent: Boolean(startupCommand), + envShell: process.env.SHELL ?? "", + envPath: process.env.PATH ?? "", + resourcesPath: process.resourcesPath ?? "", + err: String(err), + }); + } } } if (!created) { @@ -926,6 +1113,8 @@ export function createPtyService({ cwd, toolType: toolTypeHint, startupCommandPresent: Boolean(startupCommand), + command: directCommand || null, + args: directArgs, selectedShell: selectedShell?.file ?? null, shellCandidates: shellCandidates.map((shell) => shell.file), envShell: process.env.SHELL ?? "", @@ -1020,7 +1209,7 @@ export function createPtyService({ pty.onData((data) => { writeTranscript(entry, data); updatePreviewThrottled(entry, data); - broadcastData({ ptyId, sessionId, data }); + emitPtyData(entry, { ptyId, sessionId, data }); const prevState = runtimeStates.get(sessionId)?.state ?? "running"; const runtimeState = runtimeStateFromOsc133Chunk(data, prevState); @@ -1168,7 +1357,7 @@ export function createPtyService({ logger.info("pty.create", { ptyId, sessionId, laneId, cwd, shell: selectedShell?.file ?? "unknown" }); - return { ptyId, sessionId }; + return { ptyId, sessionId, pid: pty.pid ?? null }; }, write({ ptyId, data }: { ptyId: string; data: string }): void { @@ -1234,7 +1423,7 @@ export function createPtyService({ // ignore callback failures } summarizeSessionBestEffort(sessionId); - broadcastExit({ ptyId, sessionId, exitCode: null }); + emitPtyExit({ laneId: session.laneId, sessionId }, { ptyId, sessionId, exitCode: null }); if (session.tracked) { try { onSessionEnded?.({ laneId: session.laneId, sessionId, exitCode: null }); @@ -1275,7 +1464,7 @@ export function createPtyService({ } catch { // ignore callback failures } - broadcastExit({ ptyId, sessionId: entry.sessionId, exitCode: null }); + emitPtyExit(entry, { ptyId, sessionId: entry.sessionId, exitCode: null }); ptys.delete(ptyId); if (!entry.tracked) { @@ -1297,6 +1486,20 @@ export function createPtyService({ // ignore } } + }, + + onData(listener: PtyDataListener): () => void { + dataListeners.add(listener); + return () => { + dataListeners.delete(listener); + }; + }, + + onExit(listener: PtyExitListener): () => void { + exitListeners.add(listener); + return () => { + exitListeners.delete(listener); + }; } }; } diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index ecdf04eef..2cfc13e00 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -264,4 +264,40 @@ describe("sessionService resume metadata", () => { activeDisposers.push(async () => db.close()); }); + + it("emits a change event when metadata is updated", async () => { + const projectRoot = makeProjectRoot("ade-session-service-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + insertProjectGraph(db); + const service = createSessionService({ db }); + const events: string[] = []; + + service.create({ + sessionId: "session-4", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Claude Chat", + startedAt: "2026-03-17T00:10:00.000Z", + transcriptPath: "/tmp/session-4.log", + toolType: "claude-chat", + }); + + const unsubscribe = service.onChanged((event) => { + events.push(`${event.reason}:${event.sessionId}`); + }); + + service.updateMeta({ + sessionId: "session-4", + title: "Investigate flaky auth tests", + manuallyNamed: false, + }); + + unsubscribe(); + + expect(events).toEqual(["meta-updated:session-4"]); + + activeDisposers.push(async () => db.close()); + }); }); diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 77617dba1..81a261705 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import type { AdeDb } from "../state/kvDb"; import type { TerminalSessionDetail, + TerminalSessionChangedEvent, TerminalResumeMetadata, TerminalResumeProvider, TerminalRuntimeState, @@ -151,6 +152,18 @@ function parseLaunchMetadataFromCurrentSession( } export function createSessionService({ db }: { db: AdeDb }) { + const changeListeners = new Set<(event: TerminalSessionChangedEvent) => void>(); + + const emitChanged = (event: TerminalSessionChangedEvent): void => { + for (const listener of changeListeners) { + try { + listener(event); + } catch { + // Ignore listener failures so persistence stays best-effort. + } + } + }; + const runtimeStateFromStatus = (status: TerminalSessionStatus): TerminalRuntimeState => { if (status === "running") return "running"; if (status === "disposed") return "killed"; @@ -238,6 +251,13 @@ export function createSessionService({ db }: { db: AdeDb }) { return { list, + onChanged(listener: (event: TerminalSessionChangedEvent) => void): () => void { + changeListeners.add(listener); + return () => { + changeListeners.delete(listener); + }; + }, + reconcileStaleRunningSessions({ endedAt, status, @@ -363,6 +383,7 @@ export function createSessionService({ db }: { db: AdeDb }) { if (sets.length) { params.push(sessionId); db.run(`update terminal_sessions set ${sets.join(", ")} where id = ?`, params); + emitChanged({ sessionId, reason: "meta-updated" }); } const updated = this.get(sessionId); diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index aadd63331..3a47cac29 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -726,6 +726,170 @@ describe("createSyncRemoteCommandService", () => { .rejects.toThrow("chat.steer requires text."); }); + // ========================================================== + // parseAgentChatSendArgs / parseAgentChatSteerArgs extensions + // ========================================================== + + describe("parseAgentChatSendArgs (via chat.send) — new attachment / metadata fields", () => { + it("returns only sessionId and text when no optional metadata is provided", async () => { + await service.execute(makePayload("chat.send", { + sessionId: "sess-1", + text: "plain", + })); + expect(agentChatService.sendMessage).toHaveBeenCalledTimes(1); + const sentArg = agentChatService.sendMessage.mock.calls[0][0] as Record; + expect(sentArg).toEqual({ sessionId: "sess-1", text: "plain" }); + // Explicitly ensure none of the new optional keys leaked in. + expect(sentArg).not.toHaveProperty("displayText"); + expect(sentArg).not.toHaveProperty("attachments"); + expect(sentArg).not.toHaveProperty("reasoningEffort"); + expect(sentArg).not.toHaveProperty("executionMode"); + expect(sentArg).not.toHaveProperty("interactionMode"); + }); + + it("includes valid attachments when path + type are well-formed", async () => { + await service.execute(makePayload("chat.send", { + sessionId: "sess-1", + text: "hello", + attachments: [ + { path: "a", type: "image" }, + { path: "b", type: "file" }, + ], + })); + expect(agentChatService.sendMessage).toHaveBeenCalledWith({ + sessionId: "sess-1", + text: "hello", + attachments: [ + { path: "a", type: "image" }, + { path: "b", type: "file" }, + ], + }); + }); + + it("filters out attachment entries missing a valid path or valid type", async () => { + await service.execute(makePayload("chat.send", { + sessionId: "sess-1", + text: "hello", + attachments: [ + { path: "ok", type: "file" }, + { path: " ", type: "image" }, // whitespace-only path + { path: "no-type" }, // missing type + { path: "bad-type", type: "binary" }, // unknown type + "not-a-record", // not an object + null, + { type: "file" }, // missing path entirely + ], + })); + const sent = agentChatService.sendMessage.mock.calls[0][0] as { attachments?: unknown[] }; + expect(sent.attachments, "only the single valid entry should survive").toEqual([ + { path: "ok", type: "file" }, + ]); + }); + + it("omits attachments entirely when every entry is invalid", async () => { + await service.execute(makePayload("chat.send", { + sessionId: "sess-1", + text: "hello", + attachments: [ + { path: "", type: "file" }, + { type: "image" }, + { path: "x" }, + ], + })); + const sent = agentChatService.sendMessage.mock.calls[0][0] as Record; + expect(sent, "attachments key must be omitted when no valid entries").not.toHaveProperty("attachments"); + }); + + it("ignores non-array attachments values (object, string, undefined)", async () => { + for (const attachments of [{ not: "array" }, "image", 42, null]) { + agentChatService.sendMessage.mockClear(); + await service.execute(makePayload("chat.send", { + sessionId: "sess-1", + text: "hello", + attachments, + })); + const sent = agentChatService.sendMessage.mock.calls[0][0] as Record; + expect(sent, `non-array attachments (${JSON.stringify(attachments)}) must not attach anything`).not.toHaveProperty("attachments"); + } + }); + + it("includes displayText, reasoningEffort, executionMode, interactionMode only when non-empty strings", async () => { + await service.execute(makePayload("chat.send", { + sessionId: "sess-1", + text: "hello", + displayText: "shown to user", + reasoningEffort: "high", + executionMode: "autonomous", + interactionMode: "chat", + })); + expect(agentChatService.sendMessage).toHaveBeenCalledWith({ + sessionId: "sess-1", + text: "hello", + displayText: "shown to user", + reasoningEffort: "high", + executionMode: "autonomous", + interactionMode: "chat", + }); + }); + + it("trims string metadata and omits empty/blank values", async () => { + agentChatService.sendMessage.mockClear(); + await service.execute(makePayload("chat.send", { + sessionId: "sess-1", + text: "hello", + displayText: " padded ", + reasoningEffort: "", + executionMode: " ", + interactionMode: 42, // non-string, must be ignored + })); + const sent = agentChatService.sendMessage.mock.calls[0][0] as Record; + expect(sent.displayText, "displayText should be trimmed").toBe("padded"); + expect(sent, "reasoningEffort empty string should be omitted").not.toHaveProperty("reasoningEffort"); + expect(sent, "executionMode whitespace-only should be omitted").not.toHaveProperty("executionMode"); + expect(sent, "non-string interactionMode should be omitted").not.toHaveProperty("interactionMode"); + }); + }); + + describe("parseAgentChatSteerArgs — new attachments support", () => { + it("includes attachments when present and valid", async () => { + await service.execute(makePayload("chat.steer", { + sessionId: "sess-1", + text: "redirect", + attachments: [ + { path: "img.png", type: "image" }, + { path: "notes.txt", type: "file" }, + ], + })); + expect(agentChatService.steer).toHaveBeenCalledWith({ + sessionId: "sess-1", + text: "redirect", + attachments: [ + { path: "img.png", type: "image" }, + { path: "notes.txt", type: "file" }, + ], + }); + }); + + it("omits attachments when array has no valid entries", async () => { + agentChatService.steer.mockClear(); + await service.execute(makePayload("chat.steer", { + sessionId: "sess-1", + text: "redirect", + attachments: [{ path: "", type: "image" }, { type: "file" }], + })); + const sent = agentChatService.steer.mock.calls[0][0] as Record; + expect(sent, "no valid attachments → key omitted").not.toHaveProperty("attachments"); + expect(sent).toEqual({ sessionId: "sess-1", text: "redirect" }); + }); + + it("still throws when text is missing even if attachments are provided", async () => { + await expect(service.execute(makePayload("chat.steer", { + sessionId: "sess-1", + attachments: [{ path: "x", type: "file" }], + }))).rejects.toThrow("chat.steer requires text."); + }); + }); + it("chat.resume routes to agentChatService.resumeSession", async () => { await service.execute(makePayload("chat.resume", { sessionId: "sess-1", diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index c3371f639..2a1527f53 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -2,6 +2,7 @@ import type { AgentChatCreateArgs, AgentChatApproveArgs, AgentChatDisposeArgs, + AgentChatFileRef, AgentChatGetSummaryArgs, AgentChatListArgs, AgentChatProvider, @@ -122,6 +123,19 @@ function asStringArray(value: unknown): string[] { return value.map((entry) => asTrimmedString(entry)).filter((entry): entry is string => Boolean(entry)); } +function parseAgentChatFileRefs(value: unknown): AgentChatFileRef[] | undefined { + if (!Array.isArray(value)) return undefined; + const attachments: AgentChatFileRef[] = []; + for (const entry of value) { + if (!isRecord(entry)) continue; + const path = asTrimmedString(entry.path); + const type = entry.type === "image" ? "image" : entry.type === "file" ? "file" : null; + if (!path || !type) continue; + attachments.push({ path, type }); + } + return attachments; +} + function requireString(value: unknown, message: string): string { const parsed = asTrimmedString(value); if (!parsed) throw new Error(message); @@ -309,16 +323,24 @@ function parseAgentChatCreateArgs(value: Record): AgentChatCrea } function parseAgentChatSendArgs(value: Record): AgentChatSendArgs { + const attachments = parseAgentChatFileRefs(value.attachments); return { sessionId: requireString(value.sessionId, "chat.send requires sessionId."), text: requireString(value.text, "chat.send requires text."), + ...(asTrimmedString(value.displayText) ? { displayText: asTrimmedString(value.displayText)! } : {}), + ...(attachments?.length ? { attachments } : {}), + ...(asTrimmedString(value.reasoningEffort) ? { reasoningEffort: asTrimmedString(value.reasoningEffort)! } : {}), + ...(asTrimmedString(value.executionMode) ? { executionMode: asTrimmedString(value.executionMode)! as AgentChatSendArgs["executionMode"] } : {}), + ...(asTrimmedString(value.interactionMode) ? { interactionMode: asTrimmedString(value.interactionMode)! as AgentChatSendArgs["interactionMode"] } : {}), }; } function parseAgentChatSteerArgs(value: Record): AgentChatSteerArgs { + const attachments = parseAgentChatFileRefs(value.attachments); return { sessionId: requireString(value.sessionId, "chat.steer requires sessionId."), text: requireString(value.text, "chat.steer requires text."), + ...(attachments?.length ? { attachments } : {}), }; } 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/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index a457d1196..914a83aa3 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -384,6 +384,7 @@ import type { SuggestResolverTargetArgs, SuggestResolverTargetResult, SessionDeltaSummary, + TerminalSessionChangedEvent, StackChainItem, StopTestRunArgs, TerminalSessionDetail, @@ -1059,6 +1060,7 @@ declare global { updateMeta: (args: UpdateSessionMetaArgs) => Promise; readTranscriptTail: (args: ReadTranscriptTailArgs) => Promise; getDelta: (sessionId: string) => Promise; + onChanged: (cb: (ev: TerminalSessionChangedEvent) => void) => () => void; }; agentChat: { list: (args?: AgentChatListArgs) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 257f6ce98..98f36ff60 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -410,6 +410,7 @@ import type { DecodeOAuthStateResult, RunTestSuiteArgs, SessionDeltaSummary, + TerminalSessionChangedEvent, StackChainItem, StopTestRunArgs, TerminalSessionDetail, @@ -1483,6 +1484,14 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.sessionsReadTranscriptTail, args), getDelta: async (sessionId: string): Promise => ipcRenderer.invoke(IPC.sessionsGetDelta, { sessionId }), + onChanged: (cb: (ev: TerminalSessionChangedEvent) => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: TerminalSessionChangedEvent, + ) => cb(payload); + ipcRenderer.on(IPC.sessionsChanged, listener); + return () => ipcRenderer.removeListener(IPC.sessionsChanged, listener); + }, }, agentChat: { list: async ( diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 923ee8cb7..258c7db07 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -2611,7 +2611,7 @@ if (typeof window !== "undefined" && !(window as any).ade) { }), }, pty: { - create: resolvedArg({ ptyId: "mock" }), + create: resolvedArg({ ptyId: "mock", sessionId: "mock-session", pid: 1234 }), write: resolvedArg(undefined), resize: resolvedArg(undefined), dispose: resolvedArg(undefined), 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/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 03b08e28f..91b06d584 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -329,11 +329,23 @@ describe("AgentChatComposer", () => { expect(screen.getByText("2 Questions · codex")).toBeTruthy(); }); - it("disables attachments while steering an active turn", () => { + it("allows attachments while steering an active Codex turn", () => { renderComposer({ turnActive: true }); - expect((screen.getByTitle("Attach files or images (@)") as HTMLButtonElement).disabled).toBe(true); - expect((screen.getByTitle("Upload file from disk") as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByTitle("Attach files or images (@)") as HTMLButtonElement).disabled).toBe(false); + expect((screen.getByTitle("Upload file from disk") as HTMLButtonElement).disabled).toBe(false); + }); + + it("allows attachments while steering an active Claude turn", () => { + renderComposer({ + turnActive: true, + sessionProvider: "claude", + modelId: "anthropic/claude-sonnet-4-6", + availableModelIds: ["anthropic/claude-sonnet-4-6"], + }); + + expect((screen.getByTitle("Attach files or images (@)") as HTMLButtonElement).disabled).toBe(false); + expect((screen.getByTitle("Upload file from disk") as HTMLButtonElement).disabled).toBe(false); }); it("shows inline proof toggle and wires callback", () => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index c13055ea2..59b9e5c71 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -419,7 +419,7 @@ export function AgentChatComposer({ const uploadInputRef = useRef(null); const textareaRef = useRef(null); const fileAddInProgressRef = useRef(false); - const canAttach = !turnActive; + const canAttach = !turnActive || sessionProvider === "claude" || sessionProvider === "codex"; const attachedPaths = useMemo(() => new Set(attachments.map((a) => a.path)), [attachments]); const selectedModel = useMemo(() => getModelById(modelId), [modelId]); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index fa89fab7f..f46f76482 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -253,7 +253,7 @@ describe("AgentChatMessageList transcript rendering", () => { }, ]); - expect(view.container.innerHTML).toContain("max-w-[78ch]"); + expect(view.container.innerHTML).toContain("max-w-[min(96ch,75%)]"); }); it("renders markdown tables inside a dedicated scroll shell", () => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index d755e6d55..64b6fcb93 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -1227,7 +1227,7 @@ function renderEvent(
{ }); }); + it("keeps immediate agent events for a freshly created chat before session refresh catches up", async () => { + const { create, emitChatEvent } = installAdeMocks({ sessions: [] }); + const send = vi.fn().mockImplementation(async ({ sessionId }: { sessionId: string }) => { + emitChatEvent({ + sessionId, + timestamp: "2026-03-24T05:57:46.000Z", + event: { + type: "status", + turnStatus: "started", + turnId: "turn-1", + }, + }); + emitChatEvent({ + sessionId, + timestamp: "2026-03-24T05:57:46.100Z", + event: { + type: "text", + text: "Fresh session reply", + turnId: "turn-1", + messageId: "assistant-1", + }, + }); + emitChatEvent({ + sessionId, + timestamp: "2026-03-24T05:57:46.200Z", + event: { + type: "done", + turnId: "turn-1", + status: "completed", + model: "gpt-5.4", + }, + }); + }); + window.ade.agentChat.send = send as any; + + render( + + + , + ); + + const trigger = await screen.findByRole("button", { name: "Select model" }); + const codexLabel = getModelById("openai/gpt-5.4-codex")?.displayName ?? "GPT-5.4 Codex"; + + fireEvent.click(trigger); + fireEvent.click(await screen.findByRole("button", { name: /^Codex$/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Ship it." } }); + fireEvent.click(screen.getByTitle("Send")); + + await waitFor(() => { + expect(create).toHaveBeenCalled(); + expect(send).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: "created-session", + text: "Ship it.", + })); + }); + + expect(await screen.findByText("Fresh session reply")).toBeTruthy(); + }); + + it("preserves background streamed events when switching back to a chat with same-timestamp transcript entries", async () => { + const primarySession = buildSession("session-1", { + title: "Primary chat", + lastActivityAt: "2026-03-24T05:57:45.700Z", + }); + const backgroundSession = buildSession("session-2", { + title: "Background chat", + lastActivityAt: "2026-03-24T05:57:45.600Z", + }); + const { emitChatEvent } = installAdeMocks({ + sessions: [primarySession, backgroundSession], + }); + window.ade.sessions.readTranscriptTail = vi.fn().mockImplementation(async ({ sessionId }: { sessionId: string }) => { + if (sessionId === "session-2") { + return `${JSON.stringify({ + sessionId: "session-2", + timestamp: "2026-03-24T06:00:00.000Z", + sequence: 1, + event: { + type: "status", + turnStatus: "started", + turnId: "turn-2", + }, + })}\n`; + } + return ""; + }); + + renderTabbedPane(primarySession); + + await screen.findByRole("button", { name: /Primary chat/i }); + await screen.findByRole("button", { name: /Background chat/i }); + + emitChatEvent({ + sessionId: "session-2", + timestamp: "2026-03-24T06:00:00.000Z", + sequence: 2, + event: { + type: "text", + text: "Background output kept streaming", + turnId: "turn-2", + messageId: "assistant-2", + }, + }); + + fireEvent.click(screen.getByRole("button", { name: /Background chat/i })); + + expect(await screen.findByText("Background output kept streaming")).toBeTruthy(); + }); + + it("reloads a previously viewed chat transcript when switching back to recover missed background output", async () => { + const primarySession = buildSession("session-1", { + title: "Primary chat", + lastActivityAt: "2026-03-24T05:57:45.700Z", + }); + const backgroundSession = buildSession("session-2", { + title: "Background chat", + lastActivityAt: "2026-03-24T05:57:45.600Z", + }); + let backgroundTranscript = `${JSON.stringify({ + sessionId: "session-2", + timestamp: "2026-03-24T06:00:00.000Z", + sequence: 1, + event: { + type: "status", + turnStatus: "started", + turnId: "turn-2", + }, + })}\n`; + + installAdeMocks({ + sessions: [primarySession, backgroundSession], + }); + const readTranscriptTail = vi.fn().mockImplementation(async ({ sessionId }: { sessionId: string }) => { + if (sessionId === "session-2") return backgroundTranscript; + return ""; + }); + window.ade.sessions.readTranscriptTail = readTranscriptTail as any; + + renderTabbedPane(primarySession); + + const primaryTab = await screen.findByRole("button", { name: /Primary chat/i }); + const backgroundTab = await screen.findByRole("button", { name: /Background chat/i }); + + fireEvent.click(backgroundTab); + await waitFor(() => { + expect(readTranscriptTail).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "session-2" })); + }); + + fireEvent.click(primaryTab); + + backgroundTranscript += `${JSON.stringify({ + sessionId: "session-2", + timestamp: "2026-03-24T06:00:01.000Z", + sequence: 2, + event: { + type: "text", + text: "Recovered from transcript on revisit", + turnId: "turn-2", + messageId: "assistant-2", + }, + })}\n`; + + fireEvent.click(backgroundTab); + + expect(await screen.findByText("Recovered from transcript on revisit")).toBeTruthy(); + expect(readTranscriptTail).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "session-2" })); + }); + it("shows 'New chat' in the header when no session is selected", async () => { installAdeMocks({ sessions: [] }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index b3a34e3e1..5d6197051 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -61,9 +61,10 @@ import { chatChipToneClass } from "./chatSurfaceTheme"; import { ChatComputerUsePanel } from "./ChatComputerUsePanel"; import { ChatSubagentsPanel } from "./ChatSubagentsPanel"; import { ChatTasksPanel } from "./ChatTasksPanel"; +import { ChatFileChangesPanel } from "./ChatFileChangesPanel"; import { ChatGitToolbar } from "./ChatGitToolbar"; import { ChatTerminalDrawer, ChatTerminalToggle } from "./ChatTerminalDrawer"; -import { deriveChatSubagentSnapshots, deriveTurnDiffSummaries } from "./chatExecutionSummary"; +import { deriveChatSubagentSnapshots, deriveTodoItems, deriveTurnDiffSummaries } from "./chatExecutionSummary"; import { derivePendingInputRequests, type DerivedPendingInput } from "./pendingInput"; import { ProviderModelSelector } from "../shared/ProviderModelSelector"; import { useClickOutside } from "../../hooks/useClickOutside"; @@ -810,6 +811,7 @@ export function AgentChatPane({ }, [optimisticOutgoingMessage, selectedEvents, selectedSessionId]); const selectedSubagentSnapshots = useMemo(() => deriveChatSubagentSnapshots(selectedEvents), [selectedEvents]); const selectedTurnDiffSummaries = useMemo(() => deriveTurnDiffSummaries(selectedEvents), [selectedEvents]); + const selectedTodoItems = useMemo(() => deriveTodoItems(selectedEvents), [selectedEvents]); const pendingInput = selectedSessionId ? (pendingInputsBySession[selectedSessionId]?.[0] ?? null) : null; const selectedSessionAwaitingInput = Boolean(pendingInput) || selectedSession?.awaitingInput === true; const turnActive = selectedSessionId ? (turnActiveBySession[selectedSessionId] ?? false) : false; @@ -1386,8 +1388,17 @@ export function AgentChatPane({ let merged: AgentChatEventEnvelope[]; if (existing.length && parsed.length) { // Find real-time events that are newer than the last transcript entry. - const lastParsedTs = parsed[parsed.length - 1]!.timestamp; - const tail = existing.filter((e) => e.timestamp > lastParsedTs); + // Prefer the monotonic event sequence when available because multiple + // events can share the same millisecond timestamp during streaming. + const lastParsed = parsed[parsed.length - 1]!; + const lastParsedSequence = typeof lastParsed.sequence === "number" ? lastParsed.sequence : null; + const lastParsedTs = lastParsed.timestamp; + const tail = existing.filter((entry) => { + if (lastParsedSequence != null && typeof entry.sequence === "number") { + return entry.sequence > lastParsedSequence; + } + return entry.timestamp > lastParsedTs; + }); merged = tail.length ? [...parsed, ...tail] : parsed; } else if (existing.length) { // No transcript on disk — keep the real-time events as-is. @@ -1608,16 +1619,11 @@ export function AgentChatPane({ useEffect(() => { if (!selectedSessionId) return; - const selectedEventCount = eventsBySessionRef.current[selectedSessionId]?.length ?? 0; - const shouldForceReloadSelectedHistory = - !lockedSingleSessionMode - && selectedEventCount >= MAX_BACKGROUND_CHAT_SESSION_EVENTS - && selectedEventCount < MAX_SELECTED_CHAT_SESSION_EVENTS; if (!lockedSingleSessionMode) { - void loadHistory( - selectedSessionId, - shouldForceReloadSelectedHistory ? { force: true } : undefined, - ); + // Re-read the selected transcript on every tab switch so the selected + // chat can recover from any background event loss instead of relying + // solely on the in-memory background buffer. + void loadHistory(selectedSessionId, { force: true }); return; } const handle = window.setTimeout(() => { @@ -1767,7 +1773,11 @@ export function AgentChatPane({ ) { setOptimisticOutgoingMessage(null); } - if (!knownSessionIdsRef.current.has(envelope.sessionId)) return; + const acceptsEvent = + knownSessionIdsRef.current.has(envelope.sessionId) + || optimisticSessionIdsRef.current.has(envelope.sessionId) + || pendingSelectedSessionIdRef.current === envelope.sessionId; + if (!acceptsEvent) return; pendingEventQueueRef.current.push(envelope); const touchTimestamp = getChatSessionLocalTouchTimestampForEvent(envelope); if (touchTimestamp) { @@ -2058,6 +2068,7 @@ export function AgentChatPane({ }); loadedHistoryRef.current.delete(created.id); optimisticSessionIdsRef.current.add(created.id); + knownSessionIdsRef.current.add(created.id); pendingSelectedSessionIdRef.current = created.id; draftSelectionLockedRef.current = false; touchSession(created.id); @@ -2199,12 +2210,18 @@ export function AgentChatPane({ lastActivityAt: new Date().toISOString(), }); - const steerText = selectedAttachments.length + const steerSupportsAttachments = sessionProvider === "claude" || sessionProvider === "codex"; + const steerAttachments = steerSupportsAttachments ? selectedAttachments : []; + const steerText = selectedAttachments.length && !steerSupportsAttachments ? `${finalText}\n\nAttached context:\n${selectedAttachments.map((entry) => `- ${entry.type}: ${entry.path}`).join("\n")}` : finalText; if (turnActiveBySession[sessionId]) { setOptimisticOutgoingMessage(null); - await window.ade.agentChat.steer({ sessionId, text: steerText }); + await window.ade.agentChat.steer({ + sessionId, + text: steerText, + ...(steerAttachments.length ? { attachments: steerAttachments } : {}), + }); } else { try { setOptimisticOutgoingMessage({ sessionId, envelope: optimisticEnvelope(sessionId) }); @@ -2224,7 +2241,11 @@ export function AgentChatPane({ const sendMsg = sendError instanceof Error ? sendError.message : String(sendError); const isBusy = /turn is already active|already active/i.test(sendMsg); if (isBusy) { - await window.ade.agentChat.steer({ sessionId, text: steerText }); + await window.ade.agentChat.steer({ + sessionId, + text: steerText, + ...(steerAttachments.length ? { attachments: steerAttachments } : {}), + }); } else { throw sendError; } @@ -2280,6 +2301,8 @@ export function AgentChatPane({ const interrupt = useCallback(async () => { if (!selectedSessionId) return; + // Let the stop button disappear immediately while the main-process interrupt finishes. + setTurnActiveBySession((prev) => ({ ...prev, [selectedSessionId]: false })); try { touchSession(selectedSessionId); await window.ade.agentChat.interrupt({ sessionId: selectedSessionId }); @@ -2931,11 +2954,8 @@ export function AgentChatPane({ -{sessionDelta.deletions}
) : null} - {selectedTurnDiffSummaries.length && selectedSessionId ? ( - + {selectedTodoItems.length ? ( + ) : null} {selectedSubagentSnapshots.length ? ( { void interrupt(); } : undefined} /> ) : null} + {selectedTurnDiffSummaries.length && selectedSessionId ? ( + + ) : null} setTerminalDrawerOpen((v) => !v)} diff --git a/apps/desktop/src/renderer/components/chat/BottomDrawerSection.tsx b/apps/desktop/src/renderer/components/chat/BottomDrawerSection.tsx index 975034299..17187b9b7 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/ChatFileChangesPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatFileChangesPanel.tsx new file mode 100644 index 000000000..b93b7038a --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/ChatFileChangesPanel.tsx @@ -0,0 +1,268 @@ +import React, { useCallback, useMemo, useRef, useState } from "react"; +import { + FileCode, + FilePlus, + FileX, + GitDiff, +} from "@phosphor-icons/react"; +import type { + AgentChatGetTurnFileDiffArgs, + FileDiff, + TurnDiffFile, + TurnDiffSummary, +} from "../../../shared/types"; +import { MonacoDiffView } from "../lanes/MonacoDiffView"; +import { cn } from "../ui/cn"; +import { BottomDrawerSection } from "./BottomDrawerSection"; + +/* ── 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 ; + } +} + +const STATUS_BADGE_BY_STATUS: Record = { + A: { label: "A", classes: "bg-emerald-500/15 text-emerald-400/70" }, + D: { label: "D", classes: "bg-red-500/15 text-red-400/70" }, + R: { label: "R", classes: "bg-amber-500/15 text-amber-400/70" }, + C: { label: "C", classes: "bg-purple-500/15 text-purple-400/70" }, +}; +const STATUS_BADGE_DEFAULT = { label: "M", classes: "bg-sky-500/15 text-sky-400/70" }; + +function statusBadge(status: TurnDiffFile["status"]) { + const { label, classes } = STATUS_BADGE_BY_STATUS[status] ?? STATUS_BADGE_DEFAULT; + return ( + + {label} + + ); +} + +/* ── Aggregation ── */ + +type AggregatedFile = TurnDiffFile & { + /** The turn whose SHA pair should be used to fetch the diff. */ + beforeSha: string; + afterSha: string; + turnIndex: number; +}; + +function aggregateFiles(summaries: TurnDiffSummary[]): AggregatedFile[] { + // Summaries arrive in chronological order, so each subsequent turn is the + // "latest" for any file it touches. We keep the first turn's beforeSha and + // advance the afterSha/status/stats as later turns amend the same path. + const byPath = new Map(); + for (let i = 0; i < summaries.length; i++) { + const turn = summaries[i]; + for (const file of turn.files) { + const existing = byPath.get(file.path); + if (existing) { + existing.afterSha = turn.afterSha; + existing.turnIndex = i; + existing.status = file.status; + existing.additions = file.additions; + existing.deletions = file.deletions; + } else { + byPath.set(file.path, { + ...file, + beforeSha: turn.beforeSha, + afterSha: turn.afterSha, + turnIndex: i, + }); + } + } + } + return [...byPath.values()]; +} + +function getDiffCacheKey(args: AgentChatGetTurnFileDiffArgs) { + return [args.sessionId, args.beforeSha, args.afterSha, args.filePath].join("\u0000"); +} + +/* ── Diff pane ── */ + +type DiffLoadState = "idle" | "loading" | "loaded" | "missing" | "error"; + +function renderDiffPane({ + selectedPath, + loadingPath, + activeDiff, + loadState, +}: { + selectedPath: string | null; + loadingPath: string | null; + activeDiff: FileDiff | null; + loadState: DiffLoadState; +}) { + const centered = "flex h-full min-h-[200px] items-center justify-center p-4"; + if (!selectedPath) { + return ( +
+ Select a file to view its diff +
+ ); + } + if (loadingPath === selectedPath) { + return ( +
+ Loading diff... +
+ ); + } + if (activeDiff) { + return ( +
+ +
+ ); + } + return ( +
+ + {loadState === "missing" ? "No diff available" : "Failed to load diff"} + +
+ ); +} + +/* ── Component ── */ + +export const ChatFileChangesPanel = React.memo(function ChatFileChangesPanel({ + summaries, + sessionId, +}: { + summaries: TurnDiffSummary[]; + sessionId: string; +}) { + const [expanded, setExpanded] = useState(false); + const [selectedPath, setSelectedPath] = useState(null); + const [loadingPath, setLoadingPath] = useState(null); + const diffCache = useRef>({}); + const latestDiffRequestKey = useRef(null); + const [activeDiff, setActiveDiff] = useState(null); + const [activeDiffLoadState, setActiveDiffLoadState] = useState("idle"); + + const files = useMemo(() => aggregateFiles(summaries), [summaries]); + const totalAdditions = useMemo(() => files.reduce((sum, file) => sum + file.additions, 0), [files]); + const totalDeletions = useMemo(() => files.reduce((sum, file) => sum + file.deletions, 0), [files]); + + const handleSelectFile = useCallback( + async (filePath: string) => { + setSelectedPath(filePath); + + const file = files.find((f) => f.path === filePath); + if (!file) return; + + const args: AgentChatGetTurnFileDiffArgs = { + sessionId, + beforeSha: file.beforeSha, + afterSha: file.afterSha, + filePath, + }; + const cacheKey = getDiffCacheKey(args); + + latestDiffRequestKey.current = cacheKey; + + const cachedDiff = diffCache.current[cacheKey]; + if (cachedDiff) { + setActiveDiff(cachedDiff); + setActiveDiffLoadState("loaded"); + setLoadingPath(null); + return; + } + + setLoadingPath(filePath); + setActiveDiff(null); + setActiveDiffLoadState("loading"); + + try { + const diff = await window.ade.agentChat.getTurnFileDiff(args); + if (latestDiffRequestKey.current !== cacheKey) return; + if (!diff) { + setActiveDiff(null); + setActiveDiffLoadState("missing"); + return; + } + diffCache.current[cacheKey] = diff; + setActiveDiff(diff); + setActiveDiffLoadState("loaded"); + } catch (err) { + if (latestDiffRequestKey.current !== cacheKey) return; + console.error("[ChatFileChangesPanel] Failed to fetch diff for", filePath, err); + setActiveDiffLoadState("error"); + } finally { + if (latestDiffRequestKey.current === cacheKey) { + setLoadingPath(null); + } + } + }, + [sessionId, files], + ); + + if (!files.length) return null; + + const summaryContent = ( + + {files.length} file{files.length !== 1 ? "s" : ""} + {totalAdditions > 0 && +{totalAdditions}} + {totalDeletions > 0 && -{totalDeletions}} + + ); + + return ( + setExpanded((v) => !v)} + > +
+ {/* File list (left pane) */} +
+ {files.map((file) => { + const isSelected = selectedPath === file.path; + return ( + + ); + })} +
+ + {/* Diff viewer (right pane) */} +
+ {renderDiffPane({ selectedPath, loadingPath, activeDiff, loadState: activeDiffLoadState })} +
+
+
+ ); +}); 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..afbdee7ca 100644 --- a/apps/desktop/src/renderer/components/chat/ChatTasksPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatTasksPanel.tsx @@ -1,191 +1,78 @@ -import React, { useCallback, useMemo, useRef, useState } from "react"; +import React, { useMemo, useState } from "react"; import { - FileCode, - FilePlus, - FileX, + CheckCircle, + Circle, + CircleNotch, ListChecks, } from "@phosphor-icons/react"; -import type { - AgentChatGetTurnFileDiffArgs, - FileDiff, - TurnDiffFile, - TurnDiffSummary, -} from "../../../shared/types"; -import { MonacoDiffView } from "../lanes/MonacoDiffView"; +import type { TodoItemSnapshot } from "./chatExecutionSummary"; import { cn } from "../ui/cn"; import { BottomDrawerSection } from "./BottomDrawerSection"; -/* ── Helpers ── */ +/* ── Status visuals ── */ -function basename(filePath: string): string { - const normalized = filePath.replace(/\\/g, "/"); - return normalized.split("/").pop() ?? normalized; -} - -function statusIcon(status: TurnDiffFile["status"]) { +function statusIcon(status: TodoItemSnapshot["status"]) { switch (status) { - case "A": - return ; - case "D": - return ; + case "completed": + return ; + case "in_progress": + return ; default: - return ; + return ; } } -function statusBadge(status: TurnDiffFile["status"]) { +function statusTextClass(status: TodoItemSnapshot["status"]) { switch (status) { - case "A": - return ( - - A - - ); - case "D": - return ( - - D - - ); - case "R": - return ( - - R - - ); - case "C": - return ( - - C - - ); + case "completed": + return "text-fg/40 line-through decoration-fg/15"; + case "in_progress": + return "text-fg/75"; default: - return ( - - M - - ); + return "text-fg/55"; } } -/* ── Aggregation ── */ - -type AggregatedFile = TurnDiffFile & { - /** The turn whose SHA pair should be used to fetch the diff. */ - beforeSha: string; - afterSha: string; - turnIndex: number; +// In-progress first, then pending, then completed. +const STATUS_SORT_ORDER: Record = { + in_progress: 0, + pending: 1, + completed: 2, }; -function aggregateFiles(summaries: TurnDiffSummary[]): AggregatedFile[] { - const byPath = new Map(); - for (let i = 0; i < summaries.length; i++) { - const turn = summaries[i]; - for (const file of summaries[i].files) { - const existing = byPath.get(file.path); - if (existing) { - if (i < existing.turnIndex) { - existing.beforeSha = turn.beforeSha; - } - if (i >= existing.turnIndex) { - existing.afterSha = turn.afterSha; - existing.turnIndex = i; // use latest turn - existing.status = file.status; - existing.additions = file.additions; - existing.deletions = file.deletions; - } - } else { - byPath.set(file.path, { - ...file, - beforeSha: turn.beforeSha, - afterSha: turn.afterSha, - turnIndex: i, - }); - } - } - } - return [...byPath.values()]; -} - -function getDiffCacheKey(args: AgentChatGetTurnFileDiffArgs) { - return [args.sessionId, args.beforeSha, args.afterSha, args.filePath].join("\u0000"); -} - /* ── Component ── */ export const ChatTasksPanel = React.memo(function ChatTasksPanel({ - summaries, - sessionId, + items, }: { - summaries: TurnDiffSummary[]; - sessionId: string; + items: TodoItemSnapshot[]; }) { - const [expanded, setExpanded] = useState(false); - const [selectedPath, setSelectedPath] = useState(null); - const [loadingPath, setLoadingPath] = useState(null); - const diffCache = useRef>({}); - const latestDiffRequestKey = useRef(null); - const [activeDiff, setActiveDiff] = useState(null); - - const files = useMemo(() => aggregateFiles(summaries), [summaries]); - const totalAdditions = useMemo(() => files.reduce((sum, file) => sum + file.additions, 0), [files]); - const totalDeletions = useMemo(() => files.reduce((sum, file) => sum + file.deletions, 0), [files]); - - const handleSelectFile = useCallback( - async (filePath: string) => { - setSelectedPath(filePath); - - const file = files.find((f) => f.path === filePath); - if (!file) return; - - const args: AgentChatGetTurnFileDiffArgs = { - sessionId, - beforeSha: file.beforeSha, - afterSha: file.afterSha, - filePath, - }; - const cacheKey = getDiffCacheKey(args); - - latestDiffRequestKey.current = cacheKey; - - const cachedDiff = diffCache.current[cacheKey]; - if (cachedDiff) { - setActiveDiff(cachedDiff); - setLoadingPath(null); - return; - } - - setLoadingPath(filePath); - setActiveDiff(null); + const [expanded, setExpanded] = useState(true); + + const { completedCount, inProgressCount, pendingCount } = useMemo(() => { + let completed = 0; + let inProgress = 0; + let pending = 0; + for (const item of items) { + if (item.status === "completed") completed++; + else if (item.status === "in_progress") inProgress++; + else pending++; + } + return { completedCount: completed, inProgressCount: inProgress, pendingCount: pending }; + }, [items]); - try { - const diff = await window.ade.agentChat.getTurnFileDiff(args); - if (latestDiffRequestKey.current !== cacheKey) return; - if (!diff) { - setActiveDiff(null); - return; - } - diffCache.current[cacheKey] = diff; - setActiveDiff(diff); - } catch (err) { - if (latestDiffRequestKey.current !== cacheKey) return; - console.error("[ChatTasksPanel] Failed to fetch diff for", filePath, err); - } finally { - if (latestDiffRequestKey.current === cacheKey) { - setLoadingPath(null); - } - } - }, - [sessionId, files], + const sortedItems = useMemo( + () => [...items].sort((a, b) => STATUS_SORT_ORDER[a.status] - STATUS_SORT_ORDER[b.status]), + [items], ); - if (!files.length) return null; + if (!items.length) return null; const summaryContent = ( - - {files.length} file{files.length !== 1 ? "s" : ""} - {totalAdditions > 0 && +{totalAdditions}} - {totalDeletions > 0 && -{totalDeletions}} + + {completedCount}/{items.length} complete + {inProgressCount > 0 ? {inProgressCount} active : null} + {pendingCount > 0 ? {pendingCount} pending : null} ); @@ -197,58 +84,23 @@ export const ChatTasksPanel = React.memo(function ChatTasksPanel({ expanded={expanded} onToggle={() => setExpanded((v) => !v)} > -
- {/* File list (left pane) */} -
- {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 -
- )} -
+
+ {sortedItems.map((item) => ( +
+ + {statusIcon(item.status)} + + + {item.description} + +
+ ))}
); 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/chat/chatExecutionSummary.test.ts b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts index ef9dd95d9..dc0d4d69b 100644 --- a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { AgentChatEventEnvelope } from "../../../shared/types"; -import { deriveChatSubagentSnapshots } from "./chatExecutionSummary"; +import { deriveChatSubagentSnapshots, deriveTodoItems } from "./chatExecutionSummary"; describe("deriveChatSubagentSnapshots", () => { it("keeps running subagents ahead of completed ones and preserves descriptions", () => { @@ -147,3 +147,145 @@ describe("deriveChatSubagentSnapshots", () => { ]); }); }); + +describe("deriveTodoItems", () => { + it("returns an empty array when there are no events", () => { + expect(deriveTodoItems([])).toEqual([]); + }); + + it("returns an empty array when no todo_update events are present", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:00.000Z", + event: { type: "text", text: "hello" }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:01.000Z", + event: { + type: "subagent_started", + taskId: "task-1", + description: "unrelated", + }, + }, + ]; + + expect(deriveTodoItems(events)).toEqual([]); + }); + + it("projects a single todo_update to {id, description, status} without leaking extra fields", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:00.000Z", + event: { + type: "todo_update", + turnId: "turn-1", + items: [ + { + id: "todo-1", + description: "Investigate flaky test", + status: "in_progress", + // extra field that must NOT leak through + ...({ priority: "high" } as unknown as Record), + }, + { + id: "todo-2", + description: "Write fix", + status: "pending", + }, + ], + }, + }, + ]; + + const result = deriveTodoItems(events); + + expect(result).toEqual([ + { id: "todo-1", description: "Investigate flaky test", status: "in_progress" }, + { id: "todo-2", description: "Write fix", status: "pending" }, + ]); + for (const item of result) { + expect(Object.keys(item).sort()).toEqual(["description", "id", "status"]); + } + }); + + it("returns only the last todo_update when multiple are present (each update fully replaces)", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:00.000Z", + event: { + type: "todo_update", + items: [ + { id: "old-1", description: "Old item 1", status: "pending" }, + { id: "old-2", description: "Old item 2", status: "in_progress" }, + { id: "old-3", description: "Old item 3", status: "completed" }, + ], + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:01.000Z", + event: { type: "text", text: "thinking..." }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:02.000Z", + event: { + type: "todo_update", + items: [ + { id: "new-1", description: "New plan step", status: "in_progress" }, + ], + }, + }, + ]; + + expect(deriveTodoItems(events)).toEqual([ + { id: "new-1", description: "New plan step", status: "in_progress" }, + ]); + }); + + it("preserves item order within the latest todo_update", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:00.000Z", + event: { + type: "todo_update", + items: [ + { id: "c", description: "Third", status: "pending" }, + { id: "a", description: "First", status: "completed" }, + { id: "b", description: "Second", status: "in_progress" }, + ], + }, + }, + ]; + + expect(deriveTodoItems(events).map((item) => item.id)).toEqual(["c", "a", "b"]); + }); + + it("preserves pending, in_progress, and completed status values", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:00.000Z", + event: { + type: "todo_update", + items: [ + { id: "t1", description: "Pending item", status: "pending" }, + { id: "t2", description: "Active item", status: "in_progress" }, + { id: "t3", description: "Done item", status: "completed" }, + ], + }, + }, + ]; + + expect(deriveTodoItems(events).map((item) => item.status)).toEqual([ + "pending", + "in_progress", + "completed", + ]); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts index 75179514f..a7afa4c59 100644 --- a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts +++ b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts @@ -96,6 +96,31 @@ export function deriveTurnDiffSummaries(events: AgentChatEventEnvelope[]): TurnD return summaries; } +export type TodoItemSnapshot = { + id: string; + description: string; + status: "pending" | "in_progress" | "completed"; +}; + +/** + * Returns the latest todo_update snapshot from the event stream. + * Each todo_update replaces the full list, so we just take the last one. + */ +export function deriveTodoItems(events: AgentChatEventEnvelope[]): TodoItemSnapshot[] { + let latest: TodoItemSnapshot[] = []; + for (const envelope of events) { + const event = envelope.event; + if (event.type === "todo_update") { + latest = event.items.map((item) => ({ + id: item.id, + description: item.description, + status: item.status, + })); + } + } + return latest; +} + export type SubagentTimelineEntry = { timestamp: string; type: "started" | "progress" | "result"; diff --git a/apps/desktop/src/renderer/components/cto/AgentSidebar.tsx b/apps/desktop/src/renderer/components/cto/AgentSidebar.tsx index 9283ebfde..c552e8452 100644 --- a/apps/desktop/src/renderer/components/cto/AgentSidebar.tsx +++ b/apps/desktop/src/renderer/components/cto/AgentSidebar.tsx @@ -4,6 +4,7 @@ import type { AgentIdentity, AgentBudgetSnapshot } from "../../../shared/types"; import { AgentStatusDot } from "./shared/AgentStatusBadge"; import { Button } from "../ui/Button"; import { cn } from "../ui/cn"; +import { SmartTooltip } from "../ui/SmartTooltip"; function dollars(cents: number): string { return `$${(cents / 100).toFixed(2)}`; @@ -32,15 +33,13 @@ const AgentRow = React.memo(function AgentRow({ onClick={() => onSelectAgent(agent.id)} data-testid={`worker-row-${agent.id}`} className={cn( - "group w-full rounded-2xl text-left px-3.5 py-3 transition-all duration-200", + "group w-full rounded-lg text-left px-3.5 py-2.5 transition-all duration-150", isSelected - ? "bg-[linear-gradient(180deg,rgba(56,189,248,0.12),rgba(56,189,248,0.06))]" - : "hover:bg-white/[0.03]", + ? "bg-accent/8 border border-accent/20" + : "hover:bg-white/[0.03] border border-transparent", )} style={{ paddingLeft: `${14 + depth * 18}px`, - border: isSelected ? "1px solid rgba(56, 189, 248, 0.22)" : "1px solid rgba(255,255,255,0.04)", - boxShadow: isSelected ? "0 16px 32px rgba(0, 0, 0, 0.18)" : undefined, }} >
@@ -72,7 +71,7 @@ const AgentRow = React.memo(function AgentRow({
{isSelected && ( - + )}
@@ -162,7 +161,7 @@ export const AgentSidebar = React.memo(function AgentSidebar({ width: 252, minWidth: 252, borderRight: "1px solid rgba(255, 255, 255, 0.06)", - background: "linear-gradient(180deg, rgba(8, 12, 19, 0.96), rgba(6, 10, 16, 0.94))", + background: "var(--work-sidebar-bg)", }} >
@@ -181,34 +180,28 @@ export const AgentSidebar = React.memo(function AgentSidebar({ type="button" onClick={onSelectCto} className={cn( - "w-full rounded-[22px] text-left px-3.5 py-3.5 transition-all duration-200", + "w-full rounded-lg text-left px-3.5 py-3 transition-all duration-150", isCtoSelected - ? "bg-[linear-gradient(180deg,rgba(56,189,248,0.16),rgba(56,189,248,0.08))]" - : "hover:bg-white/[0.03]", + ? "bg-accent/8 border border-accent/20" + : "hover:bg-white/[0.03] border border-transparent", )} - style={isCtoSelected - ? { - border: "1px solid rgba(56, 189, 248, 0.22)", - boxShadow: "0 16px 34px rgba(0, 0, 0, 0.22)", - } - : { border: "1px solid rgba(255,255,255,0.05)" }} >
-
- +
+
CTO
- + Persistent
- Always-on technical lead for this project. + Always-on technical lead.
{ctoModelInfo && ( -
+
{ctoModelInfo.provider}/{ctoModelInfo.model}
)} @@ -228,16 +221,18 @@ export const AgentSidebar = React.memo(function AgentSidebar({
- + + +
@@ -246,13 +241,12 @@ export const AgentSidebar = React.memo(function AgentSidebar({
{workerTree.length === 0 ? (
- +
No workers yet
diff --git a/apps/desktop/src/renderer/components/cto/CtoPage.tsx b/apps/desktop/src/renderer/components/cto/CtoPage.tsx index 469f6f892..fcd5cef83 100644 --- a/apps/desktop/src/renderer/components/cto/CtoPage.tsx +++ b/apps/desktop/src/renderer/components/cto/CtoPage.tsx @@ -16,7 +16,6 @@ import type { CtoIdentity, CtoOnboardingState, CtoSessionLogEntry, - CtoSubordinateActivityEntry, AgentStatus, AgentChatSessionSummary, ChatSurfacePresentation, @@ -36,18 +35,18 @@ import { CtoSettingsPanel } from "./CtoSettingsPanel"; 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 { shellBodyCls } from "./shared/designTokens"; +import { SmartTooltip } from "../ui/SmartTooltip"; /* ── 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: "settings", label: "Settings", icon: Gear, color: "#F472B6" }, +const TABS: { id: TabId; label: string; icon: React.ElementType; color: string; tooltip: string }[] = [ + { id: "chat", label: "Chat", icon: ChatCircle, color: "#A78BFA", tooltip: "Chat directly with the CTO or a selected worker." }, + { id: "team", label: "Team", icon: UsersThree, color: "#60A5FA", tooltip: "Manage workers — hire, configure, and monitor your AI team." }, + { id: "workflows", label: "Workflows", icon: GitBranch, color: "#34D399", tooltip: "Automated pipelines that route Linear issues to workers." }, + { id: "settings", label: "Settings", icon: Gear, color: "#F472B6", tooltip: "CTO identity, project brief, and integration settings." }, ]; function splitTrimmed(value: string): string[] { @@ -57,18 +56,21 @@ function splitTrimmed(value: string): string[] { .filter(Boolean); } -function summarizeList(values: string[] | null | undefined, emptyFallback: string): string { - const entries = (values ?? []).map((value) => value.trim()).filter(Boolean); - if (!entries.length) return emptyFallback; - return entries.slice(0, 3).join(" · "); -} - function summarizeText(value: string | null | undefined, fallback: string): string { const normalized = value?.trim(); if (!normalized) return fallback; return normalized.length > 180 ? `${normalized.slice(0, 177).trimEnd()}...` : normalized; } +function statusDotCls(status: AgentStatus): string { + switch (status) { + case "running": return "bg-info animate-pulse"; + case "active": return "bg-success"; + case "paused": return "bg-warning"; + default: return "bg-muted-fg/40"; + } +} + /* ── Main Page ── */ export function CtoPage() { @@ -83,7 +85,6 @@ export function CtoPage() { const [ctoIdentity, setCtoIdentity] = useState(null); const [coreMemory, setCoreMemory] = useState(null); const [sessionLogs, setSessionLogs] = useState([]); - const [subordinateActivity, setSubordinateActivity] = useState([]); const [openclawStatus, setOpenclawStatus] = useState(null); // Onboarding state @@ -164,7 +165,6 @@ export function CtoPage() { setCtoIdentity(snapshot.identity); setCoreMemory(snapshot.coreMemory); setSessionLogs(snapshot.recentSessions); - setSubordinateActivity(snapshot.recentSubordinateActivity); ctoHistoryLoadedRef.current = true; } catch { // non-fatal @@ -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"); } @@ -556,16 +556,6 @@ export function CtoPage() { /* ── Render ── */ - const teamStats = useMemo(() => { - const counts = { - total: agents.length, - active: agents.filter((agent) => agent.status === "active").length, - running: agents.filter((agent) => agent.status === "running").length, - paused: agents.filter((agent) => agent.status === "paused").length, - }; - return counts; - }, [agents]); - const persistentIdentityPresentation = useMemo(() => ({ mode: "standard", profile: "persistent_identity", @@ -598,7 +588,6 @@ export function CtoPage() { const handleSelectSidebarAgent = useCallback((id: string) => { setSelectedAgentId(id); - setActiveTab("team"); }, []); const handleSelectSidebarCto = useCallback(() => { @@ -606,37 +595,16 @@ export function CtoPage() { setActiveTab("chat"); }, []); - const bridgeSummary = useMemo(() => { - if (!openclawStatus) return "Local ADE runtime"; - if (openclawStatus.state === "connected") return "Bridge connected"; - if (openclawStatus.state === "connecting" || openclawStatus.state === "reconnecting") return "Bridge warming up"; - return "Bridge offline"; - }, [openclawStatus]); - - const currentBrainSummary = useMemo(() => ( - session - ? [session.provider, session.model].filter(Boolean).join(" / ") - : selectedWorker - ? [selectedWorker.adapterType, String((selectedWorker.adapterConfig as { model?: string } | null)?.model ?? "adaptive")].join(" / ") - : [ctoIdentity?.modelPreferences.provider, ctoIdentity?.modelPreferences.model].filter(Boolean).join(" / ") - ), [ctoIdentity?.modelPreferences.model, ctoIdentity?.modelPreferences.provider, selectedWorker, session]); - - const focusSummary = useMemo(() => ( - selectedWorker - ? summarizeList(selectedWorker.capabilities, "Generalist execution") - : summarizeList(coreMemory?.activeFocus, "No focus saved yet") - ), [coreMemory?.activeFocus, selectedWorker]); + const currentBrainSummary = useMemo(() => { + if (session) return [session.provider, session.model].filter(Boolean).join(" / "); + if (selectedWorker) { + const workerModel = String((selectedWorker.adapterConfig as { model?: string } | null)?.model ?? "adaptive"); + return [selectedWorker.adapterType, workerModel].join(" / "); + } + return [ctoIdentity?.modelPreferences.provider, ctoIdentity?.modelPreferences.model].filter(Boolean).join(" / "); + }, [ctoIdentity?.modelPreferences.model, ctoIdentity?.modelPreferences.provider, selectedWorker, session]); const pageTitle = selectedWorker ? selectedWorker.name : ctoDisplayName; - const pageSubtitle = selectedWorker - ? summarizeText( - selectedWorker.title || summarizeList(selectedWorker.capabilities, ""), - "Persistent worker session with durable memory and delegated execution context.", - ) - : summarizeText( - coreMemory?.projectSummary, - "Your persistent project CTO with layered memory, durable context, and full ADE reach.", - ); return (
@@ -670,118 +638,51 @@ 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} -
- ))} -
+ {/* Minimal single-row header */} +
+ {/* Left: Avatar + name */} +
+
+ + {pageTitle.charAt(0).toUpperCase()} + +
+ {pageTitle} +
- {/* Right: Bridge/brain status */} -
- {[ - { label: bridgeSummary, tone: openclawStatus?.state === "connected" ? "#34D399" : "#38BDF8" }, - { label: currentBrainSummary || "Brain not set", tone: selectedWorker ? "#60A5FA" : "#38BDF8" }, - ].map((item) => ( -
+ {TABS.map(({ id, label, icon: Icon, tooltip }) => { + const active = activeTab === id; + return ( + +
- ))} -
-
+ + {label} + + + ); + })} +
- {/* Tab bar */} -
-
- {TABS.map(({ id, label, icon: Icon, color }) => { - const active = activeTab === id; - return ( - - ); - })} -
+ {/* Right: Model badge */} + {currentBrainSummary ? ( +
+ {currentBrainSummary}
-
+ ) : null}
{/* Tab content */} @@ -799,7 +700,7 @@ export function CtoPage() {
{showOnboarding || needsOnboarding ? ( @@ -865,12 +766,12 @@ export function CtoPage() {
) : agents.length === 0 ? (
-
- +
+
-
No workers yet
-
- Hire autonomous workers from templates or build custom ones. +
No workers yet
+
+ Hire workers to delegate tasks. Each worker gets its own chat session, memory, and model.
-
- -
- {[ - { label: "Workers", value: String(teamStats.total) }, - { label: "Active", value: String(teamStats.active) }, - { label: "Running", value: String(teamStats.running) }, - { label: "Paused", value: String(teamStats.paused) }, - ].map((item) => ( -
-
{item.label}
-
{item.value}
-
- ))} +
Team
+ + +
-
-
-
Recent Activity
-
-
- {subordinateActivity.length === 0 ? ( -
No department activity recorded yet.
- ) : subordinateActivity.map((entry) => ( - - ))} -
+
+ {agents.map((agent) => { + const workerBudget = budgetSnapshot?.workers.find((w) => w.agentId === agent.id); + const modelId = (agent.adapterConfig as { model?: string } | null)?.model; + return ( + + +
+
+ + ); + })}
) : ( +
+ void rollbackRevision(id)} onSaveCoreMemory={handleSaveWorkerCoreMemory} /> +
)}
)} @@ -946,7 +871,7 @@ export function CtoPage() { )} {/* Linear tab */} - {activeTab === "linear" && } + {activeTab === "workflows" && } {/* Settings tab */} {activeTab === "settings" && ( diff --git a/apps/desktop/src/renderer/components/cto/CtoSettingsPanel.test.tsx b/apps/desktop/src/renderer/components/cto/CtoSettingsPanel.test.tsx index d3e8cd2c3..4fa034d20 100644 --- a/apps/desktop/src/renderer/components/cto/CtoSettingsPanel.test.tsx +++ b/apps/desktop/src/renderer/components/cto/CtoSettingsPanel.test.tsx @@ -125,6 +125,7 @@ describe("CtoSettingsPanel", () => { availableExternalMcpServers={[]} />, ); + fireEvent.click(screen.getByRole("button", { name: "Brief" })); expect(screen.getByText("ADE is an agentic IDE.")).toBeTruthy(); }); @@ -140,7 +141,7 @@ describe("CtoSettingsPanel", () => { onResetOnboarding={onResetOnboarding} />, ); - expect(screen.getByText("Run setup again")).toBeTruthy(); + expect(screen.getByText("Re-run setup")).toBeTruthy(); }); it("does not show reset onboarding when callback is omitted", () => { @@ -154,7 +155,7 @@ describe("CtoSettingsPanel", () => { availableExternalMcpServers={[]} />, ); - expect(screen.queryByText("Run setup again")).toBeNull(); + expect(screen.queryByText("Re-run setup")).toBeNull(); }); it("calls onSaveCoreMemory with parsed arrays when saving memory edits", async () => { @@ -169,6 +170,7 @@ describe("CtoSettingsPanel", () => { />, ); + fireEvent.click(screen.getByRole("button", { name: "Brief" })); const editBtns = screen.getAllByTestId("core-memory-edit-btn"); expect(editBtns.length).toBeGreaterThanOrEqual(1); fireEvent.click(editBtns[0]); @@ -200,6 +202,7 @@ describe("CtoSettingsPanel", () => { />, ); + fireEvent.click(screen.getByRole("button", { name: "Brief" })); const editBtns = screen.getAllByTestId("core-memory-edit-btn"); fireEvent.click(editBtns[0]); expect(screen.getByTestId("core-memory-cancel-btn")).toBeTruthy(); @@ -222,6 +225,7 @@ describe("CtoSettingsPanel", () => { />, ); + fireEvent.click(screen.getByRole("button", { name: "Brief" })); const editBtns = screen.getAllByTestId("core-memory-edit-btn"); fireEvent.click(editBtns[0]); fireEvent.click(screen.getByTestId("core-memory-save-btn")); @@ -250,56 +254,17 @@ describe("CtoSettingsPanel", () => { availableExternalMcpServers={[]} />, ); - expect(screen.getAllByText("anthropic").length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText("claude-sonnet-4-6").length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("anthropic/claude-sonnet-4-6")).toBeTruthy(); expect(screen.getByText("reasoning: high")).toBeTruthy(); expect(screen.getByText("Strategic")).toBeTruthy(); }); - it("shows Configured when core memory has a project summary", () => { - render( - , - ); - expect(screen.getByText("Configured")).toBeTruthy(); - }); - - it("shows Needs work when core memory has empty project summary", () => { - render( - , - ); - expect(screen.getByText("Needs work")).toBeTruthy(); - }); - - it("renders the CTO runtime header card", () => { - render( - , - ); - expect(screen.getAllByText("CTO runtime").length).toBeGreaterThanOrEqual(1); - expect(screen.getByText("Identity, brief, and continuity")).toBeTruthy(); - }); + // Removed tests ("shows Configured", "shows Needs work", "renders the CTO + // runtime header card"): the sub-tab refactor removed the status badges and + // the "CTO runtime" / "Identity, brief, and continuity" header card. Those + // UI elements no longer exist in the component. - it("renders collapsible section headers", () => { + it("renders sub-tab navigation", () => { render( { availableExternalMcpServers={[]} />, ); - expect(screen.getByText("Identity")).toBeTruthy(); - // "Long-term brief" appears in both the header stat grid and section title - expect(screen.getAllByText("Long-term brief").length).toBeGreaterThanOrEqual(1); - expect(screen.getByText("Prompt preview")).toBeTruthy(); - expect(screen.getByText("MCP Access")).toBeTruthy(); - expect(screen.getByText("OpenClaw Bridge")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Identity" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Brief" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Integrations" })).toBeTruthy(); }); - it("opens Continuity log section and shows timeline entries", () => { + it("shows session history timeline entries in the Brief tab", () => { render( { />, ); - // Continuity log appears in both the stat grid and section header; click the section header button - const logHeaders = screen.getAllByText("Continuity log"); - // The section header is inside a - {right ?
{right}
: null} -
- {open &&
{children}
} -
- ); -} - /* ── Main Panel ── */ export function CtoSettingsPanel({ @@ -134,216 +105,199 @@ export function CtoSettingsPanel({ } finally { setExternalMcpSaving(false); } }, [externalMcpDraft, onSaveIdentity]); + const [settingsTab, setSettingsTab] = useState<"identity" | "brief" | "integrations">("identity"); + + const SUB_TABS = [ + { id: "identity" as const, label: "Identity", tooltip: "CTO personality, model, and reasoning configuration." }, + { id: "brief" as const, label: "Brief", tooltip: "Project summary, conventions, and focus areas that persist across sessions." }, + { id: "integrations" as const, label: "Integrations", tooltip: "MCP server access and OpenClaw bridge configuration." }, + ]; + return ( -
-
-
-
-
-
- CTO runtime +
+ {/* Sub-tab bar */} +
+ {SUB_TABS.map(({ id, label, tooltip }) => ( + + + + ))} + {onResetOnboarding && ( + + )} +
+ +
+ {/* ── Identity sub-tab ── */} + {settingsTab === "identity" && ( + <> + {identityEditing ? ( + setIdentityEditing(false)} /> + ) : identity ? ( +
+ {/* Identity card */} +
+
+
CTO Identity
+ +
+
{describeIdentityPersonality(identity)}
+
+ {[ + { label: `${identity.modelPreferences.provider}/${identity.modelPreferences.model}` }, + ...(identity.personality ? [{ label: getCtoPersonalityPreset(identity.personality).label }] : []), + ...(identity.modelPreferences.reasoningEffort ? [{ label: `reasoning: ${identity.modelPreferences.reasoningEffort}` }] : []), + ].map((tag) => ( + + {tag.label} + + ))} +
+
+ + {/* Prompt preview card */} +
+
Prompt Preview
+ +
-
- -
Identity, brief, and continuity
+ ) : ( +
Loading...
+ )} + + )} + + {/* ── Brief sub-tab ── */} + {settingsTab === "brief" && ( + <> + {/* Brief card */} +
+
+
Project Brief
+ {!memoryEditing && ( + + )}
-
- This is the private control surface for the CTO. Update who it is, what it should permanently remember, - and what external systems it can touch. +
+ The persistent brief ADE reloads after compaction and across fresh chat resumes.
-
-
- {[ - { label: "Model", value: identity ? `${identity.modelPreferences.provider}/${identity.modelPreferences.model}` : "Loading" }, - { label: "Long-term brief", value: coreMemory?.projectSummary?.trim() ? "Configured" : "Needs work" }, - { label: "Continuity log", value: sessionLogs.length ? `${sessionLogs.length} recent sessions` : "No recent sessions" }, - { label: "Memory mode", value: "Layered + durable" }, - ].map((item) => ( -
-
{item.label}
-
{item.value}
+ {memoryEditing ? ( +
+ {[ + { key: "projectSummary" as const, label: "Summary", multiline: true }, + { key: "criticalConventions" as const, label: "Conventions", multiline: false }, + { key: "userPreferences" as const, label: "Preferences", multiline: false }, + { key: "activeFocus" as const, label: "Focus", multiline: false }, + { key: "notes" as const, label: "Notes", multiline: false }, + ].map(({ key, label, multiline }) => ( +