diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md index 64981355a..a87e22390 100644 --- a/.agents/skills/ship/SKILL.md +++ b/.agents/skills/ship/SKILL.md @@ -250,13 +250,13 @@ and upstack bindings and returns `stack-coordinator-sync-required`; it never rebases and never pushes another layer, though it does push its own layer branch in Phase 0 and Phase 3b. Otherwise, skip needless rebases. -**Bot pings by iteration.** Never ping GitHub Copilot and never treat Copilot as -an expected review signal; quota exhaustion otherwise leaves the loop waiting -forever. Initial PR pushes do not need a direct review ping. Subsequent -fix-iteration re-pushes → `@codex review`. For a >250-file diff, also ping -`@greptile` and `@coderabbit` (separate comments). Phase 1 still waits for the -expected review signals to settle before fixing. This is the playbook's Phase 4 -rule — defer to it for exact bodies. +**Bot pings by iteration.** Never ping GitHub Copilot and never ping `@codex` — +neither is an expected review signal here, and Copilot quota exhaustion +otherwise leaves the loop waiting forever. No push, initial or fix-iteration, +gets a direct review ping. For a >250-file diff only, ping `@greptile` and +`@coderabbit` (separate comments). Phase 1 still waits for the expected review +signals to settle before fixing. This is the playbook's Phase 4 rule — defer to +it for exact bodies. **Merge needs admin.** `main` is ruleset-guarded — `gh pr merge --squash --match-head-commit "$QUALITY_VALIDATED_SHA"` will show @@ -328,8 +328,9 @@ self-resume signal. Either: - **Phase 3a Rebase / 3b Fix / 3c Merge / 3d Force-finalize** — per the playbook. Force-finalize runs once: ignore review comments (bookkeep their IDs), fix only CI, never delete/skip tests or weaken lint/tsconfig, then merge on green. -- **Phase 4/5:** post the iteration's `@codex review` ping after a fix push, - update state, schedule the next wake (or stop per harness above). +- **Phase 4/5:** post only the >250-file bot pings (Phase 4 sends no ping for an + ordinary push), update state, schedule the next wake (or stop per harness + above). - **Stack mode:** the same phases run, minus 3a, 3c.1–3c.5, and 3d. Phase 2 routes remaining fix work to 3b and terminal-green to 3c.0 (`ready-stacked`); the spent iteration budget escalates via diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 345c028ef..575ce98f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -537,6 +537,7 @@ jobs: src/main/services/externalSessions/discoveryUtils.test.ts src/main/services/externalSessions/externalSessionsService.test.ts src/main/services/lanes/laneStorageLifecycle.test.ts + src/main/services/chat/piSession.test.ts # Covers the Windows runtime startup timing and `connectSpawnedRuntime` # retry path. This suite spawns real `ade serve` daemons and connects to diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 8e8e01998..de115c271 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -612,7 +612,7 @@ ade --role cto actions list --domain attention --text # discover account-wide Ac ade --role cto actions run attention.getSnapshot --input-json '{"since":0}' --json ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts ade actions run pty.resumeSession --arg sessionId=session-id -ade actions run external-sessions.list --input-json '{"scope":"project","limit":20}' --text # claude/codex/cursor/droid/opencode sessions on this machine; discovery that cannot run — `opencode` is not installed, say — fails the call when that provider is the only one asked for, rather than reporting an empty list; in a multi-provider scan it is skipped and logged +ade actions run external-sessions.list --input-json '{"scope":"project","limit":20}' --text # claude/codex/cursor/droid/opencode/pi sessions on this machine; discovery that cannot run — `opencode` is not installed, say — fails the call when that provider is the only one asked for, rather than reporting an empty list; in a multi-provider scan it is skipped and logged ade actions run external-sessions.import --input-json '{"provider":"codex","sessionId":"thread-id","laneId":"lane-1","target":"cli","mode":"resume"}' --text ade actions run ai.piLoginProviders --text # Pi providers that can be signed into, with the auth methods each accepts and whether it is already configured ade --role cto actions run ai.piLoginStart --input-json '{"providerId":"anthropic"}' --json # blocks until the human finishes Pi's own OAuth/device-code flow diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 2c8d32ffd..579a20366 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -2939,7 +2939,7 @@ describe("ADE CLI", () => { "--provider", "mystery", ]), - ).toThrow(/Provider must be claude, codex, cursor, droid, opencode, or shell/); + ).toThrow(/Provider must be claude, codex, cursor, droid, opencode, pi, or shell/); }); it("does not treat new --mode values as subcommands", () => { diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index f1c2afa66..e56000cce 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1582,7 +1582,7 @@ const HELP_BY_COMMAND: Record = { --type Required for a parented agent spawn. Use subagent whenever you will need, join, or review the result; use peer only for fire-and-forget work. - --provider claude | codex | cursor | droid | opencode. CLI mode also accepts shell. + --provider claude | codex | cursor | droid | opencode | pi. CLI mode also accepts shell. --model Runtime model id. --reasoning-effort Reasoning tier. Alias: --effort. --permissions default | auto | plan | edit | full-auto | config-toml. @@ -1944,7 +1944,7 @@ const HELP_BY_COMMAND: Record = { Start a tracked provider CLI session Create flags: - --provider claude | codex | cursor | droid | opencode. + --provider claude | codex | cursor | droid | opencode | pi. --model Model id, also sent as modelId for runtime parity. --reasoning-effort Reasoning tier when the selected model supports it. Common tiers: minimal, low, medium, high, xhigh, ultra, ultracode. @@ -1995,7 +1995,7 @@ const HELP_BY_COMMAND: Record = { Flags: --personal Use machine-owned chats instead of a project/lane chat. --lane Lane/worktree for the chat. - --provider claude | codex | cursor | droid | opencode. + --provider claude | codex | cursor | droid | opencode | pi. --model Model id, also sent as modelId for runtime parity. --reasoning-effort Reasoning tier when supported by the model. --effort Alias for --reasoning-effort. @@ -4721,10 +4721,10 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan const printConfig = readFlag(args, ["--print-config", "--dry-run"]); if (!isLaunchProfile(provider)) { - throw new CliUsageError("Provider must be claude, codex, cursor, droid, opencode, or shell."); + throw new CliUsageError("Provider must be claude, codex, cursor, droid, opencode, pi, or shell."); } if (mode === "chat" && provider === "shell") { - throw new CliUsageError("Chat mode provider must be claude, codex, cursor, droid, or opencode."); + throw new CliUsageError("Chat mode provider must be claude, codex, cursor, droid, opencode, or pi."); } if (mode === "cli") { const effectivePermissionMode = permissionMode ?? "default"; @@ -6630,7 +6630,7 @@ function buildCliSessionStartPlan( ); if (!isLaunchProfile(rawProvider)) { throw new CliUsageError( - "provider must be one of claude, codex, cursor, droid, opencode, or shell.", + "provider must be one of claude, codex, cursor, droid, opencode, pi, or shell.", ); } const provider: LaunchProfile = rawProvider; diff --git a/apps/desktop/src/main/services/__tests__/piSdk.integration.test.ts b/apps/desktop/src/main/services/__tests__/piSdk.integration.test.ts index 7cb6f9dc9..af768c5aa 100644 --- a/apps/desktop/src/main/services/__tests__/piSdk.integration.test.ts +++ b/apps/desktop/src/main/services/__tests__/piSdk.integration.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { piModelDescriptorsFromInventory, probePiProfileInventory, resolvePiInstallation } from "../ai/piInstallation"; import { PI_APPROVAL_ALLOW } from "../chat/piSdkEventMapper"; +import { classifyPiSessionFile, piSessionRootForEnvironment } from "../chat/piSessionStore"; import { acquirePiSdkConnection, releasePiSdkConnection, @@ -25,7 +26,7 @@ type Fixture = { root: string; cwd: string; agentDir: string; - sessionDir: string; + sessionRoot: string; modelPath: string; }; @@ -124,10 +125,14 @@ function createFixture(options?: { configured?: boolean; modelId?: string }): Fi tempRoots.push(root); const cwd = path.join(root, "worktree"); const agentDir = path.join(root, "agent"); - const sessionDir = path.join(root, "sessions"); + // Mirror a real Pi profile: the store root holds one directory per working + // directory, and every session file lives inside one of those. A flat + // sessionRoot beside agentDir is a layout Pi never produces, and testing + // against it hid the fact that ADE authorized the wrong root entirely. + const sessionRoot = path.join(agentDir, "sessions"); fs.mkdirSync(cwd, { recursive: true }); fs.mkdirSync(agentDir, { recursive: true }); - fs.mkdirSync(sessionDir, { recursive: true }); + fs.mkdirSync(sessionRoot, { recursive: true }); const modelId = options?.modelId ?? "test-model"; const provider: Record = { baseUrl: `${serverBaseUrl}/v1`, @@ -147,7 +152,7 @@ function createFixture(options?: { configured?: boolean; modelId?: string }): Fi if (options?.configured !== false) provider.apiKey = "ade-local-test-key"; const modelPath = path.join(agentDir, "models.json"); fs.writeFileSync(modelPath, JSON.stringify({ providers: { "ade-local": provider } })); - return { root, cwd, agentDir, sessionDir, modelPath }; + return { root, cwd, agentDir, sessionRoot, modelPath }; } function installedPiArgs(fixture: Fixture, poolKey: string, options?: { @@ -157,13 +162,14 @@ function installedPiArgs(fixture: Fixture, poolKey: string, options?: { askUserTool?: boolean; approvalTools?: string[]; extensions?: boolean; + sessionStorageDir?: string; }): { poolKey: string; packageRoot: string; packageEntry: string; cwd: string; agentDir: string; - sessionDir: string; + sessionRoot: string; modelRef: { provider: string; id: string }; thinkingLevel: string; systemPrompt: string; @@ -172,6 +178,7 @@ function installedPiArgs(fixture: Fixture, poolKey: string, options?: { askUserTool?: boolean; approvalTools?: string[]; extensions?: boolean; + sessionStorageDir?: string; baseEnv: NodeJS.ProcessEnv; } { const installation = resolvePiInstallation({ @@ -188,7 +195,7 @@ function installedPiArgs(fixture: Fixture, poolKey: string, options?: { packageEntry: installation.packageEntry, cwd: fixture.cwd, agentDir: fixture.agentDir, - sessionDir: fixture.sessionDir, + sessionRoot: fixture.sessionRoot, modelRef: { provider: "ade-local", id: options?.modelId ?? "test-model" }, thinkingLevel: "off", systemPrompt: "You are the isolated ADE Pi integration test model.", @@ -197,6 +204,7 @@ function installedPiArgs(fixture: Fixture, poolKey: string, options?: { ...(options?.askUserTool ? { askUserTool: true } : {}), ...(options?.approvalTools ? { approvalTools: options.approvalTools } : {}), ...(options?.extensions ? { extensions: true } : {}), + ...(options?.sessionStorageDir ? { sessionStorageDir: options.sessionStorageDir } : {}), baseEnv: { PATH: process.env.PATH ?? "", HOME: fixture.root, @@ -215,11 +223,27 @@ async function acquireTracked(fixture: Fixture, poolKey: string, options?: Param return tracked; } +/** Every native session under the store root, at whatever depth Pi nested it. */ function sessionFiles(fixture: Fixture): string[] { - return fs.readdirSync(fixture.sessionDir) - .filter((name) => name.endsWith(".jsonl")) - .map((name) => path.join(fixture.sessionDir, name)) - .filter((filePath) => fs.lstatSync(filePath).isFile()); + const found: string[] = []; + const pending = [fixture.sessionRoot]; + while (pending.length) { + const directory = pending.pop()!; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const filePath = path.join(directory, entry.name); + if (entry.isDirectory()) pending.push(filePath); + else if (entry.isFile() && entry.name.endsWith(".jsonl")) found.push(filePath); + } + } + return found.sort(); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) throw new Error("Timed out waiting for the installed Pi worker to flush its session."); + await new Promise((resolve) => setTimeout(resolve, 25)); + } } async function disposeConnection(connection: { poolKey: string; generation: number; pooled: PiSdkPooled }): Promise { @@ -303,6 +327,72 @@ describeInstalledPi("installed Pi SDK worker", () => { expect(sessionFiles(fixture)).toEqual([]); }); + // The shipped bug in one test: ADE authorized a session root that Pi never + // writes into, and validated the worker's session path before Pi had created + // the file. Both halves are asserted against the real installed Pi. + it("creates its session inside the authorized store and authorizes it before Pi writes the file", async () => { + const fixture = createFixture(); + const connection = await acquireTracked(fixture, `store:${Date.now()}`); + const planned = connection.pooled.sessionFile!; + + expect(path.isAbsolute(planned)).toBe(true); + // Pi nests one directory per working directory beneath the store root. + expect(path.relative(fixture.sessionRoot, planned).split(path.sep).length).toBe(2); + // Nothing on disk yet: Pi flushes on the first assistant message. + expect(fs.existsSync(planned)).toBe(false); + expect(sessionFiles(fixture)).toEqual([]); + expect(classifyPiSessionFile({ + filePath: planned, + cwd: fixture.cwd, + sessionId: connection.pooled.sessionId, + sessionRoot: fixture.sessionRoot, + }).state).toBe("pending"); + + await connection.pooled.sendPrompt({ prompt: "write the session header" }); + await nextRequest(); + await waitFor(() => fs.existsSync(planned)); + + expect(sessionFiles(fixture).map((file) => fs.realpathSync(file))).toEqual([fs.realpathSync(planned)]); + expect(classifyPiSessionFile({ + filePath: planned, + cwd: fixture.cwd, + sessionId: connection.pooled.sessionId, + sessionRoot: fixture.sessionRoot, + })).toEqual({ state: "authorized", filePath: fs.realpathSync(planned) }); + // The store root ADE authorizes is exactly the one Pi's own CLI resolves. + expect(piSessionRootForEnvironment({ PI_CODING_AGENT_DIR: fixture.agentDir })).toBe(fixture.sessionRoot); + }); + + // The other half of the store contract: when the user configured a session + // directory, Pi uses it flat and verbatim, and ADE must authorize that same + // directory rather than the profile default. + it("writes into a user-configured session directory when one is set", async () => { + const fixture = createFixture(); + const configured = path.join(fixture.root, "configured-sessions"); + fs.mkdirSync(configured, { recursive: true }); + const connection = await acquireTracked(fixture, `configured:${Date.now()}`, { + sessionStorageDir: configured, + }); + const planned = connection.pooled.sessionFile!; + + expect(path.dirname(planned)).toBe(configured); + await connection.pooled.sendPrompt({ prompt: "write the session header" }); + await nextRequest(); + await waitFor(() => fs.existsSync(planned)); + + expect(classifyPiSessionFile({ + filePath: planned, + cwd: fixture.cwd, + sessionId: connection.pooled.sessionId, + sessionRoot: configured, + })).toEqual({ state: "authorized", filePath: fs.realpathSync(planned) }); + expect(sessionFiles(fixture)).toEqual([]); + expect(piSessionRootForEnvironment({ + PI_CODING_AGENT_DIR: fixture.agentDir, + PI_CODING_AGENT_SESSION_DIR: configured, + })).toBe(configured); + }); + it("initializes an isolated profile, prompts with an image, reports auth, and preserves thinking levels", async () => { const fixture = createFixture(); const connection = await acquireTracked(fixture, `integration:${Date.now()}`); @@ -355,7 +445,7 @@ describeInstalledPi("installed Pi SDK worker", () => { await disposeConnection(byFile); const stalePointer = await acquireTracked(fixture, `resume:stale:${Date.now()}`, { - session: { sessionFile: path.join(fixture.sessionDir, "missing.jsonl"), sessionId: original.sessionId }, + session: { sessionFile: path.join(fixture.sessionRoot, "missing.jsonl"), sessionId: original.sessionId }, }); expect(fs.realpathSync(stalePointer.pooled.sessionFile!)).toBe(fs.realpathSync(original.sessionFile)); expect(stalePointer.pooled.sessionId).toBe(original.sessionId); @@ -375,7 +465,7 @@ describeInstalledPi("installed Pi SDK worker", () => { expect(sessionFiles(fixture)).toEqual(before); if (process.platform !== "win32") { - const symlink = path.join(fixture.sessionDir, "linked.jsonl"); + const symlink = path.join(fixture.sessionRoot, "linked.jsonl"); fs.symlinkSync(outside, symlink); await expect(acquirePiSdkConnection(installedPiArgs(fixture, `invalid:symlink:${Date.now()}`, { session: { sessionFile: symlink }, diff --git a/apps/desktop/src/main/services/ai/piAuthService.test.ts b/apps/desktop/src/main/services/ai/piAuthService.test.ts index 2a195dbde..7356ebe12 100644 --- a/apps/desktop/src/main/services/ai/piAuthService.test.ts +++ b/apps/desktop/src/main/services/ai/piAuthService.test.ts @@ -230,6 +230,7 @@ describe("piAuthService", () => { }); cancelPiLogin({ providerId: "xai" }); + await vi.advanceTimersByTimeAsync(2_000); await started; }); @@ -244,15 +245,88 @@ describe("piAuthService", () => { cancelPiLogin({ providerId: "xai" }); + // The prompt is released at once so Pi stops waiting on the user; only the + // final outcome waits out the grace window. expect(worker.respondToUi).toHaveBeenCalledWith("req-9", { ok: false, error: "Sign-in cancelled." }); expect(worker.cancelLogin).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(2_000); await expect(started).resolves.toEqual({ ok: false, error: "Sign-in cancelled." }); expect(events.at(-1)).toEqual({ providerId: "xai", state: "error", error: "Sign-in cancelled." }); expect(worker.release).toHaveBeenCalledTimes(1); expect(__getActivePiLoginProviderIdsForTests()).toEqual([]); }); - it("times out an abandoned flow and releases the worker", async () => { + // Pi persists the credential before its login call returns, so a cancel can + // land on a sign-in that already worked. Reporting that as a failure told + // the user to redo something that was done, while the provider quietly + // showed up connected later. + it("reports a login that completed while the cancel was in flight as a success", async () => { + const events = collectEvents(); + const worker = createFakeWorker(); + installWorker(worker); + + const started = startPiLogin({ providerId: "xai" }); + await flush(); + cancelPiLogin({ providerId: "xai" }); + worker.finishLogin(); + await flush(); + + await expect(started).resolves.toEqual({ ok: true }); + expect(events.at(-1)).toEqual({ providerId: "xai", state: "success" }); + expect(worker.release).toHaveBeenCalledTimes(1); + expect(__getActivePiLoginProviderIdsForTests()).toEqual([]); + + // The grace timer must not settle the already-finished flow a second time. + await vi.advanceTimersByTimeAsync(2_000); + expect(events.at(-1)).toEqual({ providerId: "xai", state: "success" }); + expect(worker.release).toHaveBeenCalledTimes(1); + }); + + // A supersede is an internal handoff. Announcing it as an error settled the + // replacement attempt that was already on screen: the renderer cleared the + // new flow card and latched the provider, so the sign-in the user had just + // started could never report its own result. + it("supersedes a flow without announcing an outcome for the replacement", async () => { + const events = collectEvents(); + const first = createFakeWorker(); + installWorker(first); + const started = startPiLogin({ providerId: "xai" }); + await flush(); + + const second = createFakeWorker(); + installWorker(second); + const replacement = startPiLogin({ providerId: "xai" }); + await flush(); + + expect(first.cancelLogin).toHaveBeenCalledTimes(1); + expect(first.release).toHaveBeenCalledTimes(1); + await expect(started).resolves.toEqual({ ok: false, error: "Sign-in was superseded by a newer attempt." }); + expect(events.filter((event) => event.state === "error")).toEqual([]); + + second.finishLogin(); + await flush(); + await expect(replacement).resolves.toEqual({ ok: true }); + expect(events.at(-1)).toEqual({ providerId: "xai", state: "success" }); + }); + + // Aborting the login can make Pi reject with its own abort text before the + // grace timer fires; the user asked to stop, so that is what they are told. + it("reports a cancel as a cancel even when Pi rejects with its own error", async () => { + const events = collectEvents(); + const worker = createFakeWorker(); + installWorker(worker); + + const started = startPiLogin({ providerId: "xai" }); + await flush(); + cancelPiLogin({ providerId: "xai" }); + worker.failLogin(new Error("AbortError: operation was aborted")); + await flush(); + + await expect(started).resolves.toEqual({ ok: false, error: "Sign-in cancelled." }); + expect(events.at(-1)).toEqual({ providerId: "xai", state: "error", error: "Sign-in cancelled." }); + }); + + it("times out an abandoned flow and releases the worker", async () =>{ const events = collectEvents(); const worker = createFakeWorker(); installWorker(worker); @@ -303,7 +377,7 @@ describe("piAuthService", () => { const restarted = startPiLogin({ providerId: "xai" }); await flush(); - await expect(started).resolves.toEqual({ ok: false, error: "Sign-in cancelled." }); + await expect(started).resolves.toEqual({ ok: false, error: "Sign-in was superseded by a newer attempt." }); expect(first.release).toHaveBeenCalledTimes(1); expect(__getActivePiLoginProviderIdsForTests()).toEqual(["xai"]); diff --git a/apps/desktop/src/main/services/ai/piAuthService.ts b/apps/desktop/src/main/services/ai/piAuthService.ts index 36ca0d483..f9ec9682f 100644 --- a/apps/desktop/src/main/services/ai/piAuthService.ts +++ b/apps/desktop/src/main/services/ai/piAuthService.ts @@ -41,6 +41,17 @@ export type { PiAuthStatusEvent, PiLoginProvider } from "../../../shared/types/c */ const PI_LOGIN_TIMEOUT_MS = 10 * 60 * 1000; +/** + * How long a cancel waits for an in-flight login to report its own outcome. + * + * A cancel can land after Pi has already persisted the credential and is only + * finishing its bookkeeping. Tearing the worker down at once turns that into a + * reported failure over a sign-in that actually worked — the user is told to + * retry something already done. Pi's own reply wins if it arrives inside this + * window; otherwise the cancel settles as a cancel. + */ +const PI_LOGIN_CANCEL_GRACE_MS = 2_000; + export type PiLoginResult = { ok: true } | { ok: false; error: string }; type AcquiredWorker = { pooled: PiSdkPooled; release: () => void }; @@ -86,7 +97,11 @@ type ActiveFlow = { pooled: PiSdkPooled; /** The prompt Pi is currently blocked on, if any. */ pendingRequestId: string | null; - finish: (result: PiLoginResult) => void; + finish: (result: PiLoginResult, options?: { silent?: boolean }) => void; + /** Stop the flow, giving Pi the grace window to report a completed login. */ + cancel: () => void; + /** Stop the flow at once, for a replacement attempt that must not wait. */ + supersede: () => void; }; const activeFlows = new Map(); /** Monotonic per-provider claim, so a superseded start can detect it lost. */ @@ -209,7 +224,7 @@ export async function startPiLogin(args: { }): Promise { const providerId = args.providerId.trim(); if (!providerId) return { ok: false, error: "Provider ID is required." }; - cancelPiLogin({ providerId }); + supersedePiLogin(providerId); // Acquiring a worker is async, so two starts for the same provider can both // get past the cancel above. Claim the provider synchronously and re-check // after the await, or the loser would orphan a worker nobody releases. @@ -237,24 +252,58 @@ export async function startPiLogin(args: { return await new Promise((resolve) => { let settled = false; let timer: ReturnType | null = null; + let graceTimer: ReturnType | null = null; const flow: ActiveFlow = { pooled, pendingRequestId: null, - finish: (result) => { + finish: (result, options) => { if (settled) return; settled = true; if (activeFlows.get(providerId) === flow) activeFlows.delete(providerId); if (timer) clearTimeout(timer); + const cancelling = graceTimer !== null; + if (graceTimer) clearTimeout(graceTimer); pooled.bridge.onUiRequest = null; pooled.bridge.onUiNotice = null; pooled.bridge.onUiCancel = null; release(); - emit(result.ok - ? { providerId, state: "success" } - : { providerId, state: "error", error: result.error }); - resolve(result); + // A supersede is an internal handoff, not an outcome. Announcing it + // would settle the replacement attempt that is already on screen: the + // renderer would clear the new flow card and latch its provider, so + // the sign-in the user just started could never report its own result. + if (!options?.silent) { + // Aborting the login can make Pi reject with its own internal abort + // text before the grace timer fires. The user asked to stop; report + // that, not Pi's plumbing. + emit(result.ok + ? { providerId, state: "success" } + : { providerId, state: "error", error: cancelling ? "Sign-in cancelled." : result.error }); + } + resolve(result.ok || !cancelling ? result : { ok: false, error: "Sign-in cancelled." }); + }, + cancel: () => { + if (settled || graceTimer) return; + stopPi(); + graceTimer = setTimeout( + () => flow.finish({ ok: false, error: "Sign-in cancelled." }), + PI_LOGIN_CANCEL_GRACE_MS, + ); + graceTimer.unref?.(); + }, + supersede: () => { + if (settled) return; + stopPi(); + flow.finish({ ok: false, error: "Sign-in was superseded by a newer attempt." }, { silent: true }); }, }; + /** Release Pi's side: answer any open prompt, then abort the login. */ + function stopPi(): void { + if (flow.pendingRequestId) { + pooled.respondToUi(flow.pendingRequestId, { ok: false, error: "Sign-in cancelled." }); + flow.pendingRequestId = null; + } + pooled.cancelLogin(); + } activeFlows.set(providerId, flow); pooled.bridge.onUiRequest = (requestId, payload) => { @@ -302,21 +351,36 @@ export function submitPiLoginPrompt(args: { return { ok: true }; } -/** Stop an in-flight sign-in (if any), release its worker, and settle it. */ +/** + * Stop an in-flight sign-in (if any) and settle it. + * + * Settling is not instantaneous: Pi gets a short grace window to report a + * login it had already completed, so a cancel racing a successful grant is + * reported as the success it was. See `PI_LOGIN_CANCEL_GRACE_MS`. + */ export function cancelPiLogin(args: { providerId: string }): void { - const providerId = args.providerId.trim(); - // Claiming a fresh generation cancels a start that is still acquiring its - // worker: it has no flow to find yet, so without this the cancel is a no-op - // and the sign-in the user stopped runs on holding a worker. + const providerId = claimProviderGeneration(args.providerId); + activeFlows.get(providerId)?.cancel(); +} + +/** + * Stop the outgoing flow for a provider a replacement is about to start. + * Module-private: skipping the grace window is only ever right when another + * attempt is taking over, never when a renderer asks to cancel. + */ +function supersedePiLogin(providerId: string): void { + activeFlows.get(claimProviderGeneration(providerId))?.supersede(); +} + +/** + * Claiming a fresh generation stops a start that is still acquiring its worker: + * it has no flow to find yet, so without this the cancel is a no-op and the + * sign-in the user stopped runs on holding a worker. + */ +function claimProviderGeneration(rawProviderId: string): string { + const providerId = rawProviderId.trim(); startGenerations.set(providerId, (startGenerations.get(providerId) ?? 0) + 1); - const flow = activeFlows.get(providerId); - if (!flow) return; - if (flow.pendingRequestId) { - flow.pooled.respondToUi(flow.pendingRequestId, { ok: false, error: "Sign-in cancelled." }); - flow.pendingRequestId = null; - } - flow.pooled.cancelLogin(); - flow.finish({ ok: false, error: "Sign-in cancelled." }); + return providerId; } // --- Test hooks ------------------------------------------------------------ diff --git a/apps/desktop/src/main/services/ai/piInstallation.test.ts b/apps/desktop/src/main/services/ai/piInstallation.test.ts new file mode 100644 index 000000000..29c9b48a4 --- /dev/null +++ b/apps/desktop/src/main/services/ai/piInstallation.test.ts @@ -0,0 +1,76 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { readPiProfileInventory, resolvePiInstallation } from "./piInstallation"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +/** A Pi profile with the given models.json providers and no stored auth. */ +function profile(providers: Record): ReturnType { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pi-install-")); + roots.push(root); + const agentDir = path.join(root, "agent"); + fs.mkdirSync(agentDir, { recursive: true }); + fs.writeFileSync(path.join(agentDir, "models.json"), JSON.stringify({ providers })); + return resolvePiInstallation({ ...process.env, PI_CODING_AGENT_DIR: agentDir }); +} + +function providerById(installation: ReturnType, id: string) { + return readPiProfileInventory(installation).providers.find((entry) => entry.id === id); +} + +describe("Pi provider classification", () => { + // LM Studio ships `apiKey: "lmstudio"` in models.json — a placeholder its + // OpenAI-compatible endpoint requires and ignores. Reading that as a + // credential classified a server the user runs as an API provider: ADE + // offered to sign in to localhost and reported it connected on the strength + // of a config file rather than a reachable server. + it("treats a loopback provider as local even when it carries a placeholder key", () => { + const installation = profile({ + lmstudio: { + baseUrl: "http://127.0.0.1:1234/v1", + apiKey: "lmstudio", + models: [{ id: "gemma-4" }], + }, + }); + + const lmstudio = providerById(installation, "lmstudio"); + expect(lmstudio?.authType).toBe("local"); + expect(lmstudio?.authMethods).toEqual(["local"]); + expect(lmstudio?.baseUrl).toBe("http://127.0.0.1:1234/v1"); + }); + + it("classifies every loopback spelling as local", () => { + for (const baseUrl of [ + "http://localhost:1234/v1", + "http://127.0.0.1:11434", + "http://0.0.0.0:8080/v1", + "http://[::1]:1234/v1", + ]) { + const installation = profile({ server: { baseUrl, apiKey: "placeholder" } }); + expect(providerById(installation, "server")?.authType).toBe("local"); + } + }); + + // A remote provider reached through a custom base URL is still remote, and + // the user does have to authenticate to it. + it("keeps a remote provider on a custom base URL an api-key provider", () => { + const installation = profile({ + proxied: { baseUrl: "https://gateway.example.com/v1", apiKey: "sk-real-key" }, + }); + + const proxied = providerById(installation, "proxied"); + expect(proxied?.authType).toBe("api-key"); + expect(proxied?.baseUrl).toBeUndefined(); + }); + + it("leaves a provider with neither a key nor a base URL unclassified", () => { + const installation = profile({ bare: { models: [{ id: "m" }] } }); + expect(providerById(installation, "bare")?.authType).toBeNull(); + }); +}); diff --git a/apps/desktop/src/main/services/ai/piInstallation.ts b/apps/desktop/src/main/services/ai/piInstallation.ts index da8561c8e..549810efe 100644 --- a/apps/desktop/src/main/services/ai/piInstallation.ts +++ b/apps/desktop/src/main/services/ai/piInstallation.ts @@ -10,6 +10,7 @@ import { encodePiRegistryId, } from "../../../shared/modelRegistry"; import { resolveExecutableFromKnownLocations } from "./cliExecutableResolver"; +import type { AiPiProviderAuthSource, AiPiProviderStatus } from "../../../shared/types/config"; export { PI_SDK_MIN_NODE } from "../chat/piSdkProtocol"; export const PI_PACKAGE_NAME = "@earendil-works/pi-coding-agent" as const; @@ -30,29 +31,6 @@ export type PiInstallation = { blocker: string | null; }; -export type PiProviderAuthSource = - | "stored" - | "runtime" - | "environment" - | "fallback" - | "models_json_key" - | "models_json_command" - | null; - -export type PiProfileProvider = { - id: string; - name: string; - modelCount: number; - availableModelCount: number; - configured: boolean; - authType: "api-key" | "oauth" | "local" | "unknown" | null; - authMethods: Array<"api-key" | "oauth" | "local">; - authSource?: PiProviderAuthSource; - authLabel?: string | null; - subscription?: boolean; - loginLabel?: string | null; - authExpiresAt?: number | null; -}; export type PiProfileInventory = { installed: boolean; @@ -67,7 +45,7 @@ export type PiProfileInventory = { modelsPath: string; modelsStorePath: string; blocker: string | null; - providers: PiProfileProvider[]; + providers: AiPiProviderStatus[]; availableModelIds: string[]; stale: boolean; authFileDetected: boolean; @@ -216,7 +194,27 @@ export function resolvePiInstallation(env: NodeJS.ProcessEnv = process.env): PiI }; } -function authSummary(value: unknown): { type: PiProfileProvider["authType"]; expiresAt?: number | null } { +/** Loopback hosts are the tell for "a model server the user runs themselves". */ +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"]); + +/** + * The base URL of a provider served from this machine, or `null`. + * + * Deliberately host-based rather than key-based: a placeholder API key means + * nothing, and a remote provider reached through a custom `baseUrl` proxy is + * still a remote provider the user must authenticate to. + */ +function loopbackBaseUrl(value: unknown): string | null { + const raw = nonEmpty(value); + if (!raw) return null; + try { + return LOOPBACK_HOSTS.has(new URL(raw).hostname.toLowerCase()) ? raw : null; + } catch { + return null; + } +} + +function authSummary(value: unknown): { type: AiPiProviderStatus["authType"]; expiresAt?: number | null } { const record = value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; if (!record) return { type: null }; const type = nonEmpty(record.type)?.toLowerCase(); @@ -239,7 +237,7 @@ export function readPiProfileInventory(installation = resolvePiInstallation()): ? models.providers as Record : {}; const ids = new Set(); - const providers = new Map(); + const providers = new Map(); for (const [providerId, raw] of Object.entries(modelProviders)) { const provider = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record : {}; const list = Array.isArray(provider.models) ? provider.models : []; @@ -248,8 +246,20 @@ export function readPiProfileInventory(installation = resolvePiInstallation()): if (id) ids.add(encodePiRegistryId("default", providerId, id)); } const authInfo = authSummary(auth[providerId]); + // A loopback base URL wins over everything, including a stored auth entry. + // LM Studio ships `apiKey: "lmstudio"` in models.json — a placeholder its + // OpenAI-compatible endpoint requires and ignores — so keying off the + // presence of a key classified a server the user runs as an API provider, + // offered to "sign in" to it, and reported it connected on the strength of + // a config file rather than a reachable server. + const localBaseUrl = loopbackBaseUrl(provider.baseUrl); + // A stored auth entry still wins: it is the one piece of evidence that the + // provider really has an interactive credential. Only the *placeholder* + // key case — a loopback server with no auth-store entry, which is what LM + // Studio ships — is reclassified, so a provider behind a local gateway + // does not lose its sign-in. const authType = authInfo.type - ?? (provider.apiKey ? "api-key" as const : provider.baseUrl ? "local" as const : null); + ?? (localBaseUrl ? "local" as const : provider.apiKey ? "api-key" as const : provider.baseUrl ? "local" as const : null); providers.set(providerId, { id: providerId, name: nonEmpty(provider.name) ?? providerId, @@ -258,6 +268,9 @@ export function readPiProfileInventory(installation = resolvePiInstallation()): configured: Boolean(auth[providerId]) || Boolean(provider.apiKey) || Boolean(provider.baseUrl), authType, authMethods: authType === "api-key" || authType === "oauth" || authType === "local" ? [authType] : [], + // Only for local providers: it is the endpoint the user runs, and the + // one thing that distinguishes "a server you host" from "a key you paste". + ...(localBaseUrl ? { baseUrl: localBaseUrl } : {}), ...(provider.apiKey ? { authSource: "models_json_key" as const } : {}), ...(authInfo.expiresAt !== undefined ? { authExpiresAt: authInfo.expiresAt } : {}), }); @@ -300,7 +313,7 @@ export function readPiProfileInventory(installation = resolvePiInstallation()): } function providerAuthSourcePath( - source: PiProviderAuthSource | undefined, + source: AiPiProviderAuthSource | undefined, installation: PiInstallation, ): string | undefined { if (source === "stored") return installation.authPath; @@ -314,7 +327,7 @@ function runtimeRecord(value: unknown): Record | null { function runtimeAuthType( value: Record | null, - fallback: PiProfileProvider | undefined, + fallback: AiPiProviderStatus | undefined, ): "api-key" | "oauth" | "local" | null { const type = nonEmpty(value?.type)?.toLowerCase(); if (type === "oauth" || type === "token") return "oauth"; @@ -324,8 +337,8 @@ function runtimeAuthType( function runtimeAuthSource( value: Record | null, - fallback: PiProfileProvider | undefined, -): PiProviderAuthSource { + fallback: AiPiProviderStatus | undefined, +): AiPiProviderAuthSource { const source = nonEmpty(value?.source); if (source === "stored" || source === "runtime" || source === "environment" || source === "fallback" || source === "models_json_key" || source === "models_json_command") { @@ -349,7 +362,9 @@ export function piModelDescriptorsFromInventory(inventory: PiProfileInventory) { profileId: decoded.profileId, displayName: `${provider?.name ?? decoded.providerId} / ${decoded.modelId}`, ...(provider?.authMethods.length ? { authTypes: provider.authMethods } : {}), - color: "#F97316", + // Pi's chat accent, so a Pi model reads as Pi wherever the per-model + // colour is used instead of the provider accent. + color: "#181C25", })]; }); } @@ -387,14 +402,17 @@ export async function probePiProfileInventory( }); const runtimeAuth = await acquired.pooled.requestAuth().catch(() => []); const fallbackProvidersById = new Map(fallback.providers.map((provider) => [provider.id, provider] as const)); - const providersById = new Map(); + const providersById = new Map(); for (const item of Array.isArray(runtimeAuth) ? runtimeAuth : []) { const runtime = runtimeRecord(item); const providerId = nonEmpty(runtime?.id); if (!providerId) continue; const fallbackProvider = fallbackProvidersById.get(providerId); - const authType = runtimeAuthType(runtime, fallbackProvider); + // A loopback provider stays local only when Pi's runtime has no auth + // type of its own to report. + const authType = runtimeAuthType(runtime, fallbackProvider) + ?? (fallbackProvider?.authType === "local" ? "local" as const : null); const authMethods: Array<"api-key" | "oauth" | "local"> = fallbackProvider?.authMethods ?? (authType ? [authType] : []); providersById.set(providerId, { @@ -405,6 +423,9 @@ export async function probePiProfileInventory( configured: Boolean(runtimeConfigured(runtime) || fallbackProvider?.configured || (availableCounts.get(providerId) ?? 0) > 0), authType, authMethods, + // Pi's live provider list carries no endpoint, so the profile-file + // reading is the only source of a local provider's base URL. + ...(fallbackProvider?.baseUrl ? { baseUrl: fallbackProvider.baseUrl } : {}), authSource: runtimeAuthSource(runtime, fallbackProvider), authLabel: nonEmpty(runtime?.label) ?? fallbackProvider?.authLabel ?? null, subscription: fallbackProvider?.subscription === true, @@ -468,7 +489,7 @@ export async function probePiProfileInventory( } export function providerPathForPiAuthSource( - source: PiProviderAuthSource | undefined, + source: AiPiProviderAuthSource | undefined, installation: PiInstallation, ): string | undefined { return providerAuthSourcePath(source, installation); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 42815d84d..af0e5b795 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -552,13 +552,9 @@ import { type PiSdkPooled, } from "./piSdkPool"; import type { PiSdkUiRequestPayload } from "./piSdkProtocol"; -import { - acquirePiSessionLease, - piSessionCreationLeaseTarget, - piSessionDirectoryForEnvironment, - resolvePiSessionFile, - type PiSessionLease, -} from "./piSessionLease"; +import { acquirePiSessionLease, type PiSessionLease } from "./piSessionLease"; +import { recordPiSessionOwner } from "./piSessionOwnership"; +import { classifyPiSessionFile, piSessionStoreForEnvironment, resolvePiSessionFile } from "./piSessionStore"; import { piModelDescriptorsFromInventory, probePiProfileInventory, resolvePiInstallation } from "../ai/piInstallation"; import { discoverCursorCliModelDescriptors, @@ -1810,6 +1806,10 @@ type PiRuntime = { modelId: string | null; activeCompactionId: string | null; lease: PiSessionLease | null; + /** Store root this runtime's session file must stay under. */ + sessionRoot: string; + /** Pi has named the session file but not written it yet; no header to check. */ + sessionFilePending: boolean; /** Tool/extension policy this worker was built with; a change forces a restart. */ toolPolicyKey: string; }; @@ -10999,8 +10999,13 @@ export function createAgentChatService(args: { piExtensionsEnabled ? "ext" : "no-ext", ].join("|"); if (managed.runtime?.kind === "pi") { + // `runtimeInvalidated` is checked here, not only on teardown: a session + // whose header failed validation mid-turn is flagged while its worker is + // still alive, and without this the same rejected runtime is handed back + // to every later send. if (managed.runtime.poolKey === poolKey && managed.runtime.toolPolicyKey === toolPolicyKey + && !managed.runtimeInvalidated && isPiSdkPooledAlive(managed.runtime.sdk)) { return managed.runtime; } @@ -11017,21 +11022,30 @@ export function createAgentChatService(args: { const persisted = readPersistedState(managed.session.id); const sessionFile = managed.session.piSessionFile?.trim() || persisted?.piSessionFile?.trim() || null; const sessionId = managed.session.piSessionId?.trim() || persisted?.piSessionId?.trim() || null; - const sessionDir = piSessionDirectoryForEnvironment(runtimeEnv, path.join(layout.cacheDir, "pi", "sessions")); - fs.mkdirSync(sessionDir, { recursive: true }); + // One store for chat and the tracked Pi CLI. Pi's own profile is that + // store: an ADE-owned cache directory would be invisible to the user's `pi` + // command, and to every ADE path that resolves sessions from the profile + // (terminal resume, fork, external-session discovery). + const sessionStore = piSessionStoreForEnvironment(runtimeEnv); + const sessionRoot = sessionStore.root; + fs.mkdirSync(sessionRoot, { recursive: true }); const piLeaseIdentity = processRegistry ? { processStartedAt: processRegistry.startedAt, isProcessIdentityLive: (pid: number, startedAt: string) => processRegistry.isProcessIdentityLive(pid, startedAt), } : {}; + // Only a resume can be leased up front. A new session takes its lease once + // the worker reports the path Pi picked — no directory-wide token in + // between, because Pi names each session's file when it creates it, so two + // chats in one lane never contend for the same JSONL. The tracked CLI still + // needs its creation lease: it has to discover which session Pi made. let piLease: PiSessionLease | null = null; - let piCreationLease: PiSessionLease | null = null; const existingPiSessionFile = resolvePiSessionFile({ cwd: managed.laneWorktreePath, sessionId: sessionId ?? "", sessionFile, - sessionDir, + sessionRoot, env: runtimeEnv, }); if (existingPiSessionFile) { @@ -11041,17 +11055,7 @@ export function createAgentChatService(args: { ownerId: managed.session.id, ...piLeaseIdentity, }); - } else { - // A new Pi SDK session has no JSONL path until Pi creates its session. - // Serialize that first-write window so two ADE runtimes cannot both - // create implicit sessions before either one can publish a concrete - // header-authoritative file pointer. - piCreationLease = acquirePiSessionLease({ - sessionFile: piSessionCreationLeaseTarget(sessionDir), - owner: "sdk", - ownerId: managed.session.id, - ...piLeaseIdentity, - }); + recordPiSessionOwner({ sessionFile: existingPiSessionFile, owner: "sdk", ownerSessionId: managed.session.id }); } const systemPrompt = isPersonalSession(managed.session) ? PERSONAL_CHAT_SYSTEM_PROMPT @@ -11084,7 +11088,8 @@ export function createAgentChatService(args: { packageEntry: installation.packageEntry, cwd: managed.laneWorktreePath, agentDir: installation.agentDir, - sessionDir, + sessionRoot, + ...(sessionStore.storageDir ? { sessionStorageDir: sessionStore.storageDir } : {}), tools: piTools, ...(piToolPolicy.approvalTools.length ? { approvalTools: piToolPolicy.approvalTools } : {}), // Lets Pi ask the user a question mid-turn, which no tool allowlist can @@ -11108,41 +11113,44 @@ export function createAgentChatService(args: { }); } catch (error) { piLease?.release(); - piCreationLease?.release(); throw error; } - const workerSessionFile = resolvePiSessionFile({ + // Pi buffers a new session in memory and only writes its JSONL file once + // the first assistant message lands, so a freshly created session names a + // path that does not exist yet. That path is still constrained to the + // authorized store here; the concrete lease is taken later, once Pi has + // flushed and the header can be checked. + const workerSession = classifyPiSessionFile({ + filePath: acquired.pooled.sessionFile ?? "", cwd: managed.laneWorktreePath, - sessionId: acquired.pooled.sessionId ?? "", - sessionFile: acquired.pooled.sessionFile, - sessionDir, - env: runtimeEnv, + sessionId: acquired.pooled.sessionId ?? null, + sessionRoot, }); - if (!workerSessionFile) { - releasePiSdkConnection(poolKey, acquired.generation, () => { - piCreationLease?.release(); - piLease?.release(); - }); + if (workerSession.state === "rejected") { + releasePiSdkConnection(poolKey, acquired.generation, () => piLease?.release()); throw new Error("Pi SDK worker returned a session outside the authorized native session directory."); } - acquired.pooled.sessionFile = workerSessionFile; + acquired.pooled.sessionFile = workerSession.filePath; if (!piLease) { + // Leased on the pending path too: Pi has already fixed this exact JSONL + // name, so it is spoken for even though nothing is on disk yet. try { piLease = acquirePiSessionLease({ - sessionFile: workerSessionFile, + sessionFile: workerSession.filePath, owner: "sdk", ownerId: managed.session.id, ...piLeaseIdentity, }); + // Stake a durable claim: a tracked Pi terminal picks its session out of + // the same native store, and without this it adopts a chat's session + // whenever the two were created minutes apart. + recordPiSessionOwner({ sessionFile: workerSession.filePath, owner: "sdk", ownerSessionId: managed.session.id }); } catch (error) { - releasePiSdkConnection(poolKey, acquired.generation, () => piCreationLease?.release()); + releasePiSdkConnection(poolKey, acquired.generation); throw error; } } - if (piCreationLease) { - piCreationLease.release(); - piCreationLease = null; - } + const piSessionFilePending = workerSession.state === "pending"; if (piRuntimeSetupInterruptRequested.get(managed)) { piRuntimeSetupInterruptRequested.delete(managed); releasePiSdkConnection(poolKey, acquired.generation, () => { @@ -11164,10 +11172,44 @@ export function createAgentChatService(args: { modelId: piModelId, activeCompactionId: null, lease: piLease, + sessionRoot, + sessionFilePending: piSessionFilePending, toolPolicyKey, }; managed.runtime = runtime; managed.runtimeInvalidated = false; + /** + * Confirm the session file once Pi flushes it. Ownership was taken on the + * planned path already; what is still unverified is the header, which is + * the only proof the file really belongs to this working directory. + */ + const settlePiSessionLease = (): void => { + if (managed.runtime !== runtime || !runtime.sessionFilePending) return; + const classified = classifyPiSessionFile({ + filePath: runtime.sdk.sessionFile ?? "", + cwd: managed.laneWorktreePath, + sessionId: runtime.sdk.sessionId ?? null, + sessionRoot: runtime.sessionRoot, + }); + if (classified.state === "pending") return; + runtime.sessionFilePending = false; + if (classified.state !== "authorized") { + logger.warn("agent_chat.pi_session_file_unauthorized", { + sessionId: managed.session.id, + sessionFile: runtime.sdk.sessionFile ?? "", + }); + // The setup path throws on the same failure. Here a turn is already in + // flight, and killing it would lose the user's work over a file ADE + // only reads for resume — so the turn finishes on the live worker + // while the runtime is marked unusable. Nothing points at the session + // afterwards: the pointer is dropped and the next send rebuilds. + delete managed.session.piSessionFile; + delete managed.session.piSessionId; + managed.runtimeInvalidated = true; + return; + } + persistChatState(managed); + }; acquired.pooled.bridge.onEvent = (event) => { if (managed.runtime !== runtime) return; const eventRecord = asRecord(event); @@ -11178,6 +11220,7 @@ export function createAgentChatService(args: { for (const mapped of mapPiSdkEventToChatEvents(event, turnId, runtime.activeCompactionId)) emitChatEvent(managed, mapped); if (eventRecord?.type === "compaction_end") runtime.activeCompactionId = null; if (eventRecord?.type === "session_info_changed") adoptRuntimeSessionTitle(managed, eventRecord, "pi_session_info"); + if (eventRecord?.type === "message_end") settlePiSessionLease(); }; acquired.pooled.bridge.onLifecycle = (event) => { if (managed.runtime !== runtime) return; @@ -12153,7 +12196,7 @@ export function createAgentChatService(args: { : !managed.runtimeInvalidated && (managed.seededDroidSdkSessionId || prevPersisted?.droidSdkSessionId) ? { droidSdkSessionId: managed.seededDroidSdkSessionId ?? prevPersisted?.droidSdkSessionId } : {}), - ...(managed.runtime?.kind === "pi" + ...(managed.runtime?.kind === "pi" && !managed.runtimeInvalidated ? { ...(managed.runtime.sdk.sessionId ? { piSessionId: managed.runtime.sdk.sessionId } : {}), ...(managed.runtime.sdk.sessionFile ? { piSessionFile: managed.runtime.sdk.sessionFile } : {}), diff --git a/apps/desktop/src/main/services/chat/piSdkPool.ts b/apps/desktop/src/main/services/chat/piSdkPool.ts index cc069e439..e43f41723 100644 --- a/apps/desktop/src/main/services/chat/piSdkPool.ts +++ b/apps/desktop/src/main/services/chat/piSdkPool.ts @@ -105,7 +105,8 @@ export type AcquirePiSdkConnectionArgs = PiSdkPackageLocation & { poolKey: string; cwd: string; agentDir: string; - sessionDir?: string | null; + sessionRoot?: string | null; + sessionStorageDir?: string | null; modelRef?: PiSdkModelRef | null; thinkingLevel?: string | null; systemPrompt?: string | null; @@ -221,7 +222,8 @@ function createPiSdkConnection(args: AcquirePiSdkConnectionArgs): Promise { agentDir: "/Users/example/.pi/agent", modelRef: "anthropic/claude-sonnet", thinkingLevel: "medium", + sessionRoot: "/Users/example/.pi/agent/sessions", + sessionStorageDir: "/Users/example/pi-sessions", }, })).toBeNull(); + + // The store root and the directory Pi writes into are separate fields; + // both must be validated, or a rename can silently unhook one of them. + const initBase = { + protocolVersion: PI_SDK_PROTOCOL_VERSION, + type: "init" as const, + requestId: "init-2", + payload: { packageRoot: "/p", cwd: "/c", agentDir: "/a" }, + }; + expect(validatePiSdkWorkerRequest({ + ...initBase, + payload: { ...initBase.payload, sessionRoot: 5 }, + })).toMatch(/sessionRoot/u); + expect(validatePiSdkWorkerRequest({ + ...initBase, + payload: { ...initBase.payload, sessionStorageDir: 5 }, + })).toMatch(/sessionStorageDir/u); }); it("rejects malformed worker responses before they reach the pool", () => { diff --git a/apps/desktop/src/main/services/chat/piSdkProtocol.ts b/apps/desktop/src/main/services/chat/piSdkProtocol.ts index 347e68800..807b4fcf5 100644 --- a/apps/desktop/src/main/services/chat/piSdkProtocol.ts +++ b/apps/desktop/src/main/services/chat/piSdkProtocol.ts @@ -26,7 +26,7 @@ export type PiSdkPackageLocation = { export type PiSdkSessionTarget = { /** Open this JSONL session file. */ sessionFile?: string | null; - /** Resolve this Pi session id in sessionDir and open its file. */ + /** Resolve this Pi session id in the session store and open its file. */ sessionId?: string | null; /** Continue the most recent session when true. */ resume?: boolean | { sessionFile?: string | null; sessionId?: string | null }; @@ -37,8 +37,20 @@ export type PiSdkWorkerInit = PiSdkPackageLocation & { cwd: string; /** Passed to Pi as PI_CODING_AGENT_DIR. */ agentDir: string; - /** Passed to Pi as PI_CODING_AGENT_SESSION_DIR. */ - sessionDir?: string | null; + /** + * Root every native session file this worker may open must live under. This + * is an authorization boundary, not a storage instruction: in Pi's default + * layout it is `/sessions`, whose per-cwd subdirectories hold the + * actual files. + */ + sessionRoot?: string | null; + /** + * Directory Pi is told to write sessions into, when the user configured one. + * Left unset for Pi's default layout so Pi derives its own per-cwd directory + * — handing it `sessionRoot` would write files flat into the store root where + * Pi's own subdirectory-only discovery can never see them again. + */ + sessionStorageDir?: string | null; modelRef?: PiSdkModelRef | null; thinkingLevel?: string | null; systemPrompt?: string | null; @@ -343,7 +355,8 @@ export function validatePiSdkWorkerRequest(raw: unknown): string | null { if (!isPackageLocation(payload)) return "Pi SDK init requires packageRoot/packageDir or packageEntry."; if (!nonEmptyString(payload.cwd)) return "Pi SDK init requires an absolute cwd."; if (!nonEmptyString(payload.agentDir)) return "Pi SDK init requires an absolute agentDir."; - if (payload.sessionDir != null && typeof payload.sessionDir !== "string") return "Pi SDK sessionDir must be a string or null."; + if (payload.sessionRoot != null && typeof payload.sessionRoot !== "string") return "Pi SDK sessionRoot must be a string or null."; + if (payload.sessionStorageDir != null && typeof payload.sessionStorageDir !== "string") return "Pi SDK sessionStorageDir must be a string or null."; if (payload.modelRef != null && !isModelRef(payload.modelRef)) return "Pi SDK modelRef must be a model string or {provider,id}."; if (payload.thinkingLevel != null && !nonEmptyString(payload.thinkingLevel)) return "Pi SDK thinkingLevel cannot be empty."; if (payload.tools != null && (!Array.isArray(payload.tools) diff --git a/apps/desktop/src/main/services/chat/piSdkWorker.ts b/apps/desktop/src/main/services/chat/piSdkWorker.ts index 834dd11be..753210e8d 100644 --- a/apps/desktop/src/main/services/chat/piSdkWorker.ts +++ b/apps/desktop/src/main/services/chat/piSdkWorker.ts @@ -16,10 +16,7 @@ import { type PiSdkWorkerRequest, type PiSdkWorkerResponse, } from "./piSdkProtocol"; -import { - piSessionHeaderMatchesCwd, - readPiSessionHeader, -} from "./piSessionLease"; +import { piSessionHeaderMatchesCwd, readPiSessionHeader } from "./piSessionStore"; import { PI_ASK_USER_TOOL_NAME, createPiApprovalGate, @@ -51,6 +48,8 @@ let session: PiSession | null = null; let modelInventory: JsonValue[] = []; let lastAssistantError: string | null = null; const VALID_THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); +/** Cap on the post-sign-in catalog refresh; the credential is already stored. */ +const PI_LOGIN_REFRESH_TIMEOUT_MS = 15_000; /** Root-exported factory per built-in tool, used to rebuild it behind an approval gate. */ const PI_TOOL_DEFINITION_FACTORIES: Record = { read: "createReadToolDefinition", @@ -303,13 +302,16 @@ function sessionTarget(init: PiSdkWorkerInit): PiSdkSessionTarget { return init.session ?? {}; } -function sessionFileIsAuthorized(filePath: string, sessionDir: string | null): boolean { - if (!sessionDir) return true; +function sessionFileIsAuthorized(filePath: string, sessionRoot: string | null): boolean { + if (!sessionRoot) return true; try { - const resolvedFile = fs.realpathSync(filePath); - const resolvedDir = fs.realpathSync(sessionDir); + // `.native` on both sides, matching piSessionLease: on Windows the JS + // realpath keeps 8.3 short names and junction casing, so one spelling of a + // directory fails containment against another spelling of itself. + const resolvedFile = fs.realpathSync.native(filePath); + const resolvedDir = fs.realpathSync.native(sessionRoot); const relative = path.relative(resolvedDir, resolvedFile); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); } catch { return false; } @@ -317,17 +319,17 @@ function sessionFileIsAuthorized(filePath: string, sessionDir: string | null): b function validatedSessionFile( filePath: string, - sessionDir: string | null, + sessionRoot: string | null, expectedId: string | null, expectedCwd: string, ): string | null { let resolved: string; try { - resolved = fs.realpathSync(path.resolve(filePath)); + resolved = fs.realpathSync.native(path.resolve(filePath)); } catch { return null; } - if (!sessionFileIsAuthorized(resolved, sessionDir)) return null; + if (!sessionFileIsAuthorized(resolved, sessionRoot)) return null; // A native file may be inside the authorized Pi root while belonging to a // different project. Require a non-empty, exact normalized header cwd at // this worker boundary; blank cwd must never act as a wildcard. @@ -348,38 +350,43 @@ async function openSessionManager(init: PiSdkWorkerInit, sdk: PiModule): Promise const resumeRecord = record(resume); const resumeFile = sessionFile ?? nonEmpty(resumeRecord?.sessionFile); const resumeId = requestedId ?? nonEmpty(resumeRecord?.sessionId); - const sessionDir = nonEmpty(init.sessionDir); + // The authorization root and the directory Pi writes into are different + // things. Pi only nests per cwd when told nothing; handing it the root would + // scatter files flat into `/sessions`, which Pi's own discovery + // (subdirectories only) can never read back. + const sessionRoot = nonEmpty(init.sessionRoot); + const storageDir = nonEmpty(init.sessionStorageDir) ?? undefined; try { if (resumeFile) { assertAbsolute("sessionFile", resumeFile); - const authorizedFile = validatedSessionFile(resumeFile, sessionDir, resumeId, init.cwd); + const authorizedFile = validatedSessionFile(resumeFile, sessionRoot, resumeId, init.cwd); // A stale path can accompany a durable session id after a project move or // remote handoff. Prefer the id lookup in that case; never create a new // session while a persisted native pointer is present. if (authorizedFile) { - return method(manager, "open").call(manager, authorizedFile, sessionDir ?? path.dirname(authorizedFile), init.cwd) as PiSessionManager; + return method(manager, "open").call(manager, authorizedFile, storageDir ?? path.dirname(authorizedFile), init.cwd) as PiSessionManager; } if (!resumeId) { throw new Error(`Pi session file "${resumeFile}" is missing, outside the authorized session directory, or invalid.`); } } if (resumeId) { - const list = await method(manager, "list").call(manager, init.cwd, sessionDir ?? undefined); + const list = await method(manager, "list").call(manager, init.cwd, storageDir); const found = (Array.isArray(list) ? list : []).find((item) => record(item)?.id === resumeId); const foundPath = nonEmpty(record(found)?.path); const authorizedFoundPath = foundPath - ? validatedSessionFile(foundPath, sessionDir, resumeId, init.cwd) + ? validatedSessionFile(foundPath, sessionRoot, resumeId, init.cwd) : null; if (!authorizedFoundPath) { throw new Error(`Pi session "${resumeId}" was not found in the authorized session directory.`); } - return method(manager, "open").call(manager, authorizedFoundPath, sessionDir ?? path.dirname(authorizedFoundPath), init.cwd) as PiSessionManager; + return method(manager, "open").call(manager, authorizedFoundPath, storageDir ?? path.dirname(authorizedFoundPath), init.cwd) as PiSessionManager; } if (resume === true) { - return method(manager, "continueRecent").call(manager, init.cwd, sessionDir ?? undefined) as PiSessionManager; + return method(manager, "continueRecent").call(manager, init.cwd, storageDir) as PiSessionManager; } - return method(manager, "create").call(manager, init.cwd, sessionDir ?? undefined) as PiSessionManager; + return method(manager, "create").call(manager, init.cwd, storageDir) as PiSessionManager; } catch (error) { throw new Error(`Pi session could not be opened${resumeFile ? ` at ${resumeFile}` : ""}: ${errorMessage(error)}`); } @@ -578,17 +585,22 @@ async function initWorker(init: PiSdkWorkerInit): Promise { if (invalidTools.length) { throw new Error(`Pi SDK only permits the built-in tools read, bash, edit, and write; received ${invalidTools.join(", ")}.`); } - if (init.sessionDir) assertAbsolute("sessionDir", init.sessionDir); + if (init.sessionRoot) assertAbsolute("sessionRoot", init.sessionRoot); + if (init.sessionStorageDir) assertAbsolute("sessionStorageDir", init.sessionStorageDir); const location = resolvePackageLocation(init); piRoot = location.root; piEntry = location.entry; piVersion = location.version; initState = init; process.env.PI_CODING_AGENT_DIR = init.agentDir; - if (init.sessionDir) process.env.PI_CODING_AGENT_SESSION_DIR = init.sessionDir; + // Pi's embedded SDK never reads PI_CODING_AGENT_SESSION_DIR — only its CLI + // entry point does — so the storage directory travels as an explicit + // SessionManager argument instead. Mirroring the variable here would just + // hand a stale override to anything the worker later spawns. for (const [key, value] of Object.entries(init.skillsEnv ?? {})) process.env[key] = value; fs.mkdirSync(init.agentDir, { recursive: true }); - if (init.sessionDir) fs.mkdirSync(init.sessionDir, { recursive: true }); + if (init.sessionRoot) fs.mkdirSync(init.sessionRoot, { recursive: true }); + if (init.sessionStorageDir) fs.mkdirSync(init.sessionStorageDir, { recursive: true }); post({ protocolVersion: PI_SDK_PROTOCOL_VERSION, type: "lifecycle", event: "initializing" }); try { @@ -833,11 +845,29 @@ async function loginProvider(providerId: string, method?: string | null): Promis // login() settles once local credential state is consistent, but not once // remote catalogs are fresh. Refresh so newly unlocked models appear. + // + // Bounded: the credential is already written by this point, so a slow + // network catalog fetch must not keep the sign-in RPC — and the card the + // user is watching — pending behind it. if (typeof runtime.refresh === "function") { try { - await (runtime.refresh as Callable).call(modelRuntime, { allowNetwork: true, providers: [providerId] }); + // The refresh keeps running after the timeout wins the race, so its + // rejection has to stay observed — an unhandled one would take down the + // worker over a catalog fetch the sign-in no longer depends on. + const refreshed = Promise.resolve( + (runtime.refresh as Callable).call(modelRuntime, { allowNetwork: true, providers: [providerId] }), + ).catch(() => undefined); + await Promise.race([ + refreshed, + new Promise((resolve) => { + const timer = setTimeout(resolve, PI_LOGIN_REFRESH_TIMEOUT_MS); + timer.unref?.(); + }), + ]); } catch { - // Refresh failures are reported through the model list, not the login. + // Only a synchronous throw from refresh() reaches here; an async + // rejection is already absorbed above. Either way a stale catalog is + // reported through the model list, not as a failed sign-in. } } // Deliberately does not return the model inventory: the credential is diff --git a/apps/desktop/src/main/services/chat/piSession.test.ts b/apps/desktop/src/main/services/chat/piSession.test.ts new file mode 100644 index 000000000..ebcfe5851 --- /dev/null +++ b/apps/desktop/src/main/services/chat/piSession.test.ts @@ -0,0 +1,517 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { acquirePiSessionLease, piSessionCreationLeaseTarget, piSessionLeaseIsHeld } from "./piSessionLease"; +import { + piSessionCouldBelongToTerminal, + piSessionIsAdoptableByTerminal, + readPiSessionOwner, + recordPiSessionOwner, +} from "./piSessionOwnership"; +import { + classifyPiSessionFile, + listPiSessionFilesForCwd, + piSessionRootForEnvironment, + piSessionStoreForEnvironment, + repositoryOverridesPiSessionDir, + resolvePiSessionFile, +} from "./piSessionStore"; + +const tempRoots: string[] = []; + +afterEach(() => { + for (const root of tempRoots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function makeSession(): { root: string; file: string; id: string; cwd: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pi-lease-")); + tempRoots.push(root); + const cwd = path.join(root, "worktree"); + const sessionRoot = path.join(root, "sessions"); + fs.mkdirSync(path.join(sessionRoot, "encoded"), { recursive: true }); + const id = "019fd86d-f40d-76c6-a194-d5ba030cbad3"; + const file = path.join(sessionRoot, "encoded", `2026-04-01T00-00-00-000Z_${id}.jsonl`); + fs.writeFileSync(file, `${JSON.stringify({ type: "session", id, cwd, timestamp: new Date().toISOString() })}\n`); + return { root, file, id, cwd }; +} + +async function waitForChildOutput(child: ChildProcess, marker: string): Promise { + await new Promise((resolve, reject) => { + let output = ""; + const timer = setTimeout(() => reject(new Error(`Timed out waiting for child marker ${marker}. Output: ${output}`)), 10_000); + const onData = (chunk: Buffer | string) => { + output += String(chunk); + if (!output.includes(marker)) return; + clearTimeout(timer); + child.stdout?.off("data", onData); + resolve(); + }; + child.stdout?.on("data", onData); + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.once("exit", (code, signal) => { + if (output.includes(marker)) return; + clearTimeout(timer); + reject(new Error(`Lease child exited before ${marker}: ${code ?? signal ?? "unknown"}. Output: ${output}`)); + }); + }); +} + +async function waitForChildExit(child: ChildProcess): Promise { + if (child.exitCode != null || child.signalCode != null) return; + await new Promise((resolve) => child.once("exit", () => resolve())); +} + +describe("Pi native session leases", () => { + it("resolves and excludes concurrent SDK/CLI writers", () => { + const session = makeSession(); + const canonicalFile = fs.realpathSync.native(session.file); + expect(resolvePiSessionFile({ cwd: session.cwd, sessionId: session.id, sessionRoot: path.join(session.root, "sessions") })).toBe(canonicalFile); + expect(resolvePiSessionFile({ cwd: session.cwd, sessionId: "", sessionFile: session.file })).toBe(canonicalFile); + + const sdk = acquirePiSessionLease({ sessionFile: session.file, owner: "sdk", ownerId: "chat-1" }); + expect(() => acquirePiSessionLease({ sessionFile: session.file, owner: "cli", ownerId: "pty-1" })).toThrow(/already owned/iu); + + sdk.release(); + const cli = acquirePiSessionLease({ sessionFile: session.file, owner: "cli", ownerId: "pty-1" }); + expect(fs.existsSync(`${session.file}.ade-lease`)).toBe(true); + cli.release(); + expect(fs.existsSync(`${session.file}.ade-lease`)).toBe(false); + }); + + // Pi buffers a new session in memory and only writes the JSONL file on the + // first assistant message, so ADE has to take ownership of a path that does + // not exist yet. Requiring the file up front made every new Pi chat fail + // with "Pi SDK worker returned a session outside the authorized native + // session directory". + it("accepts a session Pi has named but not yet written", () => { + const session = makeSession(); + const sessionRoot = path.join(session.root, "sessions"); + const planned = path.join(sessionRoot, "encoded", "2026-04-02T00-00-00-000Z_pending.jsonl"); + + expect(resolvePiSessionFile({ cwd: session.cwd, sessionId: "", sessionFile: planned, sessionRoot })).toBeNull(); + // Canonicalized through the deepest directory that already exists, so the + // eventual `.ade-lease` sidecar cannot be created twice for one JSONL file + // under macOS aliases such as /var -> /private/var. + expect(classifyPiSessionFile({ filePath: planned, cwd: session.cwd, sessionRoot })).toEqual({ + state: "pending", + filePath: path.join(fs.realpathSync.native(path.dirname(planned)), path.basename(planned)), + }); + expect(classifyPiSessionFile({ filePath: session.file, cwd: session.cwd, sessionId: session.id, sessionRoot })).toEqual({ + state: "authorized", + filePath: fs.realpathSync.native(session.file), + }); + }); + + // ADE mis-assigned a days-old session to a terminal once, stored it as that + // terminal's resume target, and then reopened it on every relaunch — the + // user's typed message went into a stranger's transcript. A terminal creates + // its own session, so one that predates the terminal cannot be its own. + it("refuses a resume target older than the terminal that would open it", () => { + const session = makeSession(); + const startedAt = "2026-08-10T17:44:00.000Z"; + + // Four days older than the terminal — the exact shape ADE mis-assigned. + const stale = path.join(path.dirname(session.file), "stale.jsonl"); + fs.writeFileSync(stale, `${JSON.stringify({ + type: "session", + id: "019fd7c4-4d85-7b4d-8adc-86766db98403", + cwd: session.cwd, + timestamp: "2026-08-06T15:49:54.181Z", + })}\n`); + expect(piSessionCouldBelongToTerminal({ sessionFile: stale, terminalStartedAt: startedAt })).toBe(false); + + const own = path.join(path.dirname(session.file), "own.jsonl"); + fs.writeFileSync(own, `${JSON.stringify({ + type: "session", + id: "own-session", + cwd: session.cwd, + timestamp: "2026-08-10T17:44:03.000Z", + })}\n`); + expect(piSessionCouldBelongToTerminal({ sessionFile: own, terminalStartedAt: startedAt })).toBe(true); + + // Pi can write its header a moment before ADE's row; the grace covers that. + const early = path.join(path.dirname(session.file), "early.jsonl"); + fs.writeFileSync(early, `${JSON.stringify({ + type: "session", + id: "early-session", + cwd: session.cwd, + timestamp: "2026-08-10T17:43:30.000Z", + })}\n`); + expect(piSessionCouldBelongToTerminal({ sessionFile: early, terminalStartedAt: startedAt })).toBe(true); + + // Nothing to compare against must never block a resume. + expect(piSessionCouldBelongToTerminal({ sessionFile: own, terminalStartedAt: null })).toBe(true); + expect(piSessionCouldBelongToTerminal({ sessionFile: "/nope.jsonl", terminalStartedAt: startedAt })).toBe(true); + }); + + // Chat and the tracked CLI share one native store, and two sessions created + // minutes apart are indistinguishable by time — a terminal opened an ADE + // chat's session and replayed its transcript. Ownership has to be recorded, + // not inferred. + it("keeps a terminal from adopting a session another ADE surface owns", () => { + const session = makeSession(); + const chatOwned = path.join(path.dirname(session.file), "chat.jsonl"); + fs.writeFileSync(chatOwned, "{}\n"); + + // Unowned: a `pi` run started outside ADE stays adoptable. + expect(piSessionIsAdoptableByTerminal(chatOwned, "terminal-1")).toBe(true); + + recordPiSessionOwner({ sessionFile: chatOwned, owner: "sdk", ownerSessionId: "chat-61fa4009" }); + expect(readPiSessionOwner(chatOwned)).toEqual({ owner: "sdk", ownerSessionId: "chat-61fa4009" }); + expect(piSessionIsAdoptableByTerminal(chatOwned, "terminal-1")).toBe(false); + + const mine = path.join(path.dirname(session.file), "mine.jsonl"); + fs.writeFileSync(mine, "{}\n"); + recordPiSessionOwner({ sessionFile: mine, owner: "cli", ownerSessionId: "terminal-1" }); + expect(piSessionIsAdoptableByTerminal(mine, "terminal-1")).toBe(true); + // Another terminal's session is not this terminal's to reopen either. + expect(piSessionIsAdoptableByTerminal(mine, "terminal-2")).toBe(false); + + // Ownership survives the lease being released — that is the whole point. + const lease = acquirePiSessionLease({ sessionFile: mine, owner: "cli", ownerId: "pty-1" }); + lease.release(); + expect(readPiSessionOwner(mine)).toEqual({ owner: "cli", ownerSessionId: "terminal-1" }); + }); + + it("refuses planned session paths that leave the authorized store", () => { + const session = makeSession(); + const sessionRoot = path.join(session.root, "sessions"); + const escape = path.join(session.root, "elsewhere"); + fs.mkdirSync(escape, { recursive: true }); + fs.symlinkSync(escape, path.join(sessionRoot, "linked"), "dir"); + + expect(classifyPiSessionFile({ + filePath: path.join(session.root, "outside.jsonl"), + cwd: session.cwd, + sessionRoot, + }).state).toBe("rejected"); + // Lexically inside the store, actually outside it. + expect(classifyPiSessionFile({ + filePath: path.join(sessionRoot, "linked", "new.jsonl"), + cwd: session.cwd, + sessionRoot, + }).state).toBe("rejected"); + expect(classifyPiSessionFile({ filePath: "relative.jsonl", cwd: session.cwd, sessionRoot }).state).toBe("rejected"); + // Written, but for a different working directory. + expect(classifyPiSessionFile({ + filePath: session.file, + cwd: path.join(session.root, "other-worktree"), + sessionRoot, + }).state).toBe("rejected"); + }); + + // Pi fixes a session's file name when it creates the session, so two chats + // in one working directory get distinct paths and can both be owned at once. + // Making them share a directory-wide token until the first assistant message + // would stop a second Pi chat in a lane from starting at all. + it("owns two not-yet-written sessions for one working directory independently", () => { + const session = makeSession(); + const sessionRoot = path.join(session.root, "sessions"); + const first = path.join(sessionRoot, "encoded", "2026-04-02T00-00-00-000Z_a.jsonl"); + const second = path.join(sessionRoot, "encoded", "2026-04-02T00-00-01-000Z_b.jsonl"); + + const leaseA = acquirePiSessionLease({ sessionFile: first, owner: "sdk", ownerId: "chat-a" }); + const leaseB = acquirePiSessionLease({ sessionFile: second, owner: "sdk", ownerId: "chat-b" }); + expect(piSessionLeaseIsHeld(first)).toBe(true); + expect(piSessionLeaseIsHeld(second)).toBe(true); + expect(piSessionLeaseIsHeld(path.join(sessionRoot, "encoded", "unleased.jsonl"))).toBe(false); + expect(() => acquirePiSessionLease({ sessionFile: first, owner: "cli", ownerId: "pty-1" })).toThrow(/already owned/iu); + + leaseA.release(); + leaseB.release(); + expect(piSessionLeaseIsHeld(first)).toBe(false); + }); + + // The store root is shared by every project on the machine, so a single + // root-wide creation token would make one lane's first Pi session block + // every other lane's. + it("scopes the session-creation lease to a working directory", () => { + const session = makeSession(); + const sessionRoot = path.join(session.root, "sessions"); + const first = piSessionCreationLeaseTarget(sessionRoot, session.cwd); + const second = piSessionCreationLeaseTarget(sessionRoot, path.join(session.root, "other-worktree")); + + expect(first).not.toBe(second); + expect(path.dirname(first)).toBe(path.resolve(sessionRoot)); + expect(piSessionCreationLeaseTarget(sessionRoot, `${session.cwd}${path.sep}`)).toBe(first); + + const lease = acquirePiSessionLease({ sessionFile: first, owner: "sdk", ownerId: "chat-1" }); + const other = acquirePiSessionLease({ sessionFile: second, owner: "cli", ownerId: "pty-1" }); + expect(() => acquirePiSessionLease({ sessionFile: first, owner: "cli", ownerId: "pty-2" })).toThrow(/already owned/iu); + lease.release(); + other.release(); + }); + + // Pi nests per cwd only when it is told nothing; an explicit directory is + // used flat. Handing Pi the store root would write files where Pi's own + // subdirectory-only discovery can never read them back. + it("separates the authorized store root from the directory Pi writes into", () => { + const session = makeSession(); + const sessionRoot = path.join(session.root, "sessions"); + + expect(piSessionStoreForEnvironment({ HOME: session.root })).toEqual({ + root: path.join(session.root, ".pi", "agent", "sessions"), + storageDir: null, + }); + expect(piSessionStoreForEnvironment({ HOME: session.root, PI_CODING_AGENT_SESSION_DIR: sessionRoot })).toEqual({ + root: path.resolve(sessionRoot), + storageDir: path.resolve(sessionRoot), + }); + + const agentDir = path.join(session.root, "agent"); + fs.mkdirSync(agentDir, { recursive: true }); + // `sessionDir` is Pi's own settings key, not ADE's field name. + fs.writeFileSync(path.join(agentDir, "settings.json"), JSON.stringify({ sessionDir: sessionRoot })); + expect(piSessionStoreForEnvironment({ HOME: session.root, PI_CODING_AGENT_DIR: agentDir })).toEqual({ + root: path.resolve(sessionRoot), + storageDir: path.resolve(sessionRoot), + }); + // The environment variable still wins, exactly as it does for Pi's CLI. + expect(piSessionStoreForEnvironment({ + HOME: session.root, + PI_CODING_AGENT_DIR: agentDir, + PI_CODING_AGENT_SESSION_DIR: session.root, + }).root).toBe(path.resolve(session.root)); + }); + + it("honors the user-selected native Pi session directory", () => { + const session = makeSession(); + const sessionRoot = path.join(session.root, "sessions"); + expect(piSessionRootForEnvironment({ + HOME: session.root, + PI_CODING_AGENT_SESSION_DIR: sessionRoot, + })).toBe(path.resolve(sessionRoot)); + expect(resolvePiSessionFile({ + cwd: session.cwd, + sessionId: session.id, + env: { HOME: session.root, PI_CODING_AGENT_SESSION_DIR: sessionRoot }, + })).toBe(fs.realpathSync.native(session.file)); + }); + + it("cleans a dead writer sidecar but never overwrites a live one", () => { + const session = makeSession(); + const lockPath = `${session.file}.ade-lease`; + fs.writeFileSync(lockPath, `${JSON.stringify({ + version: 1, + token: "dead", + owner: "sdk", + ownerId: "old", + pid: 999_999_999, + acquiredAt: new Date().toISOString(), + sessionFile: session.file, + })}\n`); + + const lease = acquirePiSessionLease({ sessionFile: session.file, owner: "cli", ownerId: "pty-2" }); + expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).token).not.toBe("dead"); + lease.release(); + }); + + it("uses the process start identity to distinguish a reused PID", () => { + const session = makeSession(); + const lockPath = `${session.file}.ade-lease`; + fs.writeFileSync(lockPath, `${JSON.stringify({ + version: 2, + token: "reused-pid", + owner: "sdk", + ownerId: "old-runtime", + pid: process.pid, + processStartedAt: "2026-01-01T00:00:00.000Z", + acquiredAt: new Date().toISOString(), + sessionFile: session.file, + })}\n`); + + const lease = acquirePiSessionLease({ + sessionFile: session.file, + owner: "cli", + ownerId: "new-runtime", + isProcessIdentityLive: (_pid, startedAt) => startedAt === "2026-08-06T00:00:00.000Z", + }); + expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).token).not.toBe("reused-pid"); + lease.release(); + + fs.writeFileSync(lockPath, `${JSON.stringify({ + version: 2, + token: "same-incarnation", + owner: "sdk", + ownerId: "live-runtime", + pid: process.pid, + processStartedAt: "2026-08-06T00:00:00.000Z", + acquiredAt: new Date().toISOString(), + sessionFile: session.file, + })}\n`); + expect(() => acquirePiSessionLease({ + sessionFile: session.file, + owner: "cli", + ownerId: "blocked-runtime", + isProcessIdentityLive: (_pid, startedAt) => startedAt === "2026-08-06T00:00:00.000Z", + })).toThrow(/already owned/iu); + fs.unlinkSync(lockPath); + }); + + it("does not release a replacement sidecar owned by another writer", () => { + const session = makeSession(); + const lockPath = `${session.file}.ade-lease`; + const lease = acquirePiSessionLease({ sessionFile: session.file, owner: "sdk", ownerId: "chat-lease" }); + const replacement = { + version: 1 as const, + token: "replacement", + owner: "cli" as const, + ownerId: "external-cli", + pid: process.pid, + acquiredAt: new Date().toISOString(), + sessionFile: session.file, + }; + fs.writeFileSync(lockPath, `${JSON.stringify(replacement)}\n`); + + lease.release(); + expect(JSON.parse(fs.readFileSync(lockPath, "utf8"))).toMatchObject({ token: "replacement", owner: "cli" }); + fs.unlinkSync(lockPath); + }); + + it("rejects a session id whose header belongs to another cwd", () => { + const session = makeSession(); + const otherCwd = path.join(session.root, "other"); + expect(resolvePiSessionFile({ cwd: otherCwd, sessionId: session.id, sessionRoot: path.join(session.root, "sessions") })).toBeNull(); + expect(resolvePiSessionFile({ cwd: otherCwd, sessionId: "", sessionFile: session.file, sessionRoot: path.join(session.root, "sessions") })).toBeNull(); + }); + + it("rejects native headers with a missing cwd at both explicit and id lookup boundaries", () => { + const session = makeSession(); + const missingCwdId = "019fd86d-f40d-76c6-a194-d5ba030cbad4"; + const missingCwdFile = path.join(session.root, "sessions", "encoded", `${missingCwdId}.jsonl`); + fs.writeFileSync(missingCwdFile, `${JSON.stringify({ type: "session", id: missingCwdId })}\n`); + + expect(resolvePiSessionFile({ + cwd: session.cwd, + sessionId: missingCwdId, + sessionFile: missingCwdFile, + sessionRoot: path.join(session.root, "sessions"), + })).toBeNull(); + expect(resolvePiSessionFile({ + cwd: session.cwd, + sessionId: missingCwdId, + sessionRoot: path.join(session.root, "sessions"), + })).toBeNull(); + }); + + it("snapshots only exact-cwd native sessions for implicit PTY ownership", () => { + const session = makeSession(); + const foreignCwd = path.join(session.root, "foreign"); + const foreignId = "019fd86d-f40d-76c6-a194-d5ba030cbad5"; + const foreignFile = path.join(session.root, "sessions", "encoded", `${foreignId}.jsonl`); + fs.writeFileSync(foreignFile, `${JSON.stringify({ type: "session", id: foreignId, cwd: foreignCwd })}\n`); + + expect(listPiSessionFilesForCwd({ + cwd: session.cwd, + sessionRoot: path.join(session.root, "sessions"), + })).toEqual([{ filePath: fs.realpathSync.native(session.file), id: session.id }]); + }); + + it("rejects a live lease held by another Node process and allows handoff after release", async () => { + const session = makeSession(); + const childScript = ` + const { acquirePiSessionLease } = await import(process.env.ADE_PI_LEASE_MODULE); + const lease = acquirePiSessionLease({ sessionFile: process.env.ADE_PI_LEASE_FILE, owner: "sdk", ownerId: "child" }); + process.stdout.write("ready\\n"); + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + if (chunk.includes("release")) { + lease.release(); + process.exit(0); + } + }); + `; + const child = spawn(process.execPath, [ + "--experimental-strip-types", + "--input-type=module", + "-e", + childScript, + ], { + stdio: ["pipe", "pipe", "pipe"], + env: { + PATH: process.env.PATH ?? "", + HOME: session.root, + USERPROFILE: session.root, + // Windows child processes need these: a Node spawned without SystemRoot + // fails to initialize before it ever reaches the lease code. + ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), + ...(process.env.COMSPEC ? { COMSPEC: process.env.COMSPEC } : {}), + ...(process.env.TEMP ? { TEMP: process.env.TEMP } : {}), + ADE_PI_LEASE_MODULE: pathToFileURL(path.resolve(__dirname, "piSessionLease.ts")).href, + ADE_PI_LEASE_FILE: session.file, + }, + }); + + try { + await waitForChildOutput(child, "ready"); + const lockPath = `${session.file}.ade-lease`; + const childRecord = JSON.parse(fs.readFileSync(lockPath, "utf8")) as { pid: number; token: string }; + expect(childRecord.pid).toBe(child.pid); + expect(() => acquirePiSessionLease({ sessionFile: session.file, owner: "cli", ownerId: "parent" })).toThrow(/already owned/iu); + expect(JSON.parse(fs.readFileSync(lockPath, "utf8"))).toMatchObject({ pid: child.pid, token: childRecord.token }); + + child.stdin?.write("release\\n"); + await waitForChildExit(child); + const parentLease = acquirePiSessionLease({ sessionFile: session.file, owner: "cli", ownerId: "parent" }); + expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).pid).toBe(process.pid); + parentLease.release(); + } finally { + if (child.exitCode == null && child.signalCode == null) { + child.stdin?.write("release\\n"); + await waitForChildExit(child); + if (child.exitCode == null && child.signalCode == null) child.kill(); + } + } + }); +}); + +describe("pi session store trust boundary", () => { + function makeCheckout(settings: unknown): { home: string; cwd: string } { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pi-trust-")); + tempRoots.push(home); + const cwd = path.join(home, "checkout"); + fs.mkdirSync(path.join(cwd, ".pi"), { recursive: true }); + fs.writeFileSync(path.join(cwd, ".pi", "settings.json"), JSON.stringify(settings)); + return { home, cwd }; + } + + // Pi's own SettingsManager merges a checkout's `.pi/settings.json` over the + // profile. ADE opens repositories the user has not vouched for, so honouring + // that here would let any cloned repo point ADE's session store — the thing + // that decides which files ADE authorizes and leases — at a directory the + // repo controls. + it("never lets a checkout redirect the session store", () => { + const { home, cwd } = makeCheckout({ sessionDir: "./pi-sessions" }); + const store = piSessionStoreForEnvironment({ HOME: home, USERPROFILE: home } as NodeJS.ProcessEnv); + + expect(store.root.startsWith(path.join(home, ".pi", "agent"))).toBe(true); + expect(store.root).not.toContain("pi-sessions"); + expect(repositoryOverridesPiSessionDir(cwd)).toBe(true); + }); + + it("reports no override for a checkout that does not ask for one", () => { + const { cwd } = makeCheckout({ theme: "dark" }); + expect(repositoryOverridesPiSessionDir(cwd)).toBe(false); + }); + + // The profile's own settings.json IS honoured — that file is the user's. + it("honours the session directory the user set in their own profile", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pi-profile-")); + tempRoots.push(home); + const configured = path.join(home, "elsewhere", "sessions"); + fs.mkdirSync(path.join(home, ".pi", "agent"), { recursive: true }); + fs.writeFileSync( + path.join(home, ".pi", "agent", "settings.json"), + JSON.stringify({ sessionDir: configured }), + ); + + const store = piSessionStoreForEnvironment({ HOME: home, USERPROFILE: home } as NodeJS.ProcessEnv); + expect(store.root).toBe(path.resolve(configured)); + }); +}); diff --git a/apps/desktop/src/main/services/chat/piSessionLease.test.ts b/apps/desktop/src/main/services/chat/piSessionLease.test.ts deleted file mode 100644 index abd18b4fb..000000000 --- a/apps/desktop/src/main/services/chat/piSessionLease.test.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { spawn, type ChildProcess } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; -import { afterEach, describe, expect, it } from "vitest"; -import { - acquirePiSessionLease, - listPiSessionFilesForCwd, - piSessionDirectoryForEnvironment, - resolvePiSessionFile, -} from "./piSessionLease"; - -const tempRoots: string[] = []; - -afterEach(() => { - for (const root of tempRoots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); -}); - -function makeSession(): { root: string; file: string; id: string; cwd: string } { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pi-lease-")); - tempRoots.push(root); - const cwd = path.join(root, "worktree"); - const sessionDir = path.join(root, "sessions"); - fs.mkdirSync(path.join(sessionDir, "encoded"), { recursive: true }); - const id = "019fd86d-f40d-76c6-a194-d5ba030cbad3"; - const file = path.join(sessionDir, "encoded", `2026-04-01T00-00-00-000Z_${id}.jsonl`); - fs.writeFileSync(file, `${JSON.stringify({ type: "session", id, cwd, timestamp: new Date().toISOString() })}\n`); - return { root, file, id, cwd }; -} - -async function waitForChildOutput(child: ChildProcess, marker: string): Promise { - await new Promise((resolve, reject) => { - let output = ""; - const timer = setTimeout(() => reject(new Error(`Timed out waiting for child marker ${marker}. Output: ${output}`)), 10_000); - const onData = (chunk: Buffer | string) => { - output += String(chunk); - if (!output.includes(marker)) return; - clearTimeout(timer); - child.stdout?.off("data", onData); - resolve(); - }; - child.stdout?.on("data", onData); - child.once("error", (error) => { - clearTimeout(timer); - reject(error); - }); - child.once("exit", (code, signal) => { - if (output.includes(marker)) return; - clearTimeout(timer); - reject(new Error(`Lease child exited before ${marker}: ${code ?? signal ?? "unknown"}. Output: ${output}`)); - }); - }); -} - -async function waitForChildExit(child: ChildProcess): Promise { - if (child.exitCode != null || child.signalCode != null) return; - await new Promise((resolve) => child.once("exit", () => resolve())); -} - -describe("Pi native session leases", () => { - it("resolves and excludes concurrent SDK/CLI writers", () => { - const session = makeSession(); - const canonicalFile = fs.realpathSync(session.file); - expect(resolvePiSessionFile({ cwd: session.cwd, sessionId: session.id, sessionDir: path.join(session.root, "sessions") })).toBe(canonicalFile); - expect(resolvePiSessionFile({ cwd: session.cwd, sessionId: "", sessionFile: session.file })).toBe(canonicalFile); - - const sdk = acquirePiSessionLease({ sessionFile: session.file, owner: "sdk", ownerId: "chat-1" }); - expect(() => acquirePiSessionLease({ sessionFile: session.file, owner: "cli", ownerId: "pty-1" })).toThrow(/already owned/iu); - - sdk.release(); - const cli = acquirePiSessionLease({ sessionFile: session.file, owner: "cli", ownerId: "pty-1" }); - expect(fs.existsSync(`${session.file}.ade-lease`)).toBe(true); - cli.release(); - expect(fs.existsSync(`${session.file}.ade-lease`)).toBe(false); - }); - - it("honors the user-selected native Pi session directory", () => { - const session = makeSession(); - const sessionDir = path.join(session.root, "sessions"); - expect(piSessionDirectoryForEnvironment({ - HOME: session.root, - PI_CODING_AGENT_SESSION_DIR: sessionDir, - })).toBe(path.resolve(sessionDir)); - expect(resolvePiSessionFile({ - cwd: session.cwd, - sessionId: session.id, - env: { HOME: session.root, PI_CODING_AGENT_SESSION_DIR: sessionDir }, - })).toBe(fs.realpathSync(session.file)); - }); - - it("cleans a dead writer sidecar but never overwrites a live one", () => { - const session = makeSession(); - const lockPath = `${session.file}.ade-lease`; - fs.writeFileSync(lockPath, `${JSON.stringify({ - version: 1, - token: "dead", - owner: "sdk", - ownerId: "old", - pid: 999_999_999, - acquiredAt: new Date().toISOString(), - sessionFile: session.file, - })}\n`); - - const lease = acquirePiSessionLease({ sessionFile: session.file, owner: "cli", ownerId: "pty-2" }); - expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).token).not.toBe("dead"); - lease.release(); - }); - - it("uses the process start identity to distinguish a reused PID", () => { - const session = makeSession(); - const lockPath = `${session.file}.ade-lease`; - fs.writeFileSync(lockPath, `${JSON.stringify({ - version: 2, - token: "reused-pid", - owner: "sdk", - ownerId: "old-runtime", - pid: process.pid, - processStartedAt: "2026-01-01T00:00:00.000Z", - acquiredAt: new Date().toISOString(), - sessionFile: session.file, - })}\n`); - - const lease = acquirePiSessionLease({ - sessionFile: session.file, - owner: "cli", - ownerId: "new-runtime", - isProcessIdentityLive: (_pid, startedAt) => startedAt === "2026-08-06T00:00:00.000Z", - }); - expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).token).not.toBe("reused-pid"); - lease.release(); - - fs.writeFileSync(lockPath, `${JSON.stringify({ - version: 2, - token: "same-incarnation", - owner: "sdk", - ownerId: "live-runtime", - pid: process.pid, - processStartedAt: "2026-08-06T00:00:00.000Z", - acquiredAt: new Date().toISOString(), - sessionFile: session.file, - })}\n`); - expect(() => acquirePiSessionLease({ - sessionFile: session.file, - owner: "cli", - ownerId: "blocked-runtime", - isProcessIdentityLive: (_pid, startedAt) => startedAt === "2026-08-06T00:00:00.000Z", - })).toThrow(/already owned/iu); - fs.unlinkSync(lockPath); - }); - - it("does not release a replacement sidecar owned by another writer", () => { - const session = makeSession(); - const lockPath = `${session.file}.ade-lease`; - const lease = acquirePiSessionLease({ sessionFile: session.file, owner: "sdk", ownerId: "chat-lease" }); - const replacement = { - version: 1 as const, - token: "replacement", - owner: "cli" as const, - ownerId: "external-cli", - pid: process.pid, - acquiredAt: new Date().toISOString(), - sessionFile: session.file, - }; - fs.writeFileSync(lockPath, `${JSON.stringify(replacement)}\n`); - - lease.release(); - expect(JSON.parse(fs.readFileSync(lockPath, "utf8"))).toMatchObject({ token: "replacement", owner: "cli" }); - fs.unlinkSync(lockPath); - }); - - it("rejects a session id whose header belongs to another cwd", () => { - const session = makeSession(); - const otherCwd = path.join(session.root, "other"); - expect(resolvePiSessionFile({ cwd: otherCwd, sessionId: session.id, sessionDir: path.join(session.root, "sessions") })).toBeNull(); - expect(resolvePiSessionFile({ cwd: otherCwd, sessionId: "", sessionFile: session.file, sessionDir: path.join(session.root, "sessions") })).toBeNull(); - }); - - it("rejects native headers with a missing cwd at both explicit and id lookup boundaries", () => { - const session = makeSession(); - const missingCwdId = "019fd86d-f40d-76c6-a194-d5ba030cbad4"; - const missingCwdFile = path.join(session.root, "sessions", "encoded", `${missingCwdId}.jsonl`); - fs.writeFileSync(missingCwdFile, `${JSON.stringify({ type: "session", id: missingCwdId })}\n`); - - expect(resolvePiSessionFile({ - cwd: session.cwd, - sessionId: missingCwdId, - sessionFile: missingCwdFile, - sessionDir: path.join(session.root, "sessions"), - })).toBeNull(); - expect(resolvePiSessionFile({ - cwd: session.cwd, - sessionId: missingCwdId, - sessionDir: path.join(session.root, "sessions"), - })).toBeNull(); - }); - - it("snapshots only exact-cwd native sessions for implicit PTY ownership", () => { - const session = makeSession(); - const foreignCwd = path.join(session.root, "foreign"); - const foreignId = "019fd86d-f40d-76c6-a194-d5ba030cbad5"; - const foreignFile = path.join(session.root, "sessions", "encoded", `${foreignId}.jsonl`); - fs.writeFileSync(foreignFile, `${JSON.stringify({ type: "session", id: foreignId, cwd: foreignCwd })}\n`); - - expect(listPiSessionFilesForCwd({ - cwd: session.cwd, - sessionDir: path.join(session.root, "sessions"), - })).toEqual([{ filePath: fs.realpathSync(session.file), id: session.id }]); - }); - - it("rejects a live lease held by another Node process and allows handoff after release", async () => { - const session = makeSession(); - const childScript = ` - const { acquirePiSessionLease } = await import(process.env.ADE_PI_LEASE_MODULE); - const lease = acquirePiSessionLease({ sessionFile: process.env.ADE_PI_LEASE_FILE, owner: "sdk", ownerId: "child" }); - process.stdout.write("ready\\n"); - process.stdin.setEncoding("utf8"); - process.stdin.on("data", (chunk) => { - if (chunk.includes("release")) { - lease.release(); - process.exit(0); - } - }); - `; - const child = spawn(process.execPath, [ - "--experimental-strip-types", - "--input-type=module", - "-e", - childScript, - ], { - stdio: ["pipe", "pipe", "pipe"], - env: { - PATH: process.env.PATH ?? "", - HOME: session.root, - USERPROFILE: session.root, - ADE_PI_LEASE_MODULE: pathToFileURL(path.resolve(__dirname, "piSessionLease.ts")).href, - ADE_PI_LEASE_FILE: session.file, - }, - }); - - try { - await waitForChildOutput(child, "ready"); - const lockPath = `${session.file}.ade-lease`; - const childRecord = JSON.parse(fs.readFileSync(lockPath, "utf8")) as { pid: number; token: string }; - expect(childRecord.pid).toBe(child.pid); - expect(() => acquirePiSessionLease({ sessionFile: session.file, owner: "cli", ownerId: "parent" })).toThrow(/already owned/iu); - expect(JSON.parse(fs.readFileSync(lockPath, "utf8"))).toMatchObject({ pid: child.pid, token: childRecord.token }); - - child.stdin?.write("release\\n"); - await waitForChildExit(child); - const parentLease = acquirePiSessionLease({ sessionFile: session.file, owner: "cli", ownerId: "parent" }); - expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).pid).toBe(process.pid); - parentLease.release(); - } finally { - if (child.exitCode == null && child.signalCode == null) { - child.stdin?.write("release\\n"); - await waitForChildExit(child); - if (child.exitCode == null && child.signalCode == null) child.kill(); - } - } - }); -}); diff --git a/apps/desktop/src/main/services/chat/piSessionLease.ts b/apps/desktop/src/main/services/chat/piSessionLease.ts index cb4d372f0..c83e56489 100644 --- a/apps/desktop/src/main/services/chat/piSessionLease.ts +++ b/apps/desktop/src/main/services/chat/piSessionLease.ts @@ -1,11 +1,10 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; // The cross-process lease test loads this source file directly with Node's // strip-types loader, which requires the explicit source extension. // @ts-expect-error TS5097: the bundler resolves the sibling TypeScript module. -import { pathsEqual } from "../shared/pathCompare.ts"; +import { pathKey } from "../shared/pathCompare.ts"; export type PiSessionLeaseOwner = "sdk" | "cli"; @@ -36,163 +35,6 @@ function nonEmpty(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } -function homeDir(env: NodeJS.ProcessEnv): string { - return nonEmpty(env.USERPROFILE) ?? nonEmpty(env.HOME) ?? os.homedir(); -} - -/** Resolve the native Pi session tree used by both CLI and discovery paths. */ -export function piSessionDirectoryForEnvironment( - env: NodeJS.ProcessEnv = process.env, - fallbackDir?: string | null, -): string { - const explicit = nonEmpty(env.PI_CODING_AGENT_SESSION_DIR); - if (explicit && path.isAbsolute(explicit)) return path.resolve(explicit); - if (fallbackDir && path.isAbsolute(fallbackDir)) return path.resolve(fallbackDir); - const agentDir = nonEmpty(env.PI_CODING_AGENT_DIR) ?? path.join(homeDir(env), ".pi", "agent"); - return path.join(path.resolve(agentDir), "sessions"); -} - -function samePath(left: string, right: string): boolean { - return pathsEqual(path.resolve(left), path.resolve(right)); -} - -function pathWithinDirectory(filePath: string, directoryPath: string): boolean { - const relative = path.relative(directoryPath, filePath); - return relative === "" - || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); -} - -export type PiSessionHeader = { - id: string; - /** Normalized, non-empty native Pi working directory from the header. */ - cwd: string; -}; - -export function normalizePiSessionCwd(value: unknown): string | null { - const clean = nonEmpty(value); - return clean ? path.resolve(clean) : null; -} - -/** Read a native Pi header. A session without a cwd is invalid for ADE use. */ -export function readPiSessionHeader(filePath: string): PiSessionHeader | null { - try { - const line = fs.readFileSync(filePath, "utf8").split(/\r?\n/u, 1)[0] ?? ""; - const parsed = JSON.parse(line) as Record; - const id = nonEmpty(parsed.id); - const cwd = normalizePiSessionCwd(parsed.cwd); - return parsed.type === "session" && id && cwd ? { id, cwd } : null; - } catch { - return null; - } -} - -export function piSessionHeaderMatchesCwd( - header: Pick | null | undefined, - requestedCwd: unknown, -): boolean { - const expected = normalizePiSessionCwd(requestedCwd); - return Boolean(header?.cwd && expected && samePath(header.cwd, expected)); -} - -function canonicalSessionFile(filePath: string): string | null { - try { - return fs.realpathSync(path.resolve(filePath)); - } catch { - return null; - } -} - -export type PiSessionFile = { - filePath: string; - id: string; -}; - -/** - * Snapshot every valid native Pi session under a configured root. - * - * This deliberately reads the header instead of trusting timestamp-prefixed - * filenames, refuses symlinks, and requires an exact requested cwd. Callers - * use it before an implicit/fork launch to distinguish a newly-created JSONL - * from a recent session that was already present. - */ -export function listPiSessionFilesForCwd(args: { - cwd: string; - sessionDir?: string | null; - env?: NodeJS.ProcessEnv; -}): PiSessionFile[] { - const requestedCwd = normalizePiSessionCwd(args.cwd); - if (!requestedCwd) return []; - const root = nonEmpty(args.sessionDir) ?? piSessionDirectoryForEnvironment(args.env ?? process.env); - const pendingDirectories = [path.resolve(root)]; - const files: PiSessionFile[] = []; - const seenFiles = new Set(); - - while (pendingDirectories.length > 0) { - const directory = pendingDirectories.pop(); - if (!directory) continue; - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(directory, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - const candidate = path.join(directory, entry.name); - if (entry.isSymbolicLink()) continue; - if (entry.isDirectory()) { - pendingDirectories.push(candidate); - continue; - } - if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; - const resolved = canonicalSessionFile(candidate); - if (!resolved || seenFiles.has(resolved)) continue; - const header = readPiSessionHeader(resolved); - if (!header || !piSessionHeaderMatchesCwd(header, requestedCwd)) continue; - seenFiles.add(resolved); - files.push({ filePath: resolved, id: header.id }); - } - } - return files; -} - -/** Resolve a native Pi JSONL file without importing Pi. */ -export function resolvePiSessionFile(args: { - cwd: string; - sessionId: string; - sessionFile?: string | null; - sessionDir?: string | null; - env?: NodeJS.ProcessEnv; -}): string | null { - const targetId = args.sessionId.trim(); - const explicit = nonEmpty(args.sessionFile); - if (!targetId && !explicit) return null; - if (explicit) { - // Use the filesystem's canonical spelling so macOS aliases such as - // /var -> /private/var cannot produce two sidecars for one JSONL file. - const resolved = canonicalSessionFile(explicit); - if (!resolved) return null; - const requestedSessionDir = nonEmpty(args.sessionDir); - if (requestedSessionDir) { - let canonicalSessionDir: string; - try { - canonicalSessionDir = fs.realpathSync(path.resolve(requestedSessionDir)); - } catch { - return null; - } - if (!pathWithinDirectory(resolved, canonicalSessionDir)) return null; - } - const header = readPiSessionHeader(resolved); - if (header && (!targetId || header.id === targetId) && piSessionHeaderMatchesCwd(header, args.cwd)) return resolved; - return null; - } - if (!targetId) return null; - return listPiSessionFilesForCwd({ - cwd: args.cwd, - ...(args.sessionDir ? { sessionDir: args.sessionDir } : {}), - ...(args.env ? { env: args.env } : {}), - }).find((session) => session.id === targetId)?.filePath ?? null; -} - function lockPathFor(sessionFile: string): string { return `${sessionFile}.ade-lease`; } @@ -418,7 +260,33 @@ export function piSessionLeasePath(sessionFile: string): string { return lockPathFor(path.resolve(sessionFile)); } -/** Synthetic target used to serialize ADE-created sessions before Pi writes its first JSONL file. */ -export function piSessionCreationLeaseTarget(sessionDir: string): string { - return path.join(path.resolve(sessionDir), ".ade-session-create"); +/** + * Whether another live writer already owns this native session. + * + * Used to keep a launch from adopting a session that belongs to someone else — + * cheaper, and far less destructive, than discovering it by failing to acquire + * the lease after the launch has already committed to that session. + */ +export function piSessionLeaseIsHeld( + sessionFile: string, + isProcessIdentityLive?: ProcessIdentityLiveCheck, +): boolean { + const lockPath = lockPathFor(path.resolve(sessionFile)); + if (localLeases.has(lockPath)) return true; + const record = readLease(lockPath); + return Boolean(record && leaseOwnerIsLive(record, isProcessIdentityLive)); +} + +/** + * Synthetic target used to serialize ADE-created sessions before Pi writes its + * first JSONL file. + * + * Keyed by working directory: the store root is shared by every project on the + * machine, so a single root-wide token would make one lane's starting Pi chat + * block every other lane's. Pi ignores the file — all of its own scans filter + * on `.jsonl`. + */ +export function piSessionCreationLeaseTarget(sessionRoot: string, cwd: string): string { + const key = createHash("sha256").update(pathKey(path.resolve(cwd))).digest("hex").slice(0, 16); + return path.join(path.resolve(sessionRoot), `.ade-session-create-${key}`); } diff --git a/apps/desktop/src/main/services/chat/piSessionOwnership.ts b/apps/desktop/src/main/services/chat/piSessionOwnership.ts new file mode 100644 index 000000000..4b496b99c --- /dev/null +++ b/apps/desktop/src/main/services/chat/piSessionOwnership.ts @@ -0,0 +1,95 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { PiSessionLeaseOwner } from "./piSessionLease"; +import { readPiSessionHeader } from "./piSessionStore"; + +function nonEmpty(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +export type PiSessionOwnerRecord = { + owner: PiSessionLeaseOwner; + /** + * ADE chat session id, or tracked terminal session id. Deliberately NOT the + * lease's `ownerId`, which is a live PTY handle: this one has to survive + * relaunches, and the two sit on adjacent lines at the call sites. + */ + ownerSessionId: string; +}; + +function ownerPathFor(sessionFile: string): string { + return `${path.resolve(sessionFile)}.ade-owner`; +} + +/** + * Record which ADE surface a native Pi session belongs to, durably. + * + * The `.ade-lease` sidecar answers "is someone writing this right now" and is + * removed on release. This answers "whose session is this" and is never + * removed, because chat and the tracked CLI share one native store: without it + * a terminal adopts a chat's session as soon as the two were created minutes + * apart, and time proximity cannot tell them apart. + */ +export function recordPiSessionOwner(args: { + sessionFile: string; + owner: PiSessionLeaseOwner; + ownerSessionId: string; +}): void { + const ownerSessionId = nonEmpty(args.ownerSessionId); + if (!ownerSessionId) return; + try { + const record: PiSessionOwnerRecord = { owner: args.owner, ownerSessionId }; + fs.writeFileSync(ownerPathFor(args.sessionFile), `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 }); + } catch { + // Ownership is an optimization for candidate selection, never a gate on + // running the session the user asked for. + } +} + +export function readPiSessionOwner(sessionFile: string): PiSessionOwnerRecord | null { + try { + const parsed = JSON.parse(fs.readFileSync(ownerPathFor(sessionFile), "utf8")) as Partial; + const ownerSessionId = nonEmpty(parsed.ownerSessionId); + return (parsed.owner === "sdk" || parsed.owner === "cli") && ownerSessionId + ? { owner: parsed.owner, ownerSessionId } + : null; + } catch { + return null; + } +} + +/** + * Whether a tracked terminal may adopt this session as its own. + * + * Unowned sessions stay adoptable so a `pi` run started outside ADE can still + * be picked up; anything already claimed by an ADE chat, or by a different + * terminal, is not this terminal's to reopen. + */ +export function piSessionIsAdoptableByTerminal(sessionFile: string, terminalSessionId: string): boolean { + const owner = readPiSessionOwner(sessionFile); + if (!owner) return true; + return owner.owner === "cli" && owner.ownerSessionId === terminalSessionId.trim(); +} + +/** + * Whether a native Pi session could plausibly belong to a terminal that + * started at `terminalStartedAt`. + * + * A tracked terminal's session is created by that terminal, so one created + * before it existed is somebody else's — another terminal's, or an ADE chat's, + * now that both write into a single native store. ADE stored such a pointer + * once and then resumed it on every relaunch, so the check runs at resume time + * and repairs the stored target rather than trusting it. + */ +export function piSessionCouldBelongToTerminal(args: { + sessionFile: string; + terminalStartedAt: string | null | undefined; + /** Clock skew and the gap between ADE's row and Pi's first write. */ + graceMs?: number; +}): boolean { + const startedAt = Date.parse(args.terminalStartedAt?.trim() || ""); + if (!Number.isFinite(startedAt)) return true; + const createdAt = readPiSessionHeader(args.sessionFile)?.createdAt; + if (createdAt == null) return true; + return createdAt >= startedAt - (args.graceMs ?? 60_000); +} diff --git a/apps/desktop/src/main/services/chat/piSessionStore.ts b/apps/desktop/src/main/services/chat/piSessionStore.ts new file mode 100644 index 000000000..ce6fa2782 --- /dev/null +++ b/apps/desktop/src/main/services/chat/piSessionStore.ts @@ -0,0 +1,327 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathsEqual } from "../shared/pathCompare"; + +function nonEmpty(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function homeDir(env: NodeJS.ProcessEnv): string { + return nonEmpty(env.USERPROFILE) ?? nonEmpty(env.HOME) ?? os.homedir(); +} + +export function piAgentDirectoryForEnvironment(env: NodeJS.ProcessEnv = process.env): string { + return path.resolve(nonEmpty(env.PI_CODING_AGENT_DIR) ?? path.join(homeDir(env), ".pi", "agent")); +} + +function expandHome(value: string, env: NodeJS.ProcessEnv): string { + if (value === "~") return homeDir(env); + if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(homeDir(env), value.slice(2)); + return value; +} + +/** + * Read the session directory the user configured in Pi's own global settings. + * + * Deliberately only the profile-level `settings.json`. Pi's `SettingsManager` + * also merges a checkout's `.pi/settings.json`, but that file belongs to a + * repository ADE has not vouched for — honouring it here would let any cloned + * repo redirect where ADE authorizes and leases Pi sessions. + */ +function configuredSessionDir(env: NodeJS.ProcessEnv): string | null { + try { + const raw = fs.readFileSync(path.join(piAgentDirectoryForEnvironment(env), "settings.json"), "utf8"); + const value = nonEmpty((JSON.parse(raw) as Record).sessionDir); + if (!value) return null; + const expanded = expandHome(value, env); + return path.isAbsolute(expanded) ? path.resolve(expanded) : null; + } catch { + return null; + } +} + +/** + * A `sessionDir` set by the checkout's own `.pi/settings.json`. + * + * ADE never honours it — see `configuredSessionDir` — but Pi's CLI merges + * project settings, so a tracked `pi` terminal launched in such a repository + * writes somewhere ADE does not authorize. Detecting it lets the launch say so + * instead of failing with a generic "could not be verified" message. + */ +export function repositoryOverridesPiSessionDir(cwd: string): boolean { + try { + const raw = fs.readFileSync(path.join(path.resolve(cwd), ".pi", "settings.json"), "utf8"); + return Boolean(nonEmpty((JSON.parse(raw) as Record).sessionDir)); + } catch { + return false; + } +} + +export type PiSessionStore = { + /** Root that every native Pi session file for this profile must live under. */ + root: string; + /** + * Directory Pi itself must be told to write into, or `null` when Pi should + * use its own per-cwd default beneath the agent directory. + * + * Pi only nests sessions per cwd (`/sessions//`) when + * no directory is supplied; an explicit directory is used flat and verbatim. + * Passing `root` to Pi would therefore scatter files directly into + * `/sessions`, where Pi's own subdirectory-only discovery can never + * find them again. + */ + storageDir: string | null; +}; + +/** + * Resolve the one native Pi session store shared by ADE chat, tracked Pi CLI + * terminals, and external-session discovery. + * + * Precedence mirrors Pi's own CLI (`--session-dir` > env > settings > default); + * ADE never passes `--session-dir`, so the env variable is the top of the list. + */ +export function piSessionStoreForEnvironment(env: NodeJS.ProcessEnv = process.env): PiSessionStore { + // Pi expands `~` in this variable, so `~/pi-sessions` is a setting a user can + // really have. Ignoring it here would put Pi's writes and ADE's authorization + // in different directories — exactly the split this store exists to close. + const explicit = nonEmpty(env.PI_CODING_AGENT_SESSION_DIR); + const expandedExplicit = explicit ? expandHome(explicit, env) : null; + const configured = expandedExplicit && path.isAbsolute(expandedExplicit) + ? path.resolve(expandedExplicit) + : configuredSessionDir(env); + if (configured) return { root: configured, storageDir: configured }; + return { root: path.join(piAgentDirectoryForEnvironment(env), "sessions"), storageDir: null }; +} + +/** Resolve the native Pi session tree used by both CLI and discovery paths. */ +export function piSessionRootForEnvironment(env: NodeJS.ProcessEnv = process.env): string { + return piSessionStoreForEnvironment(env).root; +} + +function samePath(left: string, right: string): boolean { + return pathsEqual(path.resolve(left), path.resolve(right)); +} + +function pathWithinDirectory(filePath: string, directoryPath: string): boolean { + const relative = path.relative(directoryPath, filePath); + return relative === "" + || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); +} + +export type PiSessionHeader = { + id: string; + /** Normalized, non-empty native Pi working directory from the header. */ + cwd: string; + /** Epoch ms Pi created the session, or null when the header has no usable timestamp. */ + createdAt: number | null; +}; + +export function normalizePiSessionCwd(value: unknown): string | null { + const clean = nonEmpty(value); + return clean ? path.resolve(clean) : null; +} + +/** Read a native Pi header. A session without a cwd is invalid for ADE use. */ +export function readPiSessionHeader(filePath: string): PiSessionHeader | null { + try { + const line = fs.readFileSync(filePath, "utf8").split(/\r?\n/u, 1)[0] ?? ""; + const parsed = JSON.parse(line) as Record; + const id = nonEmpty(parsed.id); + const cwd = normalizePiSessionCwd(parsed.cwd); + const parsedAt = Date.parse(nonEmpty(parsed.timestamp) ?? ""); + const createdAt = Number.isFinite(parsedAt) ? parsedAt : null; + return parsed.type === "session" && id && cwd ? { id, cwd, createdAt } : null; + } catch { + return null; + } +} + +export function piSessionHeaderMatchesCwd( + header: Pick | null | undefined, + requestedCwd: unknown, +): boolean { + const expected = normalizePiSessionCwd(requestedCwd); + return Boolean(header?.cwd && expected && samePath(header.cwd, expected)); +} + +/** + * Canonical spelling of a path that exists, or `null`. + * + * `.native` throughout: on Windows it expands 8.3 short names and junction + * spellings while the JS `realpathSync` preserves whatever it was given, so + * mixing the two makes a path and its own parent directory fail a containment + * test against each other. + */ +function canonicalSessionFile(filePath: string): string | null { + try { + return fs.realpathSync.native(path.resolve(filePath)); + } catch { + return null; + } +} + +export type PiSessionFile = { + filePath: string; + id: string; +}; + +/** + * Snapshot every valid native Pi session under a configured root. + * + * This deliberately reads the header instead of trusting timestamp-prefixed + * filenames, refuses symlinks, and requires an exact requested cwd. Callers + * use it before an implicit/fork launch to distinguish a newly-created JSONL + * from a recent session that was already present. + */ +export function listPiSessionFilesForCwd(args: { + cwd: string; + sessionRoot?: string | null; + env?: NodeJS.ProcessEnv; +}): PiSessionFile[] { + const requestedCwd = normalizePiSessionCwd(args.cwd); + if (!requestedCwd) return []; + const root = nonEmpty(args.sessionRoot) ?? piSessionRootForEnvironment(args.env ?? process.env); + const pendingDirectories = [path.resolve(root)]; + const files: PiSessionFile[] = []; + const seenFiles = new Set(); + + while (pendingDirectories.length > 0) { + const directory = pendingDirectories.pop(); + if (!directory) continue; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const candidate = path.join(directory, entry.name); + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) { + pendingDirectories.push(candidate); + continue; + } + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; + const resolved = canonicalSessionFile(candidate); + if (!resolved || seenFiles.has(resolved)) continue; + const header = readPiSessionHeader(resolved); + if (!header || !piSessionHeaderMatchesCwd(header, requestedCwd)) continue; + seenFiles.add(resolved); + files.push({ filePath: resolved, id: header.id }); + } + } + return files; +} + +/** Resolve a native Pi JSONL file without importing Pi. */ +export function resolvePiSessionFile(args: { + cwd: string; + sessionId: string; + sessionFile?: string | null; + sessionRoot?: string | null; + env?: NodeJS.ProcessEnv; +}): string | null { + const targetId = args.sessionId.trim(); + const explicit = nonEmpty(args.sessionFile); + if (!targetId && !explicit) return null; + if (explicit) { + // Use the filesystem's canonical spelling so macOS aliases such as + // /var -> /private/var cannot produce two sidecars for one JSONL file. + const resolved = canonicalSessionFile(explicit); + if (!resolved) return null; + const requestedSessionDir = nonEmpty(args.sessionRoot); + if (requestedSessionDir) { + let canonicalSessionDir: string; + try { + canonicalSessionDir = fs.realpathSync.native(path.resolve(requestedSessionDir)); + } catch { + return null; + } + if (!pathWithinDirectory(resolved, canonicalSessionDir)) return null; + } + const header = readPiSessionHeader(resolved); + if (header && (!targetId || header.id === targetId) && piSessionHeaderMatchesCwd(header, args.cwd)) return resolved; + return null; + } + if (!targetId) return null; + return listPiSessionFilesForCwd({ + cwd: args.cwd, + ...(args.sessionRoot ? { sessionRoot: args.sessionRoot } : {}), + ...(args.env ? { env: args.env } : {}), + }).find((session) => session.id === targetId)?.filePath ?? null; +} + +/** + * Canonicalize the deepest existing ancestor of a path that does not exist yet. + * + * Lexical containment alone would accept `/link/new.jsonl` where `link` + * is a symlink out of the store, so the surviving prefix is resolved and the + * unresolved tail re-appended before the containment test runs. + */ +function canonicalPlannedFile(filePath: string): string | null { + const resolved = path.resolve(filePath); + const tail: string[] = []; + let current = resolved; + for (;;) { + try { + return path.join(fs.realpathSync.native(current), ...tail); + } catch { + const parent = path.dirname(current); + if (parent === current) return null; + tail.unshift(path.basename(current)); + current = parent; + } + } +} + +export type PiSessionFileState = + /** The file exists and its header matches the requested cwd and session id. */ + | "authorized" + /** Inside the store, but Pi has not written it yet — no header to check. */ + | "pending" + | "rejected"; + +export type PiSessionFileClassification = + | { state: "rejected" } + | { state: "authorized" | "pending"; filePath: string }; + +/** + * Classify a native Pi session path that ADE is about to take ownership of. + * + * Pi does not create the JSONL file until the session's first assistant + * message (`SessionManager` buffers everything before that), so a freshly + * created session reports a concrete path that does not exist yet. That path + * still has to be constrained to the authorized store before ADE leases it, + * but it cannot be header-validated until Pi flushes — hence `pending`, which + * callers resolve by re-classifying once the file appears. + */ +export function classifyPiSessionFile(args: { + filePath: string; + cwd: string; + sessionId?: string | null; + /** Required: confining the file to the store is the point of this function. */ + sessionRoot: string; +}): PiSessionFileClassification { + const rejected = { state: "rejected" } as const; + const requested = nonEmpty(args.filePath); + if (!requested || !path.isAbsolute(requested)) return rejected; + const targetId = nonEmpty(args.sessionId); + // Canonicalized by the same walker as the candidate, so the two spellings + // are always comparable. + const storeRoot = canonicalPlannedFile(args.sessionRoot); + if (!storeRoot) return rejected; + + const existing = canonicalSessionFile(requested); + if (existing) { + if (!pathWithinDirectory(existing, storeRoot)) return rejected; + const header = readPiSessionHeader(existing); + if (!header || !piSessionHeaderMatchesCwd(header, args.cwd)) return rejected; + if (targetId && header.id !== targetId) return rejected; + return { state: "authorized", filePath: existing }; + } + + const planned = canonicalPlannedFile(requested); + if (!planned || !pathWithinDirectory(planned, storeRoot)) return rejected; + return { state: "pending", filePath: planned }; +} diff --git a/apps/desktop/src/main/services/externalSessions/discoverPi.ts b/apps/desktop/src/main/services/externalSessions/discoverPi.ts index ecab160f1..080e767fc 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverPi.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverPi.ts @@ -21,10 +21,10 @@ import { } from "./discoveryUtils"; import { commandArrayToLine } from "../../../shared/shell"; import type { TerminalResumeLaunchConfig } from "../../../shared/types/sessions"; -import { piSessionDirectoryForEnvironment } from "../chat/piSessionLease"; +import { piSessionRootForEnvironment } from "../chat/piSessionStore"; function piSessionsDir(args: ExternalSessionDiscoveryArgs): string { - return piSessionDirectoryForEnvironment(args.env ?? process.env); + return piSessionRootForEnvironment(args.env ?? process.env); } function sessionTitle(records: Record[]): string | null { diff --git a/apps/desktop/src/main/services/ipc/ipcChannelRedaction.test.ts b/apps/desktop/src/main/services/ipc/ipcChannelRedaction.test.ts new file mode 100644 index 000000000..99c4545a4 --- /dev/null +++ b/apps/desktop/src/main/services/ipc/ipcChannelRedaction.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { IPC } from "../../../shared/ipc"; +import { ipcChannelRedactionMap, redactIpcArgsForChannel } from "./ipcChannelRedaction"; + +describe("ipc channel redaction", () => { + // ADE never reads, stores, or logs a provider credential. When Pi asks for an + // API key, the user's answer to that prompt IS the credential, and it travels + // over this channel as an ordinary string — nothing downstream can tell it + // apart from a device code. Dropping the channel from the map leaks it into + // any verbose IPC trace, and no other test fails. + it("redacts a Pi sign-in answer, which may be a raw API key", () => { + expect(ipcChannelRedactionMap[IPC.aiPiLoginSubmit]?.has("value")).toBe(true); + + const [redacted] = redactIpcArgsForChannel(IPC.aiPiLoginSubmit, [ + { providerId: "anthropic", requestId: "req-1", value: "sk-ant-not-a-real-key" }, + ]) as Array>; + + expect(redacted.value).toBe("[redacted]"); + expect(JSON.stringify(redacted)).not.toContain("sk-ant-not-a-real-key"); + // Non-secret fields must survive or the trace stops being useful. + expect(redacted).toMatchObject({ providerId: "anthropic", requestId: "req-1" }); + }); + + it("leaves channels with no declared secrets untouched", () => { + const args = [{ anything: "kept" }]; + expect(redactIpcArgsForChannel("some/unmapped/channel", args)).toBe(args); + }); + + it("does not descend into non-object arguments", () => { + expect(redactIpcArgsForChannel(IPC.terminalWrite, ["raw", 7, null])).toEqual(["raw", 7, null]); + }); +}); diff --git a/apps/desktop/src/main/services/ipc/ipcChannelRedaction.ts b/apps/desktop/src/main/services/ipc/ipcChannelRedaction.ts new file mode 100644 index 000000000..c8cbefd4a --- /dev/null +++ b/apps/desktop/src/main/services/ipc/ipcChannelRedaction.ts @@ -0,0 +1,49 @@ +import { IPC } from "../../../shared/ipc"; + +/** + * Channel-aware redaction: these channels carry sensitive payloads (commands, + * env vars, typed text, terminal data, credentials) that must NOT land in + * structured trace logs. Redact by replacing the field with `[redacted]` + * before the generic summarizer descends into the args. + * + * Lives outside `registerIpc` so the contract is reachable from a test. A + * channel dropping out of this map is a silent leak — nothing else fails. + */ +export const ipcChannelRedactionMap: Record> = { + [IPC.appControlLaunch]: new Set(["command", "env"]), + [IPC.appControlLaunchInTerminal]: new Set(["command", "env"]), + [IPC.appControlTypeText]: new Set(["text"]), + [IPC.appControlDispatchKey]: new Set(["text", "unmodifiedText", "key", "code"]), + [IPC.terminalWrite]: new Set(["data"]), + [IPC.ptySendToSession]: new Set(["text"]), + [IPC.ptyWrite]: new Set(["data"]), + [IPC.appOpenExternal]: new Set(["url"]), + [IPC.builtInBrowserNavigate]: new Set(["url"]), + [IPC.builtInBrowserCreateTab]: new Set(["url"]), + [IPC.builtInBrowserShowPanel]: new Set(["url"]), + [IPC.transcriptionTranscribe]: new Set(["pcm"]), + [IPC.accountPollLogin]: new Set(["sessionId"]), + [IPC.accountCancelLogin]: new Set(["sessionId"]), + [IPC.accountPairMachine]: new Set(["machineKey"]), + [IPC.accountRenameMachine]: new Set(["machineKey", "customName"]), + [IPC.accountRemoveMachine]: new Set(["machineKey"]), + [IPC.attentionNotchPublishSnapshot]: new Set(["items"]), + [IPC.attentionNotchPublishToast]: new Set(["title", "subtitle"]), + // A Pi sign-in prompt answer is the credential itself when Pi asks for an + // API key, so it must never reach a verbose IPC trace. + [IPC.aiPiLoginSubmit]: new Set(["value"]), +}; + +export function redactIpcArgsForChannel(channel: string, args: unknown[]): unknown[] { + const redactKeys = ipcChannelRedactionMap[channel]; + if (!redactKeys || redactKeys.size === 0) return args; + return args.map((arg) => { + if (!arg || typeof arg !== "object" || Array.isArray(arg)) return arg; + const record = arg as Record; + const out: Record = {}; + for (const [key, val] of Object.entries(record)) { + out[key] = redactKeys.has(key) ? "[redacted]" : val; + } + return out; + }); +} diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index d2a1fafc4..67ee3a17b 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -19,6 +19,7 @@ import type { DiskPressureMonitor, DiskPressureSnapshot } from "../storage/diskP import path from "node:path"; import { fileURLToPath } from "node:url"; import { IPC } from "../../../shared/ipc"; +import { redactIpcArgsForChannel } from "./ipcChannelRedaction"; import type { AttentionItem, AttentionNotchSettings, @@ -1929,49 +1930,6 @@ export function registerIpc({ const traceEveryIpcInvoke = process.env.ADE_TRACE_IPC === "verbose"; let ipcInvokeSeq = 0; - // Channel-aware redaction: these channels carry sensitive payloads - // (commands, env vars, typed text, terminal data) that must NOT land in - // structured trace logs. Redact by replacing the field with `[redacted]` - // before the generic summarizer descends into the args. - const ipcChannelRedactionMap: Record> = { - [IPC.appControlLaunch]: new Set(["command", "env"]), - [IPC.appControlLaunchInTerminal]: new Set(["command", "env"]), - [IPC.appControlTypeText]: new Set(["text"]), - [IPC.appControlDispatchKey]: new Set(["text", "unmodifiedText", "key", "code"]), - [IPC.terminalWrite]: new Set(["data"]), - [IPC.ptySendToSession]: new Set(["text"]), - [IPC.ptyWrite]: new Set(["data"]), - [IPC.appOpenExternal]: new Set(["url"]), - [IPC.builtInBrowserNavigate]: new Set(["url"]), - [IPC.builtInBrowserCreateTab]: new Set(["url"]), - [IPC.builtInBrowserShowPanel]: new Set(["url"]), - [IPC.transcriptionTranscribe]: new Set(["pcm"]), - [IPC.accountPollLogin]: new Set(["sessionId"]), - [IPC.accountCancelLogin]: new Set(["sessionId"]), - [IPC.accountPairMachine]: new Set(["machineKey"]), - [IPC.accountRenameMachine]: new Set(["machineKey", "customName"]), - [IPC.accountRemoveMachine]: new Set(["machineKey"]), - [IPC.attentionNotchPublishSnapshot]: new Set(["items"]), - [IPC.attentionNotchPublishToast]: new Set(["title", "subtitle"]), - // A Pi sign-in prompt answer is the credential itself when Pi asks for an - // API key, so it must never reach a verbose IPC trace. - [IPC.aiPiLoginSubmit]: new Set(["value"]), - }; - - const redactIpcArgsForChannel = (channel: string, args: unknown[]): unknown[] => { - const redactKeys = ipcChannelRedactionMap[channel]; - if (!redactKeys || redactKeys.size === 0) return args; - return args.map((arg) => { - if (!arg || typeof arg !== "object" || Array.isArray(arg)) return arg; - const record = arg as Record; - const out: Record = {}; - for (const [key, val] of Object.entries(record)) { - out[key] = redactKeys.has(key) ? "[redacted]" : val; - } - return out; - }); - }; - const shouldRedactIpcKey = (key: string | undefined): boolean => { if (!key) return false; const normalized = key.toLowerCase(); diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 999161875..e63b38274 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -182,8 +182,26 @@ const mocks = vi.hoisted(() => { } return fileContents.get(p) ?? ""; }), - unlinkSync: vi.fn(), - writeFileSync: vi.fn(), + unlinkSync: vi.fn((p: string) => { + fileContents.delete(p); + existsSyncResults.set(p, false); + }), + writeFileSync: vi.fn((p: string, data: unknown) => { + fileContents.set(p, String(data)); + existsSyncResults.set(p, true); + }), + // The Pi session lease publishes its sidecar with a create-only hard link, + // which is what makes the no-clobber guarantee atomic. Modelling it as a + // plain copy would let two writers both "win". + linkSync: vi.fn((from: string, to: string) => { + if (existsSyncResults.get(to) === true) { + const error = new Error(`EEXIST: file already exists, link '${from}' -> '${to}'`) as NodeJS.ErrnoException; + error.code = "EEXIST"; + throw error; + } + fileContents.set(to, fileContents.get(from) ?? ""); + existsSyncResults.set(to, true); + }), renameSync: vi.fn(), randomUUID: vi.fn(() => "uuid-" + Math.random().toString(36).slice(2, 10)), randomBytes: vi.fn(() => Buffer.alloc(32, nextRandomByte++ % 256)), @@ -251,6 +269,7 @@ vi.mock("node:fs", () => ({ readFileSync: mocks.readFileSync, unlinkSync: mocks.unlinkSync, writeFileSync: mocks.writeFileSync, + linkSync: mocks.linkSync, renameSync: mocks.renameSync, promises: mocks.promises, }, @@ -268,11 +287,15 @@ vi.mock("node:fs", () => ({ readFileSync: mocks.readFileSync, unlinkSync: mocks.unlinkSync, writeFileSync: mocks.writeFileSync, + linkSync: mocks.linkSync, renameSync: mocks.renameSync, promises: mocks.promises, })); -vi.mock("node:crypto", () => ({ +vi.mock("node:crypto", async (importOriginal) => ({ + // createHash is real: the Pi session store derives its per-cwd lease token + // from it, and a stubbed digest would make two different worktrees collide. + ...(await importOriginal()), randomBytes: mocks.randomBytes, randomUUID: mocks.randomUUID, })); @@ -753,15 +776,18 @@ describe("ptyService", () => { }); describe("Pi native session ownership selection", () => { + const projectRoot = "/repo/project"; + it("skips a recent pre-existing session file and selects only the new header", () => { const oldFile = "/tmp/pi-sessions/2026-08-08T00-00-00-000Z-old.jsonl"; const newFile = "/tmp/pi-sessions/2026-08-08T00-00-01-000Z-new.jsonl"; const selected = selectPiStorageSessionCandidate({ + cwd: projectRoot, startedAt: "2026-08-08T00:00:01.100Z", maxStartDeltaMs: 10_000, candidates: [ - { id: "old", sourcePath: oldFile, createdAt: Date.parse("2026-08-08T00:00:01.050Z") }, - { id: "new", sourcePath: newFile, createdAt: Date.parse("2026-08-08T00:00:01.200Z") }, + { id: "old", cwd: projectRoot, sourcePath: oldFile, createdAt: Date.parse("2026-08-08T00:00:01.050Z") }, + { id: "new", cwd: projectRoot, sourcePath: newFile, createdAt: Date.parse("2026-08-08T00:00:01.200Z") }, ], excludedIds: new Set(["old"]), excludedFiles: new Set([oldFile]), @@ -775,15 +801,81 @@ describe("ptyService", () => { const childId = "child-session"; expect(piForkParentIdFromCommand(`pi --fork ${parentId}`)).toBe(parentId); expect(selectPiStorageSessionCandidate({ + cwd: projectRoot, startedAt: "2026-08-08T00:00:01.100Z", maxStartDeltaMs: 10_000, candidates: [ - { id: parentId, sourcePath: "/tmp/pi-sessions/parent.jsonl", createdAt: Date.parse("2026-08-08T00:00:01.050Z") }, - { id: childId, sourcePath: "/tmp/pi-sessions/child.jsonl", createdAt: Date.parse("2026-08-08T00:00:01.200Z") }, + { id: parentId, cwd: projectRoot, sourcePath: "/tmp/pi-sessions/parent.jsonl", createdAt: Date.parse("2026-08-08T00:00:01.050Z") }, + { id: childId, cwd: projectRoot, sourcePath: "/tmp/pi-sessions/child.jsonl", createdAt: Date.parse("2026-08-08T00:00:01.200Z") }, ], excludedIds: new Set([parentId]), })).toEqual({ id: childId, filePath: "/tmp/pi-sessions/child.jsonl" }); }); + + // Discovery scopes by containment, so a primary lane rooted at the project + // sees every lane worktree's sessions. Selecting one produced a resume + // target that every downstream cwd check then rejected, surfacing as + // "Pi's latest session is outside the authorized native session directory". + it("ignores a newer session belonging to a lane worktree inside the cwd", () => { + expect(selectPiStorageSessionCandidate({ + cwd: projectRoot, + candidates: [ + { id: "own", cwd: projectRoot, sourcePath: "/tmp/pi-sessions/own.jsonl", updatedAt: 1_000 }, + { + id: "lane", + cwd: `${projectRoot}/.ade/worktrees/lane-a`, + sourcePath: "/tmp/pi-sessions/lane.jsonl", + updatedAt: 9_000, + }, + ], + })).toEqual({ id: "own", filePath: "/tmp/pi-sessions/own.jsonl" }); + }); + + // A terminal launched now must not adopt a session created days ago whose + // file was merely appended to a moment ago — which is what an ADE chat + // writing into the shared native store does to its own session. + it("ignores an old session whose file was just touched when matching a launch", () => { + const launchedAt = "2026-08-10T17:44:00.000Z"; + expect(selectPiStorageSessionCandidate({ + cwd: projectRoot, + startedAt: launchedAt, + maxStartDeltaMs: 10 * 60_000, + candidates: [ + { + id: "old-but-touched", + cwd: projectRoot, + sourcePath: "/tmp/pi-sessions/old.jsonl", + createdAt: Date.parse("2026-08-06T15:49:54.000Z"), + updatedAt: Date.parse(launchedAt), + }, + { + id: "mine", + cwd: projectRoot, + sourcePath: "/tmp/pi-sessions/mine.jsonl", + createdAt: Date.parse("2026-08-10T17:44:02.000Z"), + updatedAt: Date.parse("2026-08-10T17:44:02.000Z"), + }, + ], + })).toEqual({ id: "mine", filePath: "/tmp/pi-sessions/mine.jsonl" }); + }); + + it("never adopts a session another live ADE writer owns", () => { + expect(selectPiStorageSessionCandidate({ + cwd: projectRoot, + candidates: [ + { id: "leased", cwd: projectRoot, sourcePath: "/tmp/pi-sessions/leased.jsonl", updatedAt: 9_000 }, + { id: "free", cwd: projectRoot, sourcePath: "/tmp/pi-sessions/free.jsonl", updatedAt: 1_000 }, + ], + isOwnedByAnotherWriter: (filePath) => filePath.endsWith("leased.jsonl"), + })).toEqual({ id: "free", filePath: "/tmp/pi-sessions/free.jsonl" }); + }); + + it("refuses a candidate with no recorded working directory", () => { + expect(selectPiStorageSessionCandidate({ + cwd: projectRoot, + candidates: [{ id: "blank", cwd: "", sourcePath: "/tmp/pi-sessions/blank.jsonl", updatedAt: 9_000 }], + })).toBeNull(); + }); }); describe("resource attribution roots", () => { @@ -4013,6 +4105,123 @@ describe("ptyService", () => { } }); + describe("Pi resume targets", () => { + const PI_SESSION_ID = "019fd86d-f40d-76c6-a194-d5ba030cbad3"; + const PI_ROOT = "/Users/ade-test/.pi/agent/sessions"; + const PI_FILE = `${PI_ROOT}/encoded/2026-01-01T00-00-00-000Z_${PI_SESSION_ID}.jsonl`; + + /** A native Pi session on disk whose header predates the terminal. */ + function seedOldPiSession(cwd: string) { + mocks.existsSyncResults.set(PI_ROOT, true); + mocks.existsSyncResults.set(`${PI_ROOT}/encoded`, true); + mocks.existsSyncResults.set(PI_FILE, true); + // listPiSessionFilesForCwd reads with { withFileTypes: true }. + const dirent = (name: string, dir: boolean) => ({ + name, + isDirectory: () => dir, + isFile: () => !dir, + isSymbolicLink: () => false, + }); + mocks.dirEntries.set(PI_ROOT, [dirent("encoded", true)] as never); + mocks.dirEntries.set(`${PI_ROOT}/encoded`, [dirent(PI_FILE.split("/").pop() as string, false)] as never); + mocks.fileStats.set(`${PI_ROOT}/encoded`, { isDirectory: true }); + mocks.fileStats.set(PI_FILE, { isDirectory: false }); + mocks.fileContents.set( + PI_FILE, + `${JSON.stringify({ type: "session", id: PI_SESSION_ID, cwd, timestamp: "2026-01-01T00:00:00.000Z" })}\n`, + ); + } + + function endedPiSession(sessionService: ReturnType["sessionService"], extra: Record) { + sessionService.create({ + sessionId: "session-pi", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Pi CLI", + // Four months after the session file above was written. + startedAt: "2026-05-01T12:00:00.000Z", + transcriptPath: "/tmp/transcripts/session-pi.log", + toolType: "pi", + resumeCommand: `pi --session ${PI_SESSION_ID}`, + resumeMetadata: { + provider: "pi", + targetKind: "session", + targetId: PI_SESSION_ID, + launch: { model: "anthropic/claude-opus-4", thinkingLevel: "high" }, + ...extra, + }, + }); + sessionService.end({ + sessionId: "session-pi", + endedAt: "2026-05-01T12:30:00.000Z", + exitCode: 0, + status: "completed", + }); + } + + // An imported session is older than the terminal *by definition* — the + // user reached back and picked it. Applying the "older than the terminal + // means ADE mis-assigned it" heuristic here handed them a terminal + // labelled with their session that contained a blank transcript. + it("keeps a user-imported Pi session even though it predates the terminal", async () => { + const { service, sessionService, logger } = createHarness(); + seedOldPiSession("/tmp/test-worktree"); + endedPiSession(sessionService, { importedFrom: "pi" }); + + await service.create({ + sessionId: "session-pi", + laneId: "lane-1", + title: "Pi CLI", + cwd: "/tmp/test-worktree", + cols: 80, + rows: 24, + toolType: "pi", + startupCommand: `pi --session ${PI_SESSION_ID}`, + }); + + // The discard signal itself, not its cleanup: the imported pointer must + // survive the plausibility gate however that gate cleans up after + // itself. + expect(logger.warn).not.toHaveBeenCalledWith( + "pty.pi_resume_target_discarded", + expect.anything(), + ); + expect(sessionService.updateMeta).not.toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "session-pi", resumeMetadata: null }), + ); + }); + + // The same pointer with no import provenance is one ADE inferred, and a + // fresh terminal reopening a four-month-old transcript is the bug. + it("discards an ADE-inferred Pi pointer older than the terminal, clearing command and metadata together", async () => { + const { service, sessionService, logger } = createHarness(); + seedOldPiSession("/tmp/test-worktree"); + endedPiSession(sessionService, {}); + + await service.create({ + sessionId: "session-pi", + laneId: "lane-1", + title: "Pi CLI", + cwd: "/tmp/test-worktree", + cols: 80, + rows: 24, + toolType: "pi", + startupCommand: `pi --session ${PI_SESSION_ID}`, + }); + + // setResumeCommand(null) alone is a no-op: it re-derives the command + // from resumeMetadata, which still holds the bad target. + expect(sessionService.updateMeta).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "session-pi", resumeCommand: null, resumeMetadata: null }), + ); + expect(logger.warn).toHaveBeenCalledWith( + "pty.pi_resume_target_discarded", + expect.objectContaining({ sessionId: "session-pi", piSessionId: PI_SESSION_ID }), + ); + }); + }); + it("preserves the strict resume path when a requested session id does not exist", async () => { const { service } = createHarness(); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 6fcfd48d0..ce8be7db8 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -30,11 +30,21 @@ import { resolveOpenCodeBinaryPath } from "../opencode/openCodeBinaryManager"; import { acquirePiSessionLease, piSessionCreationLeaseTarget, - piSessionDirectoryForEnvironment, - listPiSessionFilesForCwd, - resolvePiSessionFile, + piSessionLeaseIsHeld, type PiSessionLease, } from "../chat/piSessionLease"; +import { + piSessionCouldBelongToTerminal, + piSessionIsAdoptableByTerminal, + recordPiSessionOwner, +} from "../chat/piSessionOwnership"; +import { + listPiSessionFilesForCwd, + piSessionHeaderMatchesCwd, + piSessionRootForEnvironment, + repositoryOverridesPiSessionDir, + resolvePiSessionFile, +} from "../chat/piSessionStore"; import { preferNativeExecutablePath, resolveCliSpawnInvocation, @@ -728,6 +738,8 @@ type PtyEntry = { aiTitleTimer: ReturnType | null; startupTimer: ReturnType | null; initialInputTimer: ReturnType | null; + /** Cancels a readiness wait that is not a plain timer (Pi's quiescence wait). */ + initialInputCancel: (() => void) | null; cliUserTitleLineBuffer: string; cliUserTitleCommitted: boolean; /** @@ -1130,6 +1142,20 @@ function isOpenCodeCommandName(command: string): boolean { return basename === "opencode" || basename === "opencode.exe" || basename === "opencode.cmd" || basename === "opencode.bat"; } +export /** + * Pi's continuation flags. `-r` is Pi's interactive session *picker* and `-c` + * is "continue the most recent session" — neither is a resume ADE can target, + * so both are stripped when ADE rebuilds a launch. + */ +const PI_CONTINUATION_FLAG_RE = /(?:^|\s)(?:--continue|-c|-r)(?:\s|$)/iu; + +/** Drop `--session ` and any continuation flag from a Pi argv. */ +function stripPiContinuationArgs(args: string[]): string[] { + return args.filter((arg, index, all) => arg !== "--session" + && all[index - 1] !== "--session" + && !/^(?:--continue|-c|-r)$/iu.test(arg)); +} + export function isPiExecutableCommand(command: string): boolean { const basename = command.trim().replace(/^['"]|['"]$/g, "").split(/[\\/]/).pop()?.toLowerCase() ?? ""; return basename === "pi" || basename === "pi.exe" || basename === "pi.cmd" || basename === "pi.bat"; @@ -1884,6 +1910,8 @@ function resumeTargetIdForProvider( export type PiStorageSessionCandidate = { id: string; + /** Native header cwd. A candidate without one can never be owned. */ + cwd?: string | null; sourcePath?: string | null; createdAt?: number | null; updatedAt?: number | null; @@ -1900,10 +1928,20 @@ export type PiStorageSessionCandidate = { */ export function selectPiStorageSessionCandidate(args: { candidates: readonly PiStorageSessionCandidate[]; + /** + * Working directory that must own the session, compared exactly. Discovery + * scopes by containment so a project's whole tree is browsable, but a lane + * worktree sits inside the primary lane's root — selecting by containment + * would hand one directory's launch a session belonging to another, which + * every downstream consumer then rejects. + */ + cwd: string; startedAt?: string | null; maxStartDeltaMs?: number; excludedIds?: ReadonlySet; excludedFiles?: ReadonlySet; + /** Called only for candidates that already passed every in-memory filter. */ + isOwnedByAnotherWriter?: (filePath: string) => boolean; }): { id: string; filePath: string | null } | null { const requestedStartedAtMs = Date.parse(args.startedAt ?? ""); const hasStartedAt = Number.isFinite(requestedStartedAtMs); @@ -1912,6 +1950,7 @@ export function selectPiStorageSessionCandidate(args: { for (const candidate of args.candidates) { const id = sanitizeResumeTargetId(candidate.id); if (!id || args.excludedIds?.has(id)) continue; + if (!piSessionHeaderMatchesCwd({ cwd: candidate.cwd ?? "" }, args.cwd)) continue; const sourcePath = typeof candidate.sourcePath === "string" && candidate.sourcePath.trim() ? candidate.sourcePath.trim() : null; @@ -1925,8 +1964,16 @@ export function selectPiStorageSessionCandidate(args: { } if (excluded) continue; } - const timestamp = candidate.createdAt ?? candidate.updatedAt ?? 0; + // Matching against a launch time compares CREATION times only. A session + // created days ago whose file was appended to a moment ago has a current + // mtime, and scoring on that let a fresh terminal adopt an old transcript + // — including one an ADE chat had just written to, now that chat and CLI + // share a single native store. + const timestamp = hasStartedAt + ? candidate.createdAt ?? 0 + : candidate.createdAt ?? candidate.updatedAt ?? 0; if (!Number.isFinite(timestamp) || timestamp <= 0) continue; + if (sourcePath && args.isOwnedByAnotherWriter?.(sourcePath)) continue; const score = hasStartedAt ? Math.abs(timestamp - requestedStartedAtMs) : 0; if (hasStartedAt && typeof args.maxStartDeltaMs === "number" && score > args.maxStartDeltaMs) continue; if ( @@ -3302,6 +3349,8 @@ export function createPtyService({ maxStartDeltaMs?: number; excludedIds?: ReadonlySet; excludedFiles?: ReadonlySet; + /** Tracked terminal this lookup is for; sessions owned elsewhere are skipped. */ + terminalSessionId?: string | null; }): Promise<{ id: string; filePath: string | null } | null> => { try { const sessions = await discoverPiSessions({ @@ -3312,6 +3361,14 @@ export function createPtyService({ }); return selectPiStorageSessionCandidate({ candidates: sessions, + cwd: args.cwd, + // A session another live ADE writer already leased is never this + // launch's session. Skipping it here beats discovering it by failing + // to acquire the lease after the launch has committed — that path + // disposes the terminal. Passed as a predicate so the selector only + // reads a sidecar for candidates that survived its cheaper filters. + isOwnedByAnotherWriter: (filePath) => piSessionLeaseIsHeld(filePath, piLeaseIdentity.isProcessIdentityLive) + || (Boolean(args.terminalSessionId) && !piSessionIsAdoptableByTerminal(filePath, args.terminalSessionId!)), ...(args.startedAt !== undefined ? { startedAt: args.startedAt } : {}), ...(args.maxStartDeltaMs !== undefined ? { maxStartDeltaMs: args.maxStartDeltaMs } : {}), ...(args.excludedIds ? { excludedIds: args.excludedIds } : {}), @@ -3469,6 +3526,7 @@ export function createPtyService({ env: effectiveSessionEnv ?? activePiEnvironment, startedAt: session.startedAt, maxStartDeltaMs: 10 * 60_000, + terminalSessionId: sessionId, }); if (piSession) { const resumeCmd = commandArrayToLine(["pi", "--session", piSession.id], { platform: "linux" }); @@ -3840,6 +3898,8 @@ export function createPtyService({ } if (entry.initialInputTimer) { clearTimeout(entry.initialInputTimer); + entry.initialInputCancel?.(); + entry.initialInputCancel = null; entry.initialInputTimer = null; } cleanupEntryPaths(entry); @@ -5485,9 +5545,9 @@ export function createPtyService({ directArgs.join(" "), ].filter((value): value is string => Boolean(value)); const isForkLaunch = piOwnershipCommands.some((command) => /(?:^|\s)--fork(?:=|\s|$)/iu.test(command)); - const isContinueLaunch = /(?:^|\s)(?:--continue|-c|-r)(?:\s|$)/iu.test(initialResumeCommand ?? "") - || /(?:^|\s)(?:--continue|-c|-r)(?:\s|$)/iu.test(startupCommand); - const piSessionDir = piSessionDirectoryForEnvironment(launchEnv); + const isContinueLaunch = PI_CONTINUATION_FLAG_RE.test(initialResumeCommand ?? "") + || PI_CONTINUATION_FLAG_RE.test(startupCommand); + const piSessionDir = piSessionRootForEnvironment(launchEnv); piSessionDirForEntry = piSessionDir; piLaunchEnvForEntry = launchEnv; // The synthetic creation lease is the first file in this directory; @@ -5501,43 +5561,99 @@ export function createPtyService({ // if its timestamp is closest to ADE's launch time. const preexistingPiSessions = listPiSessionFilesForCwd({ cwd, - sessionDir: piSessionDir, + sessionRoot: piSessionDir, env: launchEnv, }); piPreexistingSessionFiles = new Set(preexistingPiSessions.map((session) => session.filePath)); piPreexistingSessionIds = new Set(preexistingPiSessions.map((session) => session.id)); - if (piResumeTargetId && !isForkLaunch) { - const piSessionFile = resolvePiSessionFile({ - cwd, - sessionId: piResumeTargetId, - sessionDir: piSessionDir, - env: launchEnv, + const piResumeFile = piResumeTargetId && !isForkLaunch + ? resolvePiSessionFile({ + cwd, + sessionId: piResumeTargetId, + sessionRoot: piSessionDir, + env: launchEnv, + }) + : null; + // A stored pointer at a session older than the terminal itself is + // one ADE mis-assigned earlier — it cannot be this terminal's, since + // a terminal creates its own session. Drop it and start fresh rather + // than reopening a stranger's transcript on every relaunch. + // Only ADE-inferred pointers are second-guessed. An imported session + // is old by definition — the user picked it — so applying the + // "older than the terminal" heuristic there would silently swap + // their transcript for a blank one. + const piResumeTargetWasChosenByUser = Boolean(initialResumeMetadata?.importedFrom); + const piResumeTargetIsPlausible = piResumeFile !== null + && (piResumeTargetWasChosenByUser || piSessionCouldBelongToTerminal({ + sessionFile: piResumeFile, + terminalStartedAt: sessionService.get(sessionId)?.startedAt ?? null, + // Matches the adoption window ADE itself uses when assigning a + // session, so a target ADE just recorded is never rejected here. + graceMs: 10 * 60_000, + })); + if (piResumeFile && !piResumeTargetIsPlausible) { + logger.warn("pty.pi_resume_target_discarded", { + sessionId, + ptyId, + piSessionId: piResumeTargetId ?? "", + sessionFile: piResumeFile, }); - if (!piSessionFile) { - throw new Error(`Pi session '${piResumeTargetId}' was not found in the selected working directory.`); + initialResumeCommand = null; + // Rebuilt with no target rather than reduced to a bare `pi`. This + // string is what gets typed when the direct spawn falls back to a + // shell, so hardcoding the binary dropped the model, thinking and + // tool flags that the argv path keeps — the two renderings are + // meant to be the same launch. + startupCommand = withBundledOpenCodeCommandLine( + buildTrackedCliResumeLaunchCommand( + { + provider: "pi", + targetKind: "session", + targetId: null, + launch: initialResumeMetadata?.launch ?? {}, + }, + {}, + { platform: "linux" }, + ).startupCommand, + toolTypeHint, + ); + if (directCommand && isPiExecutableCommand(directCommand)) { + directArgs = stripPiContinuationArgs(directArgs); } + // The metadata has to go too: setResumeCommand(null) re-derives the + // command from the stored resumeMetadata, which still holds the + // bad target — so clearing only the command is a no-op. + sessionService.updateMeta({ sessionId, resumeCommand: null, resumeMetadata: null }); + initialResumeMetadata = null; + } + if (piResumeFile && piResumeTargetIsPlausible) { piSessionLease = acquirePiSessionLease({ - sessionFile: piSessionFile, + sessionFile: piResumeFile, owner: "cli", ownerId: ptyId, ...piLeaseIdentity, }); + recordPiSessionOwner({ sessionFile: piResumeFile, owner: "cli", ownerSessionId: sessionId }); + } else if (piResumeTargetId && !isForkLaunch && !piResumeFile) { + throw new Error(`Pi session '${piResumeTargetId}' was not found in the selected working directory.`); } else if (isContinueLaunch && !isForkLaunch) { // Resolve --continue/-c/-r before spawning whenever Pi already has // a concrete latest session. This removes the window where Pi can // begin writing that JSONL before ADE has upgraded its synthetic // directory lease to the adjacent session lease. - const latest = await resolvePiSessionIdFromStorage({ cwd, env: launchEnv }); + const latest = await resolvePiSessionIdFromStorage({ cwd, env: launchEnv, terminalSessionId: sessionId }); if (latest) { const piSessionFile = resolvePiSessionFile({ cwd, sessionId: latest.id, sessionFile: latest.filePath, - sessionDir: piSessionDir, + sessionRoot: piSessionDir, env: launchEnv, }); if (!piSessionFile) { - throw new Error("Pi's latest session is outside the authorized native session directory."); + throw new Error(repositoryOverridesPiSessionDir(cwd) + ? `This repository's .pi/settings.json redirects Pi's session directory. ADE does not follow a checkout's setting, so it cannot track this session. Remove it, or set PI_CODING_AGENT_SESSION_DIR for your profile instead.` + : `Pi's most recent session for this directory (${latest.id}) could not be verified in ${piSessionDir}.`); } piSessionLease = acquirePiSessionLease({ sessionFile: piSessionFile, @@ -5545,6 +5661,7 @@ export function createPtyService({ ownerId: ptyId, ...piLeaseIdentity, }); + recordPiSessionOwner({ sessionFile: piSessionFile, owner: "cli", ownerSessionId: sessionId }); const piResumeCommand = `pi --session ${latest.id}`; initialResumeCommand = piResumeCommand; startupCommand = withBundledOpenCodeCommandLine(piResumeCommand, toolTypeHint); @@ -5556,7 +5673,7 @@ export function createPtyService({ } else { piSessionLeaseIsCreation = true; piSessionLease = acquirePiSessionLease({ - sessionFile: piSessionCreationLeaseTarget(piSessionDir), + sessionFile: piSessionCreationLeaseTarget(piSessionDir, cwd), owner: "cli", ownerId: ptyId, ...piLeaseIdentity, @@ -5567,7 +5684,7 @@ export function createPtyService({ // JSONL path until Pi writes it. Hold a directory creation lease // for the entire PTY lifetime so two ADE launches cannot both // target the implicit "most recent" session at once. - const creationTarget = piSessionCreationLeaseTarget(piSessionDir); + const creationTarget = piSessionCreationLeaseTarget(piSessionDir, cwd); piSessionLease = acquirePiSessionLease({ sessionFile: creationTarget, owner: "cli", @@ -5769,6 +5886,7 @@ export function createPtyService({ aiTitleTimer: null, startupTimer: null, initialInputTimer: null, + initialInputCancel: null, cliUserTitleLineBuffer: "", cliUserTitleCommitted: false, priorEndState, @@ -5827,6 +5945,7 @@ export function createPtyService({ const candidate = await resolvePiSessionIdFromStorage({ cwd: entry.boundCwd, env: entry.piLaunchEnv, + terminalSessionId: entry.sessionId, startedAt: sessionService.get(entry.sessionId)?.startedAt ?? null, maxStartDeltaMs: 10 * 60_000, excludedIds: new Set([ @@ -5841,7 +5960,7 @@ export function createPtyService({ cwd: entry.boundCwd, sessionId: candidate.id, sessionFile: candidate.filePath, - sessionDir: entry.piSessionDir, + sessionRoot: entry.piSessionDir, env: entry.piLaunchEnv, }); if (!sessionFile) throw new Error("Pi discovered a session outside the authorized native session directory."); @@ -5856,6 +5975,10 @@ export function createPtyService({ concreteLease.release(); return; } + // Recorded only once this terminal is the confirmed owner. The + // sidecar is never deleted, so claiming on the losing branch would + // permanently block every other terminal from the session. + recordPiSessionOwner({ sessionFile, owner: "cli", ownerSessionId: entry.sessionId }); entry.piSessionLease = concreteLease; entry.piSessionLeaseIsCreation = false; creationLease.release(); @@ -6163,6 +6286,43 @@ export function createPtyService({ }); }; const initialInputDelayMs = Math.max(0, Math.min(10_000, Math.floor(Number(effectiveArgs.initialInputDelayMs ?? 0) || 0))); + /** + * Wait for the TUI to stop drawing before typing into it. + * + * A fixed delay is a guess about how long a program takes to become + * interactive, and Pi loses that race: it prints a banner, the skill + * list, and a skill-conflict report before its prompt accepts input, + * which on a large profile runs well past any delay worth hard-coding. + * The first message was typed into a screen that was still painting and + * vanished. Quiescence is the observable signal that it is ready. + */ + const waitForPtyQuiet = async (quietMs: number, maxWaitMs: number): Promise<"quiet" | "timeout"> => { + if (entry.disposed) return "timeout"; + return await new Promise((resolve) => { + let quietTimer: ReturnType | null = null; + const settle = (outcome: "quiet" | "timeout") => { + entry.initialInputCancel = null; + if (quietTimer) clearTimeout(quietTimer); + clearTimeout(capTimer); + unsubscribe(); + resolve(outcome); + }; + const armQuietTimer = () => { + if (quietTimer) clearTimeout(quietTimer); + quietTimer = setTimeout(() => settle("quiet"), quietMs); + quietTimer.unref?.(); + }; + const unsubscribe = service.onData((event) => { + if (event.ptyId === ptyId) armQuietTimer(); + }); + // Parked on the entry so disposal cancels the wait, matching what + // every sibling initial-input path does with initialInputTimer. + entry.initialInputCancel = () => settle("timeout"); + const capTimer = setTimeout(() => settle("timeout"), maxWaitMs); + capTimer.unref?.(); + armQuietTimer(); + }); + }; if (effectiveArgs.awaitInitialInput) { try { if (initialInputDelayMs > 0) await delay(initialInputDelayMs); @@ -6179,6 +6339,13 @@ export function createPtyService({ closeEntry(ptyId, 1); throw err; } + } else if (toolTypeHint === "pi" && initialInputDelayMs > 0) { + void (async () => { + const readiness = await waitForPtyQuiet(initialInputDelayMs, 15_000); + if (entry.disposed) return; + logger.info("pty.pi_initial_input_ready", { ptyId, sessionId, readiness }); + await writeInitialInput().catch(failInitialInputLaunch); + })(); } else if (initialInputDelayMs > 0) { entry.initialInputTimer = setTimeout(() => { void writeInitialInput().catch(failInitialInputLaunch); @@ -7425,6 +7592,8 @@ export function createPtyService({ } if (entry.initialInputTimer) { clearTimeout(entry.initialInputTimer); + entry.initialInputCancel?.(); + entry.initialInputCancel = null; entry.initialInputTimer = null; } flushQueuedPtyData(entry, { ptyId, sessionId: entry.sessionId }); diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts index d8e9e8fa1..4f2850330 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts @@ -83,6 +83,11 @@ describe("terminalSessionSignals", () => { expect(defaultResumeCommandForTool("droid")).toBe("droid --resume"); expect(defaultResumeCommandForTool("opencode")).toBe("opencode --continue"); expect(defaultResumeCommandForTool("opencode-orchestrated")).toBe("opencode --continue"); + // Pi has no safe default. `pi --continue` means "the most recent session + // for this directory", and chat and the tracked CLI share one native Pi + // store — a brand new terminal reopened a four-day-old transcript, and the + // user's typed message went into it. Pi resumes only by a captured id. + expect(defaultResumeCommandForTool("pi")).toBeNull(); expect(defaultResumeCommandForTool("shell")).toBeNull(); }); diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.ts b/apps/desktop/src/main/utils/terminalSessionSignals.ts index ee25b53d5..c21657c33 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.ts @@ -464,7 +464,12 @@ export function defaultResumeCommandForTool(toolType: TerminalToolType | null | if (toolType === "cursor-cli") return "cursor-agent --model auto --continue"; if (toolType === "droid") return "droid --resume"; if (toolType === "opencode" || toolType === "opencode-orchestrated") return "opencode --continue"; - if (toolType === "pi") return "pi --continue"; + // Deliberately null for Pi. `pi --continue` means "the most recent session + // for this directory", and since ADE chat and the tracked CLI share one + // native Pi store that can be another terminal's session or a chat's — a + // fresh terminal reopened a four-day-old transcript this way. Pi is resumed + // only by a session id ADE actually captured for that terminal. + if (toolType === "pi") return null; return null; } diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 224a1b3bd..e9cf3894a 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -275,6 +275,7 @@ import { createHandoffLaunchJobId, type HandoffLaunchJob, } from "../../lib/handoffLaunchJobs"; +import { summarizeNativeControls as summarizeNativeLaunchControls } from "../../lib/nativeLaunchControls"; import { createAppControlContextInstanceId, createBuiltInBrowserContextInstanceId, @@ -1629,79 +1630,31 @@ function cloneParallelSlotFromComposer(args: { function summarizeNativeControls( provider: AgentChatSessionSummary["provider"] | "claude" | "codex" | "opencode" | "cursor" | "droid", controls: NativeControlState, -): Pick< - AgentChatSessionSummary, - "interactionMode" | "claudePermissionMode" | "codexApprovalPolicy" | "codexSandbox" | "codexConfigSource" | "opencodePermissionMode" | "droidPermissionMode" | "permissionMode" | "cursorModeId" -> { - if (provider === "claude") { - let permissionMode: AgentChatSessionSummary["permissionMode"]; - if (controls.interactionMode === "plan") { - permissionMode = "plan"; - } else if (controls.claudePermissionMode === "bypassPermissions") { - permissionMode = "full-auto"; - } else if (controls.claudePermissionMode === "acceptEdits") { - permissionMode = "edit"; - } else { - permissionMode = controls.claudePermissionMode; - } - return { - interactionMode: controls.interactionMode, - claudePermissionMode: controls.claudePermissionMode, - permissionMode, - }; - } - if (provider === "codex") { - let permissionMode: AgentChatSessionSummary["permissionMode"]; - if (controls.codexConfigSource === "config-toml") { - permissionMode = "config-toml"; - } else if (controls.codexApprovalPolicy === "never" && controls.codexSandbox === "danger-full-access") { - permissionMode = "full-auto"; - } else if (controls.codexApprovalPolicy === "untrusted" && controls.codexSandbox === "workspace-write") { - permissionMode = "edit"; - } else if ( - (controls.codexApprovalPolicy === "on-request" || controls.codexApprovalPolicy === "on-failure") - && controls.codexSandbox === "workspace-write" - ) { - permissionMode = "default"; - } else if ( - (controls.codexApprovalPolicy === "on-request" || controls.codexApprovalPolicy === "untrusted") - && controls.codexSandbox === "read-only" - ) { - permissionMode = "plan"; - } - return { - codexApprovalPolicy: controls.codexApprovalPolicy, - codexSandbox: controls.codexSandbox, - codexConfigSource: controls.codexConfigSource, - ...(permissionMode ? { permissionMode } : {}), - }; - } - if (provider === "cursor") { - return { - ...(controls.cursorModeId != null ? { cursorModeId: controls.cursorModeId } : {}), - }; - } - if (provider === "droid") { - return { - droidPermissionMode: controls.droidPermissionMode, - permissionMode: droidPermissionModeToLegacyPermissionMode(controls.droidPermissionMode), - }; - } - return { - opencodePermissionMode: controls.opencodePermissionMode, - permissionMode: controls.opencodePermissionMode, - }; +): ReturnType { + const summary = summarizeNativeLaunchControls(provider, controls); + if (provider !== "cursor") return summary; + // The pane has never persisted a derived `permissionMode` for cursor — the + // composer's cursor chip is driven by `cursorModeId` alone, and these + // summaries are spread straight into a session patch. Collapsing the two + // copies of this derivation is not licence to start writing that field. + const { permissionMode: _cursorDerived, ...rest } = summary; + return controls.cursorModeId != null ? rest : {}; } -function droidPermissionModeToLegacyPermissionMode(mode: AgentChatDroidPermissionMode): AgentChatPermissionMode { - if (mode === "read-only") return "plan"; - // AGI orchestrator is read-only at the top level → closest legacy mode is plan. - if (mode === "agi") return "plan"; - if (mode === "auto-low") return "edit"; - if (mode === "auto-medium") return "default"; - return "full-auto"; +/** + * Pi stores its mode in `permissionMode`, but the composer drives it through + * the shared OpenCode picker, so the chip has to be seeded from the session's + * own field or it falls back to the picker's default and misreports the mode. + */ +function piPermissionModeToPickerValue( + mode: AgentChatPermissionMode | undefined, +): AgentChatOpenCodePermissionMode | undefined { + if (mode === "plan" || mode === "edit" || mode === "full-auto" || mode === "config-toml") return mode; + if (mode === "default" || mode === "auto") return "edit"; + return undefined; } + function legacyPermissionModeToDroidPermissionMode( mode: AgentChatPermissionMode | undefined, ): AgentChatDroidPermissionMode | undefined { @@ -1713,14 +1666,7 @@ function legacyPermissionModeToDroidPermissionMode( } function cliPermissionModeFromNativeControls(provider: CliProvider, controls: NativeControlState): AgentChatPermissionMode { - if (provider === "cursor") { - const modeId = controls.cursorModeId?.trim().toLowerCase() ?? ""; - if (modeId.includes("full") || modeId.includes("auto")) return "full-auto"; - if (modeId.includes("plan")) return "plan"; - if (modeId.includes("ask")) return "edit"; - return "default"; - } - return summarizeNativeControls(provider, controls).permissionMode ?? "default"; + return summarizeNativeLaunchControls(provider, controls).permissionMode ?? "default"; } function formatWorkCliAttachmentManifest(attachments: AgentChatFileRef[]): string { @@ -5400,7 +5346,11 @@ export function AgentChatPane({ setCodexApprovalPolicy(session.codexApprovalPolicy ?? initialNativeControls.codexApprovalPolicy); setCodexSandbox(session.codexSandbox ?? initialNativeControls.codexSandbox); setCodexConfigSource(session.codexConfigSource ?? initialNativeControls.codexConfigSource); - setOpenCodePermissionMode(session.opencodePermissionMode ?? initialNativeControls.opencodePermissionMode); + setOpenCodePermissionMode( + session.opencodePermissionMode + ?? (session.provider === "pi" ? piPermissionModeToPickerValue(session.permissionMode) : undefined) + ?? initialNativeControls.opencodePermissionMode, + ); setDroidPermissionMode( session.droidPermissionMode ?? legacyPermissionModeToDroidPermissionMode(session.permissionMode) diff --git a/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.test.ts b/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.test.ts new file mode 100644 index 000000000..cf91aa199 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { PROVIDER_CHAT_ACCENTS, chatSurfaceVars, providerChatAccent } from "./chatSurfaceTheme"; + +/** The value a surface actually paints, as the DOM would see it. */ +function vars(accent: string | null, tint: "colored" | "neutral" = "colored") { + return chatSurfaceVars("standard", accent, { chromeTint: tint }) as Record; +} + +describe("chat surface accents", () => { + it("gives every first-class runtime its own accent", () => { + const perRuntime = { + claude: "#D97706", + codex: "#E7E5E4", + opencode: "#739CEE", + cursor: "#13120C", + droid: "#D46C2E", + pi: "#181C25", + }; + for (const [provider, expected] of Object.entries(perRuntime)) { + expect(providerChatAccent(provider)).toBe(expected); + } + // Distinct values, or two runtimes are indistinguishable in the transcript. + expect(new Set(Object.values(perRuntime)).size).toBe(Object.keys(perRuntime).length); + }); + + it("keeps provider aliases on the same accent as their runtime", () => { + expect(providerChatAccent("anthropic")).toBe(providerChatAccent("claude")); + expect(providerChatAccent("openai")).toBe(providerChatAccent("codex")); + expect(providerChatAccent("factory")).toBe(providerChatAccent("droid")); + expect(providerChatAccent("PI")).toBe(PROVIDER_CHAT_ACCENTS.pi); + expect(providerChatAccent("not-a-runtime")).toBeNull(); + }); + + // The stops used to mix toward a fixed violet, so every provider's bubble + // came out purple regardless of its accent — the colour was set but never + // visible as itself. + it("shades a re-coloured provider's bubble from its own accent", () => { + for (const accent of ["#739CEE", "#D46C2E", "#13120C", "#181C25"]) { + const gradient = vars(accent)["--chat-user-bubble-gradient"] ?? ""; + expect(gradient).toContain("var(--chat-accent)"); + expect(gradient).not.toContain("#7c3aed"); + expect(gradient).not.toContain("#4c1d95"); + } + }); + + // Claude and Codex already looked right, and re-colouring the other four was + // not licence to restyle them. + it("leaves the Claude and Codex bubbles exactly as they shipped", () => { + const claude = vars("#D97706"); + expect(claude["--chat-user-bubble-gradient"]).toBe( + "linear-gradient(135deg, color-mix(in srgb, var(--chat-accent) 76%, #ffffff 6%) 0%, color-mix(in srgb, var(--chat-accent) 60%, #7c3aed 40%) 50%, color-mix(in srgb, var(--chat-accent) 58%, #4c1d95 42%) 100%)", + ); + expect(claude["--chat-user-border-accent-mix"]).toBe("28%"); + expect(claude["--chat-user-shadow-accent-mix"]).toBe("34%"); + + const codex = vars("#E7E5E4"); + expect(codex["--chat-user-bubble-gradient"]).toBe( + "linear-gradient(135deg, color-mix(in srgb, var(--chat-accent) 74%, #78716c 10%) 0%, color-mix(in srgb, var(--chat-accent) 58%, #7c3aed 42%) 50%, color-mix(in srgb, var(--chat-accent) 56%, #4c1d95 44%) 100%)", + ); + expect(codex["--chat-user-border-accent-mix"]).toBe("22%"); + expect(codex["--chat-user-shadow-accent-mix"]).toBe("28%"); + }); + + it("lifts a near-black accent instead of deepening it", () => { + const deep = vars("#181C25"); + const normal = vars("#D97706"); + expect(deep["--chat-user-bubble-gradient"]).not.toBe(normal["--chat-user-bubble-gradient"]); + // A wider border mix is what keeps the bubble's edge on a dark fill. + expect(deep["--chat-user-border-accent-mix"]).toBe("46%"); + expect(normal["--chat-user-border-accent-mix"]).toBe("28%"); + }); + + it("drops every provider colour when the user picks no tint", () => { + const neutral = vars("#D46C2E", "neutral"); + expect(neutral["--chat-accent"]).toBe("#52525b"); + expect(vars("#181C25", "neutral")["--chat-accent"]).toBe(neutral["--chat-accent"]); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts b/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts index f0753f1ea..51e48df63 100644 --- a/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts +++ b/apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts @@ -18,10 +18,11 @@ export const PROVIDER_CHAT_ACCENTS: Record = { anthropic: "#D97706", codex: "#E7E5E4", openai: "#E7E5E4", - cursor: "#A78BFA", - droid: "#8B5CF6", - factory: "#8B5CF6", - opencode: "#2563EB", + cursor: "#13120C", + droid: "#D46C2E", + factory: "#D46C2E", + opencode: "#739CEE", + pi: "#181C25", google: "#F59E0B", gemini: "#F59E0B", mistral: "#F97316", @@ -109,12 +110,21 @@ function sharedSurfaceTokens(accent: string, m: number): CSSProperties { }; } -function isLightChatAccent(accent: string): boolean { +/** Dark enough that shading it further would erase the bubble's edges. */ +function isDeepChatAccent(accent: string): boolean { + return chatAccentLuminance(accent) < 0.22; +} + +function chatAccentLuminance(accent: string): number { const normalized = normalizeHex(accent); const r = hexChannel(normalized.slice(1, 3)); const g = hexChannel(normalized.slice(3, 5)); const b = hexChannel(normalized.slice(5, 7)); - return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.72; + return (0.299 * r + 0.587 * g + 0.114 * b) / 255; +} + +function isLightChatAccent(accent: string): boolean { + return chatAccentLuminance(accent) > 0.72; } /// Readable foreground for content painted on top of a `--chat-accent` fill @@ -132,6 +142,10 @@ export function effectiveChatAccent(accentColor: string, chromeTint: ChatChromeT return chromeTint === "neutral" ? NEUTRAL_CHROME_ACCENT : accentColor; } +/** + * The original bubble fills. Claude and Codex shipped looking right, so they + * keep these byte for byte — see `ACCENTS_KEEPING_ORIGINAL_BUBBLE`. + */ const CHAT_USER_BUBBLE_GRADIENT_DEFAULT = "linear-gradient(135deg, color-mix(in srgb, var(--chat-accent) 76%, #ffffff 6%) 0%, color-mix(in srgb, var(--chat-accent) 60%, #7c3aed 40%) 50%, color-mix(in srgb, var(--chat-accent) 58%, #4c1d95 42%) 100%)"; @@ -139,17 +153,51 @@ const CHAT_USER_BUBBLE_GRADIENT_DEFAULT = const CHAT_USER_BUBBLE_GRADIENT_LIGHT = "linear-gradient(135deg, color-mix(in srgb, var(--chat-accent) 74%, #78716c 10%) 0%, color-mix(in srgb, var(--chat-accent) 58%, #7c3aed 42%) 50%, color-mix(in srgb, var(--chat-accent) 56%, #4c1d95 44%) 100%)"; +/** + * Accents that keep the original gradient exactly. + * + * Those stops mix toward a fixed violet, which is why a newly-coloured + * provider could not read as its own colour. But Claude and Codex were already + * correct, and changing how they look was not part of re-colouring the others. + */ +const ACCENTS_KEEPING_ORIGINAL_BUBBLE = new Set( + [PROVIDER_CHAT_ACCENTS.claude, PROVIDER_CHAT_ACCENTS.codex].map((hex) => hex!.toLowerCase()), +); + +/** + * Every other provider shades from its own accent. + * + * Mixing toward a fixed violet meant two runtimes with different accents came + * out the same purple; deriving the stops from `--chat-accent` is what makes a + * per-provider colour visible as that colour. + */ +const CHAT_USER_BUBBLE_GRADIENT_ACCENT = + "linear-gradient(135deg, color-mix(in srgb, var(--chat-accent) 88%, #ffffff 12%) 0%, var(--chat-accent) 52%, color-mix(in srgb, var(--chat-accent) 84%, #000000 16%) 100%)"; + +/** + * Near-black accents (Cursor, Pi) — lift instead of deepen, or the bubble + * disappears into the transcript background and stops reading as a bubble. + */ +const CHAT_USER_BUBBLE_GRADIENT_DEEP = + "linear-gradient(135deg, color-mix(in srgb, var(--chat-accent) 74%, #ffffff 26%) 0%, color-mix(in srgb, var(--chat-accent) 86%, #ffffff 14%) 52%, var(--chat-accent) 100%)"; + /** Provider-colored chrome (default) — former “standard” lane accent strength. */ function coloredChatSurfaceVars(mode: ChatSurfaceMode, accentColor?: string | null): CSSProperties { const accent = resolveChatSurfaceAccent(mode, accentColor); const m = 1; const light = isLightChatAccent(accent); + const deep = isDeepChatAccent(accent); + const keepsOriginal = ACCENTS_KEEPING_ORIGINAL_BUBBLE.has(accent.toLowerCase()); + // Only the fill differs for a pinned accent: neither Claude nor Codex is + // ever `deep`, so the mix ternaries were identical on both arms. return { - ["--chat-user-border-accent-mix" as string]: light ? "22%" : "28%", - ["--chat-user-shadow-accent-mix" as string]: light ? "28%" : "34%", - ["--chat-user-bubble-gradient" as string]: light - ? CHAT_USER_BUBBLE_GRADIENT_LIGHT - : CHAT_USER_BUBBLE_GRADIENT_DEFAULT, + ["--chat-user-border-accent-mix" as string]: light ? "22%" : deep ? "46%" : "28%", + ["--chat-user-shadow-accent-mix" as string]: light ? "28%" : deep ? "44%" : "34%", + ["--chat-user-bubble-gradient" as string]: keepsOriginal + ? (light ? CHAT_USER_BUBBLE_GRADIENT_LIGHT : CHAT_USER_BUBBLE_GRADIENT_DEFAULT) + : deep + ? CHAT_USER_BUBBLE_GRADIENT_DEEP + : CHAT_USER_BUBBLE_GRADIENT_ACCENT, ...sharedSurfaceTokens(accent, m), }; } diff --git a/apps/desktop/src/renderer/components/lanes/laneDesignTokens.ts b/apps/desktop/src/renderer/components/lanes/laneDesignTokens.ts index 98d32810f..f64c4604b 100644 --- a/apps/desktop/src/renderer/components/lanes/laneDesignTokens.ts +++ b/apps/desktop/src/renderer/components/lanes/laneDesignTokens.ts @@ -66,6 +66,16 @@ export const LABEL_STYLE: CSSProperties = { color: COLORS.textMuted, }; +/** Uppercase mono heading used for the section bands inside settings panels. */ +export const SECTION_LABEL_STYLE: CSSProperties = { + fontSize: 10, + fontFamily: MONO_FONT, + textTransform: "uppercase", + letterSpacing: "1px", + color: COLORS.textMuted, + fontWeight: 700, +}; + export function inlineBadge(color: string, overrides?: CSSProperties): CSSProperties { return { display: "inline-flex", diff --git a/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.test.tsx b/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.test.tsx index 8f6539ae8..eb685e5f5 100644 --- a/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.test.tsx +++ b/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.test.tsx @@ -74,7 +74,7 @@ describe("ChatAppearancePreview", () => { expect(first.style.getPropertyValue("--chat-bubble-assistant-py").trim()).toBe("22px"); }); - it("renders five chat surface shells with colored chrome and standard border mix", () => { + it("renders six chat surface shells with colored chrome and standard border mix", () => { const { container } = render( { shellGeometry="sharp" />, ); - // One column per runtime: codex, claude, opencode, cursor, droid. + // One column per runtime: codex, claude, opencode, cursor, droid, pi. const shells = container.querySelectorAll("section[data-chat-shell-layout]"); - expect(shells.length).toBe(5); + expect(shells.length).toBe(6); const sharp = container.querySelectorAll("section[data-chat-shell-geometry='sharp']"); - expect(sharp.length).toBe(5); + expect(sharp.length).toBe(6); for (const el of shells) { const style = (el as HTMLElement).style; const mix = style.getPropertyValue("--chat-user-border-accent-mix").trim(); - expect(["22%", "28%"]).toContain(mix); + // Near-black accents (Cursor, Pi) widen the border mix so the bubble + // keeps an edge instead of merging into the transcript. + expect(["22%", "28%", "46%"]).toContain(mix); } }); diff --git a/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.tsx b/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.tsx index 56687416e..dddc644d2 100644 --- a/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.tsx +++ b/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.tsx @@ -2,7 +2,7 @@ import React from "react"; import { ChatMarkdown } from "../chat/chatMarkdown"; import { buildChatAppearanceRootStyle } from "../chat/chatAppearance"; import { CHAT_SHELL_HEADER_CLASS, ChatSurfaceShell } from "../chat/ChatSurfaceShell"; -import { providerChatAccent } from "../chat/chatSurfaceTheme"; +import { PROVIDER_CHAT_ACCENTS, providerChatAccent } from "../chat/chatSurfaceTheme"; import { ChatWorkLogBlock } from "../chat/ChatWorkLogBlock"; import type { ChatWorkLogEntry } from "../chat/chatTranscriptRows"; import { @@ -10,7 +10,7 @@ import { CHAT_USER_MESSAGE_CARD_STYLE, } from "../chat/chatTranscriptChrome"; import { ClaudeLogo, CodexLogo, CursorAgentLogo, OpenCodeLogo } from "../terminals/ToolLogos"; -import { DroidLogo } from "../shared/ProviderLogos"; +import { DroidLogo, PiLogo } from "../shared/ProviderLogos"; import { cn } from "../ui/cn"; import type { ChatChromeTint, ChatShellGeometry, ChatTranscriptDensity, ThemeId } from "../../state/appStore"; @@ -94,6 +94,7 @@ const PREVIEW_PROVIDER_META = { opencode: { name: "OpenCode", Logo: OpenCodeLogo }, cursor: { name: "Cursor", Logo: CursorAgentLogo }, droid: { name: "Droid", Logo: DroidLogo }, + pi: { name: "Pi", Logo: PiLogo }, } as const; type PreviewProviderKey = keyof typeof PREVIEW_PROVIDER_META; @@ -108,6 +109,7 @@ const PREVIEW_USAGE_MODEL: Record< opencode: { Logo: OpenCodeLogo, label: "local · runtime" }, cursor: { Logo: CursorAgentLogo, label: "cursor · auto" }, droid: { Logo: DroidLogo, label: "droid · factory" }, + pi: { Logo: PiLogo, label: "pi · xai / grok" }, }; /** Mirrors `event.type === "done"` usage footer in `AgentChatMessageList` (completed turn). */ @@ -281,13 +283,9 @@ function SharedAppearanceTranscript({ ); } -const PREVIEW_COLUMNS: ReadonlyArray<{ provider: PreviewProviderKey; fallbackAccent: string }> = [ - { provider: "codex", fallbackAccent: "#E7E5E4" }, - { provider: "claude", fallbackAccent: "#D97706" }, - { provider: "opencode", fallbackAccent: "#2563EB" }, - { provider: "cursor", fallbackAccent: "#A78BFA" }, - { provider: "droid", fallbackAccent: "#8B5CF6" }, -]; +const PREVIEW_COLUMNS: ReadonlyArray<{ provider: PreviewProviderKey; fallbackAccent: string }> = ( + ["codex", "claude", "opencode", "cursor", "droid", "pi"] as const +).map((provider) => ({ provider, fallbackAccent: PROVIDER_CHAT_ACCENTS[provider]! })); export function ChatAppearancePreview({ theme, @@ -305,8 +303,8 @@ export function ChatAppearancePreview({ return (
{PREVIEW_COLUMNS.map(({ provider, fallbackAccent }) => ( diff --git a/apps/desktop/src/renderer/components/settings/OpenCodeProviderDetailModal.tsx b/apps/desktop/src/renderer/components/settings/OpenCodeProviderDetailModal.tsx index 2caa88029..62d18fa0e 100644 --- a/apps/desktop/src/renderer/components/settings/OpenCodeProviderDetailModal.tsx +++ b/apps/desktop/src/renderer/components/settings/OpenCodeProviderDetailModal.tsx @@ -1,11 +1,10 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { CheckCircle, X, XCircle } from "@phosphor-icons/react"; +import React, { useMemo, useState } from "react"; +import { CheckCircle, XCircle } from "@phosphor-icons/react"; import type { AiApiKeyVerificationResult } from "../../../shared/types"; import type { OpenCodeProviderAuthMethod } from "../../../shared/types/config"; -import { ProviderLogo } from "../shared/ProviderLogos"; -import { COLORS, MONO_FONT, SANS_FONT, outlineButton, primaryButton } from "../lanes/laneDesignTokens"; +import { COLORS, MONO_FONT, outlineButton, primaryButton } from "../lanes/laneDesignTokens"; import { OAuthConnectModal } from "./OAuthConnectModal"; +import { ProviderDetailDialog } from "./providerSectionPrimitives"; export type OpenCodeProviderDetail = { id: string; @@ -73,7 +72,6 @@ export function OpenCodeProviderDetailModal({ const [keyValue, setKeyValue] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const dialogRef = useRef(null); const statusLabel = provider.connected ? "Connected" @@ -83,25 +81,8 @@ export function OpenCodeProviderDetailModal({ ? "Sign-in available" : "Not connected"; - useEffect(() => { - const onKey = (event: KeyboardEvent) => { - if (event.key === "Escape" && !oauthOpen) onClose(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [onClose, oauthOpen]); - - // Move focus into the dialog so keyboard users aren't typing under the overlay. - useEffect(() => { - if (oauthOpen) return; - const node = dialogRef.current; - if (!node) return; - const previous = document.activeElement as HTMLElement | null; - node.focus(); - return () => { - previous?.focus?.(); - }; - }, [oauthOpen]); + // Escape, focus-in, and focus-restore are owned by ProviderDetailDialog, + // which stands down while the nested OAuth modal is open. const save = async () => { const trimmed = keyValue.trim(); @@ -133,68 +114,13 @@ export function OpenCodeProviderDetailModal({ return ( <> - {createPortal( -
-
event.stopPropagation()} - > -
-
- -
-
- {provider.name} -
-
- {statusLabel} - {typeof provider.modelCount === "number" ? ` · ${provider.modelCount} models` : ""} -
-
-
- -
- +
{error ? (
) : null}
-
-
, - document.body, - )} + {oauthOpen ? ( void; + onClose: () => void; +}) { + const [expanded, setExpanded] = useState(false); + const connected = piProviderIsConnected(provider); + const login = provider.login; + const status = provider.status; + const shown = expanded ? modelIds : modelIds.slice(0, MODEL_PREVIEW_LIMIT); + const statusLabel = connected + ? status + ? piProviderAuthSummary(status) + : "Connected" + : login?.authTypes.length + ? "Sign-in available" + : "Not connected"; + + return ( + +
+
+
Sign in
+ {login?.authTypes.length ? ( +
+ {login.authTypes.map((method) => ( + + ))} +
+ ) : ( +
+ {status?.authType === "local" + ? "This provider is a server you run, so there is nothing to sign in to." + : connected + ? "Pi resolves this provider's credential itself \u2014 from your environment or its own config \u2014 so there is nothing to sign in to here." + : "Pi does not offer an interactive sign-in for this provider."} +
+ )} + {connected && login?.authTypes.length ? ( +
+ Already connected. Signing in again replaces the stored credential. +
+ ) : null} +
+ +
+
+ Models{modelIds.length ? ` \u00b7 ${modelIds.length}` : ""} +
+ {shown.length ? ( +
+ {shown.map((modelId) => ( + + {modelId} + + ))} + {modelIds.length > shown.length ? ( + + ) : null} +
+ ) : ( +
+ {connected + ? "Pi reports no models for this provider yet. Refresh providers after signing in." + : "Models appear once this provider is connected."} +
+ )} +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/settings/PiProvidersPanel.tsx b/apps/desktop/src/renderer/components/settings/PiProvidersPanel.tsx new file mode 100644 index 000000000..9a27f1bc4 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/PiProvidersPanel.tsx @@ -0,0 +1,914 @@ +/** + * Settings → Providers, Pi half: the sign-in flow and the provider catalog. + * + * Its own module because Pi's catalog is the same size as OpenCode's and the + * two together pushed `ProvidersSection.tsx` well past the point where either + * could be read on its own. The catalog primitives both halves share live in + * `providerSectionPrimitives.tsx`. + */ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import type { + AiPiInstallationStatus, + AiPiProviderStatus, + AiProviderConnectionStatus, + AiRuntimeConnections, + AiRuntimeConnectionStatus, +} from "../../../shared/types"; +import type { PiAuthNotice, PiAuthPrompt, PiLoginMethod, PiLoginProvider } from "../../../shared/types/config"; +import { decodePiRegistryId } from "../../../shared/modelRegistry"; +import { ProviderLogo } from "../shared/ProviderLogos"; +import { + COLORS, + MONO_FONT, + SANS_FONT, + SECTION_LABEL_STYLE, + outlineButton, + primaryButton, +} from "../lanes/laneDesignTokens"; +import { openExternalUrl } from "../../lib/openExternal"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { ArrowsClockwise } from "@phosphor-icons/react"; +import { + ConnectedTag, + ProviderGrid, + ProviderSearchField, + ProviderTile, + ProviderTileBadge, + panel, +} from "./providerSectionPrimitives"; +import { PiProviderDetailModal } from "./PiProviderDetailModal"; +import { + piProviderIsConnected, + piProviderModelCount, + runtimeConnectionForPiProvider, + type PiProviderRow, +} from "./piProviderRow"; + +export function getPiTone( + connection: AiProviderConnectionStatus | null | undefined, + installation: AiPiInstallationStatus | null | undefined, +): { color: string; label: string } { + if (!installation?.installed && !connection?.runtimeDetected) { + return { color: COLORS.textDim, label: "Not detected" }; + } + if (installation?.installed && !installation.sdkAvailable) { + return { color: COLORS.warning, label: "SDK needed" }; + } + if (connection?.runtimeAvailable) { + return { color: COLORS.success, label: "Ready" }; + } + if (installation?.sdkAvailable && connection?.authAvailable) { + return { color: COLORS.warning, label: "Configured" }; + } + if (installation?.sdkAvailable || installation?.cliAvailable) { + return { color: COLORS.warning, label: "Sign-in required" }; + } + return { color: COLORS.danger, label: "Unavailable" }; +} + +export function buildPiMessage( + connection: AiProviderConnectionStatus | null | undefined, + installation: AiPiInstallationStatus | null | undefined, +): string { + if (!installation) { + return "Checking Pi installation and provider inventory."; + } + const configuredProviders = installation.providers.filter((provider) => provider.configured).length; + const availableModels = installation.availableModelIds.length; + if (connection?.runtimeAvailable) { + const version = installation.version ? `Pi ${installation.version}` : "Pi"; + return `${version} is installed. ${availableModels} model${availableModels === 1 ? " is" : "s are"} available across ${configuredProviders} configured provider${configuredProviders === 1 ? "" : "s"}.`; + } + if (connection?.blocker) { + return connection.blocker; + } + if (installation.installed && !installation.sdkAvailable) { + return installation.blocker + ?? "Pi CLI is available, but ADE's Pi SDK package is missing. Install @earendil-works/pi-coding-agent or set ADE_PI_PACKAGE_ROOT."; + } + if (!installation.installed) { + return "Pi is not installed for this user yet. Install @earendil-works/pi-coding-agent, then use Refresh. Pi keeps its own credentials and profile."; + } + return "Pi is installed, but no configured providers or available models were detected yet."; +} + +type PiSignInFlow = { + /** Identifies this attempt, so a superseded start cannot tear down its replacement. */ + attemptId: number; + providerId: string; + /** Kept so a failed flow can be retried with the button the user actually pressed. */ + method: PiLoginMethod | null; + prompt: PiAuthPrompt | null; + /** Sticky auth URL / device code the user still has to act on. */ + link: PiAuthNotice | null; + progress: string | null; +}; + +/** Cancelling is a choice, not a failure, so it gets its own state instead of an error. */ +type PiSignInOutcome = { + providerId: string; + method: PiLoginMethod | null; + state: "ok" | "cancelled" | "error"; + error?: string; +}; + +/** Pi only sends options for select prompts; every other kind takes free text. */ +function piChoiceOptions(prompt: PiAuthPrompt | null | undefined): NonNullable { + return prompt?.options ?? []; +} + +/** Merges both provider sources by id so a signable, configured provider is one row, not two. */ +function buildPiProviderRows(configured: AiPiProviderStatus[], signable: PiLoginProvider[]): PiProviderRow[] { + const rows = new Map(); + for (const status of configured) { + rows.set(status.id, { id: status.id, name: status.name, status, login: null }); + } + for (const login of signable) { + const existing = rows.get(login.id); + if (existing) existing.login = login; + else rows.set(login.id, { id: login.id, name: login.name, status: null, login }); + } + return [...rows.values()]; +} + +/** + * Compact provider tile, the same shape OpenCode uses. Everything a provider + * can do lives behind it: with roughly forty Pi providers, expanding them all + * inline buried the handful the user actually has. + */ +function PiProviderCard({ provider, onOpen }: { provider: PiProviderRow; onOpen: () => void }) { + const connected = piProviderIsConnected(provider); + const modelCount = piProviderModelCount(provider); + const noModels = connected && provider.status != null && provider.status.availableModelCount === 0; + const badge = connected + ? "Connected" + : provider.login?.authTypes.includes("oauth") + ? "OAuth" + : provider.login?.authTypes.includes("api_key") + ? "Key" + : "Details"; + return ( + : {badge}} + onOpen={onOpen} + footer={noModels ? ( +
No models
+ ) : typeof modelCount === "number" ? ( +
+ {modelCount} model{modelCount === 1 ? "" : "s"} +
+ ) : undefined} + /> + ); +} + +/** + * A model server the user runs. These have no credential to collect — offering + * an API key field for a localhost endpoint is the wrong question — so the card + * reports whether ADE can reach it and where. + */ +/** + * What ADE's probe actually found, in the same words the OpenCode section + * uses — a local server is reachable, reachable-but-idle, or not answering. + */ +function piLocalServerTone( + connection: AiRuntimeConnectionStatus | null, + provider: PiProviderRow, +): { color: string; label: string } { + // ADE only probes ollama and lmstudio. For any other loopback server, Pi's + // own profile is the best evidence there is — reporting "Not checked" + // forever would be worse than saying what the profile knows. + if (!connection) { + return piProviderIsConnected(provider) + ? { color: COLORS.textMuted, label: "Configured in Pi" } + : { color: COLORS.textDim, label: "Not configured" }; + } + switch (connection.health) { + case "ready": + return { color: COLORS.success, label: "Running" }; + case "reachable": + case "reachable_no_models": + return { color: COLORS.warning, label: "Load a model" }; + case "not_configured": + return { color: COLORS.textMuted, label: "Not configured" }; + default: + return { color: COLORS.textDim, label: "Not detected" }; + } +} + +function PiLocalServerCard({ + provider, + connection, +}: { + provider: PiProviderRow; + connection: AiRuntimeConnectionStatus | null; +}) { + const endpoint = connection?.endpoint ?? provider.status?.baseUrl ?? null; + // Reported from ADE's live probe, never from the presence of a config entry. + // A server listed in models.json that nothing is listening on is not + // "connected", and saying so was the whole complaint. + const tone = piLocalServerTone(connection, provider); + return ( +
+
+
+ + + {provider.name} + +
+ + {tone.label} + +
+ {endpoint ? ( + + {endpoint} + + ) : null} + {connection?.blocker ? ( +
{connection.blocker}
+ ) : null} +
+ ); +} + +/** + * Shown only when ADE cannot run Pi's SDK, which is the one case in-app + * sign-in is impossible. ADE used to open a terminal and type `/login` into + * Pi's TUI after a fixed delay; that raced Pi's startup and usually submitted + * empty lines, so the instruction is stated instead of automated. + */ +function PiTerminalFallback({ installation }: { installation: AiPiInstallationStatus }) { + return ( + + {installation.cliAvailable + ? "Signing in from ADE needs Pi's SDK. Until the blocker above is resolved, run pi in a terminal and use its /login command." + : "Install Pi to sign in."} + + ); +} + +/** + * Runs Pi's own sign-in inside ADE: pick a provider, then answer whatever Pi + * asks. Pi writes the credential itself — nothing typed here is kept by ADE. + */ +/** + * Pi's provider catalog: what is connected, everything else behind a search, + * and the local model servers that have no credential to collect. + * + * Deliberately reads no sign-in state — the panel replaces it wholesale while + * a flow is on screen — which is what makes it separable from the flow card. + */ +function PiProviderBrowser({ + providers, + providersError, + signableProviders, + allRows, + connectedRows, + popularRows, + searchedRows, + localRows, + runtimeConnections, + providerSearch, + onProviderSearchChange, + loadingProviders, + onRefreshDetection, + onOpenProvider, +}: { + providers: PiLoginProvider[] | null; + providersError: string | null; + signableProviders: PiLoginProvider[]; + allRows: PiProviderRow[]; + connectedRows: PiProviderRow[]; + popularRows: PiProviderRow[]; + searchedRows: PiProviderRow[]; + localRows: PiProviderRow[]; + runtimeConnections: AiRuntimeConnections; + providerSearch: string; + onProviderSearchChange: (value: string) => void; + loadingProviders: boolean; + /** Re-probes the local servers; the Pi login list alone says nothing here. */ + onRefreshDetection: () => void; + onOpenProvider: (providerId: string) => void; +}) { + return ( + <> + {/* Named groups: the OpenCode section below carries the same visible + labels, so assistive tech needs the harness in the name. */} +
+
Connected
+ {connectedRows.length ? ( + + {connectedRows.map((row) => ( + onOpenProvider(row.id)} /> + ))} + + ) : ( +
+ No providers connected yet. Pick one below to sign in or add a key. +
+ )} +
+ +
+
All providers · {allRows.length}
+
+ + {!providerSearch.trim() ? ( + <> +
Popular
+ + {popularRows.map((row) => ( + onOpenProvider(row.id)} /> + ))} + + + ) : searchedRows.length ? ( + + {searchedRows.map((row) => ( + onOpenProvider(row.id)} /> + ))} + + ) : ( +
+ No providers match your search. +
+ )} +
+
+ + {localRows.length ? ( +
+
+
Local Model Servers
+ +
+
+ {localRows.map((row) => ( + + ))} +
+
+ ) : null} + + {providers !== null && !signableProviders.length && !providersError ? ( +
+ {allRows.length + ? "These read their keys from the environment — there is nothing to sign in to here." + : "No providers are set up in Pi yet."} +
+ ) : null} + + ); +} + +/** + * The card a running sign-in owns: Pi's progress, the device code and auth + * URL, whatever prompt Pi is blocked on, and the settled outcome. + * + * The live region stays mounted across settles — `role` alone carries the + * urgency, and adding `aria-live` would demote `alert` to polite. + */ +function PiSignInFlowCard({ + flow, + outcome, + providerName, + promptValue, + onPromptValueChange, + promptFieldId, + promptLabelId, + promptInputRef, + firstChoiceRef, + retryButtonRef, + onAnswer, + onCancel, + onRetry, +}: { + flow: PiSignInFlow | null; + outcome: PiSignInOutcome | null; + providerName: (providerId: string) => string; + promptValue: string; + onPromptValueChange: (value: string) => void; + promptFieldId: string; + promptLabelId: string; + promptInputRef: React.MutableRefObject; + firstChoiceRef: React.MutableRefObject; + retryButtonRef: React.MutableRefObject; + onAnswer: (value: string) => void; + onCancel: () => void; + onRetry: (outcome: PiSignInOutcome) => void; +}) { + const { copy, copied } = useCopyToClipboard(); + const prompt = flow?.prompt ?? null; + const choiceOptions = piChoiceOptions(prompt); + const link = flow?.link ?? null; + const userCode = link?.userCode ?? null; + const verifyUrl = link?.url ?? link?.verificationUri ?? null; + + return ( + <> + {flow ? ( +
+
+ Signing in to {providerName(flow.providerId)} +
+
+ {flow.progress ?? "Waiting for Pi…"} +
+ + {userCode ? ( +
+ Code + + {userCode} + + +
+ ) : null} + + {verifyUrl ? ( +
+ + {verifyUrl} + + +
+ ) : null} + + {prompt ? ( +
+ {/* A label may only point at a form control, so the choice + branch names its button group instead of borrowing htmlFor. */} + + {choiceOptions.length ? ( +
+ {choiceOptions.map((option, index) => ( + + ))} +
+ ) : ( +
{ + event.preventDefault(); + onAnswer(promptValue); + }} + > + onPromptValueChange(event.target.value)} + style={{ flex: "1 1 220px", minWidth: 0, background: COLORS.recessedBg, border: `1px solid ${COLORS.border}`, padding: "6px 8px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} + /> + +
+ )} +
+ ) : null} + +
+ +
+
+ ) : null} + + {/* The live region stays mounted across settles: a status/alert node + inserted at the same instant its text appears is announced + unreliably. Empty, it leaves the flow so it adds no column gap. The + role alone carries urgency — an explicit aria-live would demote an + alert back to polite. */} +
+ {outcome ? ( + + {outcome.state === "ok" + ? `Signed in to ${providerName(outcome.providerId)}.` + : outcome.state === "cancelled" + ? "Sign-in cancelled." + : `${providerName(outcome.providerId)}: ${outcome.error ?? "Sign-in did not finish."}`} + + ) : null} + {outcome?.state === "error" ? ( + + ) : null} +
+ + ); +} + +export function PiProvidersPanel({ + installation, + runtimeConnections, + onSignedIn, + onRefreshStatus, +}: { + installation: AiPiInstallationStatus; + /** ADE's own local-server probe, shared with the OpenCode section. */ + runtimeConnections: AiRuntimeConnections; + onSignedIn: () => void; + /** Re-runs the AI status probe that feeds `installation` and `runtimeConnections`. */ + onRefreshStatus: () => void; +}) { + const [providers, setProviders] = useState(null); + const [providersError, setProvidersError] = useState(null); + const [loadingProviders, setLoadingProviders] = useState(false); + const [flow, setFlow] = useState(null); + const [promptValue, setPromptValue] = useState(""); + const [outcome, setOutcome] = useState(null); + const [providerSearch, setProviderSearch] = useState(""); + const [detailProviderId, setDetailProviderId] = useState(null); + const promptInputRef = useRef(null); + const firstChoiceRef = useRef(null); + const retryButtonRef = useRef(null); + /** Pi reports a cancel as a plain failure, so remember that the user asked for it. */ + const cancelledProviderRef = useRef(null); + /** Providers whose latest attempt has already been reported. See `settle`. */ + const settledProvidersRef = useRef>(new Set()); + const lastPromptRequestIdRef = useRef(null); + const piSignInAttemptCounter = useRef(0); + const promptFieldId = React.useId(); + const promptLabelId = `${promptFieldId}-label`; + + const loadProviders = useCallback(async () => { + setLoadingProviders(true); + try { + const listed = await window.ade.ai.piLoginProviders(); + setProviders(listed); + setProvidersError(null); + } catch (err) { + setProvidersError(err instanceof Error ? err.message : String(err)); + } finally { + setLoadingProviders(false); + } + }, []); + + useEffect(() => { + if (!installation.sdkAvailable) return; + void loadProviders(); + }, [installation.sdkAvailable, loadProviders]); + + // The status subscription is installed once, so the callbacks it needs are + // read through refs rather than baked into a stale closure. + const onSignedInRef = useRef(onSignedIn); + const loadProvidersRef = useRef(loadProviders); + const flowRef = useRef(null); + const settleRef = useRef<(providerId: string, ok: boolean, error: string | null) => void>(() => undefined); + useEffect(() => { + onSignedInRef.current = onSignedIn; + loadProvidersRef.current = loadProviders; + }, [onSignedIn, loadProviders]); + useEffect(() => { + flowRef.current = flow; + }, [flow]); + const openedAuthUrlRef = useRef(null); + + useEffect(() => { + const unsubscribe = window.ade.ai.onPiAuthStatus((event) => { + // A sign-in settles in the main process, so its outcome is reported by + // this event rather than only by whoever happens to still be awaiting + // the start call. Settings is destroyed by any navigation, and binding + // the result to that one promise is what left a completed browser + // sign-in showing nothing in ADE. + if (event.state === "success" || event.state === "error") { + settleRef.current(event.providerId, event.state === "success", event.error ?? null); + return; + } + setFlow((current) => { + // Leaving Settings no longer cancels a login, so a flow can outlive its + // card. Re-adopt it on the next event, or the prompt is undeliverable + // and there is no Cancel until the ten-minute bound expires. + if (!current && (event.state === "prompt" || event.state === "pending")) { + return { + attemptId: ++piSignInAttemptCounter.current, + providerId: event.providerId, + method: null, + prompt: event.state === "prompt" ? event.prompt ?? null : null, + link: event.notice?.url || event.notice?.userCode ? event.notice ?? null : null, + progress: event.notice?.message ?? null, + }; + } + if (!current || current.providerId !== event.providerId) return current; + if (event.state === "prompt" && event.prompt) return { ...current, prompt: event.prompt }; + if (event.state !== "pending" || !event.notice) return current; + // A URL or device code is the step the user has to act on, so it stays + // on screen; plain progress lines replace each other. + return event.notice.url || event.notice.userCode + ? { ...current, link: event.notice, progress: null } + : { ...current, progress: event.notice.message }; + }); + // A local runtime delivers each status twice (direct IPC broadcast plus + // the buffered relay), and the second copy can land after the user has + // started typing. Only a genuinely new prompt clears the field, or the + // duplicate would erase a half-entered API key. + if (event.state === "prompt" && event.prompt) { + setPromptValue((current) => (lastPromptRequestIdRef.current === event.prompt!.requestId ? current : "")); + lastPromptRequestIdRef.current = event.prompt.requestId; + } + }); + return unsubscribe; + }, []); + + // A device-code or OAuth step cannot proceed until the page is open, and Pi + // is already polling by the time the URL arrives. Open it once per URL and + // leave the copyable URL and code on screen for a blocked or wrong browser. + useEffect(() => { + const url = flow?.link?.url ?? flow?.link?.verificationUri ?? null; + if (!url || openedAuthUrlRef.current === url) return; + openedAuthUrlRef.current = url; + openExternalUrl(url); + }, [flow?.link?.url, flow?.link?.verificationUri]); + + useEffect(() => { + if (!flow?.prompt) return; + // A choice prompt unmounts whatever held focus, so hand focus to the first + // option rather than letting it fall back to the document. + if (piChoiceOptions(flow.prompt).length) firstChoiceRef.current?.focus(); + else promptInputRef.current?.focus(); + }, [flow?.prompt]); + + useEffect(() => { + // Settling unmounts the flow card, which is where focus was; without this a + // keyboard user lands back on the document body instead of the one control + // that can recover the failed attempt. + if (outcome?.state === "error") retryButtonRef.current?.focus(); + }, [outcome]); + + /** + * Report a finished sign-in exactly once. + * + * `finish()` in the main process emits the status event and resolves the + * start call from the same place, and their arrival order at the renderer is + * not fixed. Both channels are kept — the event survives a remount, the + * resolution survives a dropped push — so the first one to land settles and + * the other is a no-op. Without the latch the provider list refreshed twice + * and the "was this a user cancel" ref could be cleared before the slower + * channel read it. + */ + const settle = useCallback((providerId: string, ok: boolean, error: string | null) => { + if (settledProvidersRef.current.has(providerId)) return; + settledProvidersRef.current.add(providerId); + const cancelled = !ok && cancelledProviderRef.current === providerId; + setOutcome({ + providerId, + method: flowRef.current?.providerId === providerId ? flowRef.current.method : null, + state: ok ? "ok" : cancelled ? "cancelled" : "error", + ...(ok || cancelled || !error ? {} : { error }), + }); + setFlow((current) => (current?.providerId === providerId ? null : current)); + if (ok) { + onSignedInRef.current(); + void loadProvidersRef.current(); + } + }, []); + useEffect(() => { + settleRef.current = settle; + }, [settle]); + + const start = async (providerId: string, method?: PiLoginMethod) => { + setOutcome(null); + setPromptValue(""); + cancelledProviderRef.current = null; + settledProvidersRef.current.delete(providerId); + // A retry usually re-issues the same auth URL, and it still has to open. + openedAuthUrlRef.current = null; + const attemptId = ++piSignInAttemptCounter.current; + setFlow({ attemptId, providerId, method: method ?? null, prompt: null, link: null, progress: null }); + // Every update below runs after an await, by which time "Try again" may have + // started a replacement. A superseded attempt must not report its own + // outcome or refresh providers on the newer one's behalf. + const isCurrentAttempt = () => piSignInAttemptCounter.current === attemptId; + try { + const result = await window.ade.ai.piLoginStart({ providerId, ...(method ? { method } : {}) }); + if (!isCurrentAttempt()) return; + settle(providerId, result.ok, result.error ?? null); + } catch (err) { + if (!isCurrentAttempt()) return; + // The call itself failed, which the status stream would never report. + settle(providerId, false, err instanceof Error ? err.message : String(err)); + } finally { + // A second sign-in may already own these, so only this attempt's own + // state is torn down here. + if (cancelledProviderRef.current === providerId) cancelledProviderRef.current = null; + setFlow((current) => (current?.attemptId === attemptId ? null : current)); + } + }; + + const answer = async (value: string) => { + const prompt = flow?.prompt; + if (!flow || !prompt) return; + setFlow((current) => (current ? { ...current, prompt: null } : current)); + setPromptValue(""); + const fail = (error: string) => + setOutcome({ providerId: flow.providerId, method: flow.method, state: "error", error }); + try { + // A rejected answer comes back as ok:false rather than a throw, and the + // prompt is already gone — without this the user waits out Pi's login + // timeout with nothing on screen. + const result = await window.ade.ai.piLoginSubmit({ + providerId: flow.providerId, + requestId: prompt.requestId, + value, + }); + if (!result.ok) fail(result.error ?? "Pi did not accept that answer."); + } catch (err) { + fail(err instanceof Error ? err.message : String(err)); + } + }; + + const cancel = () => { + if (!flow) return; + cancelledProviderRef.current = flow.providerId; + void window.ade.ai.piLoginCancel({ providerId: flow.providerId }).catch(() => undefined); + }; + + const signableProviders = providers ?? []; + const providerName = (providerId: string) => + signableProviders.find((provider) => provider.id === providerId)?.name + ?? installation.providers.find((provider) => provider.id === providerId)?.name + ?? providerId; + // Every provider Pi knows about: the ones already configured in the profile + // and the ones ADE can sign into, merged by id so one provider is one card. + const allRows = buildPiProviderRows( + installation.providers.filter((provider) => provider.configured), + signableProviders, + ); + // A loopback server has nothing to sign into, so its login options are + // dropped rather than merely hidden — that is what put an API key prompt in + // front of LM Studio. + const localRows = allRows + .filter((row) => row.status?.authType === "local") + .map((row) => ({ ...row, login: null })); + const signableOrKeyedRows = allRows.filter((row) => row.status?.authType !== "local"); + const connectedRows = signableOrKeyedRows.filter(piProviderIsConnected); + const search = providerSearch.trim().toLowerCase(); + // Local servers have their own section and no sign-in, so they must not + // reappear here as a card that opens the sign-in dialog. + const searchedRows = search + ? signableOrKeyedRows.filter((row) => row.id.toLowerCase().includes(search) || row.name.toLowerCase().includes(search)) + : []; + // "Popular" is what is worth showing before the user searches: everything + // with an interactive sign-in, most models first, minus what is already + // pinned above. + const popularRows = signableOrKeyedRows + .filter((row) => !piProviderIsConnected(row) && (row.login?.authTypes.length ?? 0) > 0) + .sort((left, right) => (piProviderModelCount(right) ?? 0) - (piProviderModelCount(left) ?? 0)); + const modelIdsByProvider = new Map(); + for (const registryId of installation.availableModelIds) { + const decoded = decodePiRegistryId(registryId); + if (!decoded) continue; + const existing = modelIdsByProvider.get(decoded.providerId); + if (existing) existing.push(decoded.modelId); + else modelIdsByProvider.set(decoded.providerId, [decoded.modelId]); + } + const detailProvider = detailProviderId + ? allRows.find((row) => row.id === detailProviderId) ?? null + : null; + if (!installation.sdkAvailable) { + return ( + // The card's own message already states the blocker, so this branch only + // has to offer the way out. +
+
Sign in
+ +
+ ); + } + + return ( +
+
+
Providers
+ +
+ + {providersError ? ( +
+ Could not list Pi providers: {providersError} +
+ ) : null} + + void answer(value)} + onCancel={cancel} + onRetry={(settled) => void start(settled.providerId, settled.method ?? undefined)} + /> + + {/* Starting a second sign-in cancels the first, so the list steps aside + while one is running. */} + {!flow ? ( + { + void loadProviders(); + onRefreshStatus(); + }} + onOpenProvider={setDetailProviderId} + /> + ) : null} + {detailProvider ? ( + { + setDetailProviderId(null); + void start(providerId, method); + }} + onClose={() => setDetailProviderId(null)} + /> + ) : null} + +
+ ); +} diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx index 59ed1b40a..434af838e 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx @@ -233,7 +233,7 @@ function buildPiInstallation(): NonNullable subscription: true, }, ], - availableModelIds: ["pi/openai-codex/gpt-5.4"], + availableModelIds: ["pi/default/openai-codex/gpt-5.4", "pi/default/openai-codex/gpt-5.4-codex"], authFileDetected: true, modelsFileDetected: false, settingsFileDetected: true, @@ -565,7 +565,7 @@ describe("ProvidersSection", () => { expect(await screen.findByText("Coding Agents")).toBeTruthy(); expect(screen.getByText("OpenCode — Universal Model Access")).toBeTruthy(); - expect(screen.getByText("All providers")).toBeTruthy(); + expect(screen.getByText(/^All providers · \d+$/)).toBeTruthy(); expect(screen.getByLabelText("Search all OpenCode providers")).toBeTruthy(); // Popular cards include Moonshot and Kimi. expect(screen.getByText("Moonshot AI")).toBeTruthy(); @@ -575,6 +575,17 @@ describe("ProvidersSection", () => { expect(screen.queryByText(/subscriptions ·/i)).toBeNull(); }); + /** Open a Pi provider's card, then return its sign-in button from the dialog. */ + async function openPiProviderSignIn(providerName: string, buttonName: string): Promise { + const tile = await screen.findByRole("button", { + name: new RegExp(`^(?:Open|Connect) ${providerName} in Pi$`), + }); + await act(async () => { + tile.click(); + }); + return await screen.findByRole("button", { name: buttonName }); + } + it("renders the Pi card with connected providers and opens Pi settings files", async () => { const getStatusMock = window.ade.ai.getStatus as ReturnType; getStatusMock.mockReset(); @@ -617,7 +628,7 @@ describe("ProvidersSection", () => { renderProvidersSection(); - const signIn = await screen.findByRole("button", { name: "Sign in with SuperGrok — xAI" }); + const signIn = await openPiProviderSignIn("xAI", "Sign in with SuperGrok — xAI"); expect(screen.getByRole("button", { name: "Use an API key — xAI" })).toBeTruthy(); await act(async () => { @@ -677,7 +688,7 @@ describe("ProvidersSection", () => { renderProvidersSection(); - const signIn = await screen.findByRole("button", { name: "Sign in — xAI" }); + const signIn = await openPiProviderSignIn("xAI", "Sign in — xAI"); await act(async () => { signIn.click(); }); @@ -712,7 +723,7 @@ describe("ProvidersSection", () => { renderProvidersSection(); - const signIn = await screen.findByRole("button", { name: "Sign in with SuperGrok — xAI" }); + const signIn = await openPiProviderSignIn("xAI", "Sign in with SuperGrok — xAI"); await act(async () => { signIn.click(); }); @@ -741,7 +752,7 @@ describe("ProvidersSection", () => { renderProvidersSection(); - const signIn = await screen.findByRole("button", { name: "Sign in — xAI" }); + const signIn = await openPiProviderSignIn("xAI", "Sign in — xAI"); await act(async () => { signIn.click(); }); @@ -769,19 +780,298 @@ describe("ProvidersSection", () => { renderProvidersSection(); expect((await screen.findAllByText("OpenAI Codex")).length).toBe(1); - expect(screen.getByRole("button", { name: "Sign in — OpenAI Codex" })).toBeTruthy(); expect(screen.getByText(/7 models/)).toBeTruthy(); + expect(await openPiProviderSignIn("OpenAI Codex", "Sign in — OpenAI Codex")).toBeTruthy(); + }); + + // Settings is torn down by any navigation, so cancelling on unmount killed + // the login of anyone who switched away while authorizing in their browser. + it("leaves a Pi sign-in running when Settings unmounts", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { piInstallation: buildPiInstallation() })); + (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([ + { id: "xai", name: "xAI", authTypes: ["oauth"], configured: false }, + ]); + (window.ade.ai.piLoginStart as ReturnType).mockImplementation( + () => new Promise<{ ok: boolean; error?: string }>(() => undefined), + ); + + const view = renderProvidersSection(); + const signIn = await openPiProviderSignIn("xAI", "Sign in — xAI"); + await act(async () => { + signIn.click(); + }); + + await act(async () => { + view.unmount(); + }); + expect(window.ade.ai.piLoginCancel).not.toHaveBeenCalled(); + }); + + // The outcome is reported by the status event, not only by whoever is still + // awaiting the start call, so a card that remounted mid-flow still learns. + it("reports a Pi sign-in that completed while no start call was pending", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { piInstallation: buildPiInstallation() })); + (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([ + { id: "xai", name: "xAI", authTypes: ["oauth"], configured: false }, + ]); + + renderProvidersSection(); + await screen.findByRole("button", { name: "Connect xAI in Pi" }); + getStatusMock.mockClear(); + + await act(async () => { + emitPiAuthStatus?.({ providerId: "xai", state: "success" }); + }); + + expect(screen.getByText(/Signed in to xAI\./)).toBeTruthy(); + expect(getStatusMock).toHaveBeenCalled(); + }); + + // finish() emits the event and resolves the start call from the same place; + // whichever lands first must settle, and the other must be a no-op. + it("reports a Pi sign-in once when both the event and the call resolve", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { piInstallation: buildPiInstallation() })); + (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([ + { id: "xai", name: "xAI", authTypes: ["oauth"], configured: false }, + ]); + let resolveLogin: ((result: { ok: boolean; error?: string }) => void) | null = null; + (window.ade.ai.piLoginStart as ReturnType).mockImplementation( + () => new Promise<{ ok: boolean; error?: string }>((resolve) => { + resolveLogin = resolve; + }), + ); + + renderProvidersSection(); + const signIn = await openPiProviderSignIn("xAI", "Sign in — xAI"); + await act(async () => { + signIn.click(); + }); + const loadCallsBefore = (window.ade.ai.piLoginProviders as ReturnType).mock.calls.length; + getStatusMock.mockClear(); + + await act(async () => { + emitPiAuthStatus?.({ providerId: "xai", state: "success" }); + resolveLogin?.({ ok: true }); + }); + + expect(screen.getByText(/Signed in to xAI\./)).toBeTruthy(); + expect(getStatusMock).toHaveBeenCalledTimes(1); + expect((window.ade.ai.piLoginProviders as ReturnType).mock.calls.length).toBe(loadCallsBefore + 1); }); - it("keeps Pi's terminal login reachable in one click", async () => { + // The settle latch is per provider, so it has to be released when a new + // attempt starts — otherwise the first outcome is the only one a provider + // can ever report for the rest of the session. + it("reports the outcome of a Pi sign-in restarted after a cancelled one", async () => { const getStatusMock = window.ade.ai.getStatus as ReturnType; getStatusMock.mockReset(); getStatusMock.mockResolvedValue(buildStatus(true, [], { piInstallation: buildPiInstallation() })); + (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([ + { id: "xai", name: "xAI", authTypes: ["oauth"], configured: false }, + ]); + (window.ade.ai.piLoginStart as ReturnType).mockImplementation( + () => new Promise<{ ok: boolean; error?: string }>(() => undefined), + ); + + renderProvidersSection(); + const signIn = await openPiProviderSignIn("xAI", "Sign in — xAI"); + await act(async () => { + signIn.click(); + }); + await act(async () => { + screen.getByRole("button", { name: "Cancel" }).click(); + }); + // The service settles the cancel after its grace window. + await act(async () => { + emitPiAuthStatus?.({ providerId: "xai", state: "error", error: "Sign-in cancelled." }); + }); + expect(screen.getByText("Sign-in cancelled.")).toBeTruthy(); + + const restarted = await openPiProviderSignIn("xAI", "Sign in — xAI"); + await act(async () => { + restarted.click(); + }); + await act(async () => { + emitPiAuthStatus?.({ + providerId: "xai", + state: "prompt", + prompt: { requestId: "req-2", kind: "manual_code", title: "Sign in to xAI", message: "Paste the code from your browser" }, + }); + }); + expect(screen.getByLabelText("Paste the code from your browser")).toBeTruthy(); + + await act(async () => { + emitPiAuthStatus?.({ providerId: "xai", state: "success" }); + }); + expect(screen.getByText(/Signed in to xAI\./)).toBeTruthy(); + }); + + // Pi is already polling the grant by the time the URL arrives; making the + // user find and press "Open" first is dead time in a timed flow. + it("opens a Pi device-code URL without waiting for a click", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { piInstallation: buildPiInstallation() })); + (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([ + { id: "xai", name: "xAI", authTypes: ["oauth"], configured: false }, + ]); + (window.ade.ai.piLoginStart as ReturnType).mockImplementation( + () => new Promise<{ ok: boolean; error?: string }>(() => undefined), + ); + + renderProvidersSection(); + const signIn = await openPiProviderSignIn("xAI", "Sign in — xAI"); + await act(async () => { + signIn.click(); + }); + await act(async () => { + emitPiAuthStatus?.({ + providerId: "xai", + state: "pending", + notice: { level: "info", message: "Enter the code", userCode: "ABCD-1234", verificationUri: "https://x.ai/device" }, + }); + }); + + expect(window.ade.app.openExternal).toHaveBeenCalledWith("https://x.ai/device"); + // The code and a manual escape hatch stay on screen for a blocked browser. + expect(screen.getByText("ABCD-1234")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Open" })).toBeTruthy(); + }); + + // A localhost model server has no credential to collect, so asking for an + // API key is the wrong question — it gets its own section and a reachability + // report instead. + it("reports Pi local model servers without offering an API key", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { + piInstallation: { + ...buildPiInstallation(), + providers: [ + ...buildPiInstallation().providers, + { + id: "lmstudio", + name: "LM Studio", + modelCount: 2, + availableModelCount: 2, + configured: true, + authType: "local", + authMethods: ["local"], + baseUrl: "http://127.0.0.1:1234/v1", + }, + ], + }, + })); (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([]); renderProvidersSection(); - expect(await screen.findByRole("button", { name: /Open Pi \/login/ })).toBeTruthy(); + const localServers = await screen.findByRole("group", { name: "Pi local model servers" }); + expect(within(localServers).getByText("Local Model Servers")).toBeTruthy(); + // ADE's own probe is the authority on where the server is and whether it + // answered; Pi's configured baseUrl is only the fallback. + expect(within(localServers).getByText("http://localhost:1234")).toBeTruthy(); + expect(within(localServers).getByText("Not detected")).toBeTruthy(); + // Never a sign-in affordance for a server the user runs. + expect(screen.queryByRole("button", { name: /LM Studio in Pi$/ })).toBeNull(); + expect(screen.queryByRole("button", { name: /Use an API key — LM Studio/ })).toBeNull(); + }); + + // ADE only probes ollama and lmstudio. A third loopback server is reported by + // Pi's own profile and by nothing else, so a probe-shaped label left it + // reading "Not checked" forever — a status that never resolves. + it("falls back to Pi's profile for a local server ADE cannot probe", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { + piInstallation: { + ...buildPiInstallation(), + providers: [ + ...buildPiInstallation().providers, + { + id: "llamacpp", + name: "llama.cpp", + modelCount: 1, + availableModelCount: 1, + configured: true, + authType: "local", + authMethods: ["local"], + baseUrl: "http://127.0.0.1:8080/v1", + }, + ], + }, + })); + (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([]); + + renderProvidersSection(); + + const localServers = await screen.findByRole("group", { name: "Pi local model servers" }); + expect(within(localServers).getByText("llama.cpp")).toBeTruthy(); + expect(within(localServers).getByText("Configured in Pi")).toBeTruthy(); + expect(within(localServers).queryByText("Not checked")).toBeNull(); + // Still a server the user runs: no credential affordance. + expect(screen.queryByRole("button", { name: /llama\.cpp in Pi$/ })).toBeNull(); + }); + + // Everything the local-servers section shows comes from the AI status probe, + // not from Pi's login list, so refreshing only the latter left the button + // spinning over data that could not change. + it("re-probes local servers when Pi's Refresh is pressed", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { + piInstallation: { + ...buildPiInstallation(), + providers: [ + ...buildPiInstallation().providers, + { + id: "lmstudio", + name: "LM Studio", + modelCount: 1, + availableModelCount: 1, + configured: true, + authType: "local", + authMethods: ["local"], + baseUrl: "http://127.0.0.1:1234/v1", + }, + ], + }, + })); + (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([]); + + renderProvidersSection(); + const localServers = await screen.findByRole("group", { name: "Pi local model servers" }); + getStatusMock.mockClear(); + + await act(async () => { + within(localServers).getByRole("button", { name: /Refresh/ }).click(); + }); + + expect(getStatusMock).toHaveBeenCalledWith(expect.objectContaining({ force: true })); + }); + + it("filters Pi providers with the search box", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [], { piInstallation: buildPiInstallation() })); + (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([ + { id: "xai", name: "xAI", authTypes: ["oauth"], configured: false }, + { id: "groq", name: "Groq", authTypes: ["api_key"], configured: false }, + ]); + + renderProvidersSection(); + + const search = await screen.findByLabelText("Search all Pi providers"); + expect(screen.getByRole("button", { name: "Connect Groq in Pi" })).toBeTruthy(); + fireEvent.change(search, { target: { value: "xa" } }); + expect(screen.getByRole("button", { name: "Connect xAI in Pi" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Connect Groq in Pi" })).toBeNull(); }); it("explains the Pi card instead of stranding it when Pi's SDK is missing", async () => { @@ -800,7 +1090,11 @@ describe("ProvidersSection", () => { renderProvidersSection(); expect(await screen.findAllByText("Pi is installed, but ADE cannot load its package here.")).toHaveLength(1); - expect(screen.getByRole("button", { name: /Open Pi \/login/ })).toBeTruthy(); + // ADE used to offer to open Pi and type `/login` into its TUI after a fixed + // delay. That raced Pi's startup and submitted empty lines, so the branch + // states the instruction rather than automating it. + expect(screen.queryByRole("button", { name: /Open Pi \/login/ })).toBeNull(); + expect(screen.getByText(/run pi in a terminal and use its \/login command/i)).toBeTruthy(); expect(screen.queryByRole("button", { name: /Refresh providers/ })).toBeNull(); expect(window.ade.ai.piLoginProviders).not.toHaveBeenCalled(); }); @@ -816,7 +1110,7 @@ describe("ProvidersSection", () => { expect(screen.getByText("brew install anomalyco/tap/opencode")).toBeTruthy(); expect(screen.getByRole("button", { name: /Re-check/ })).toBeTruthy(); // The group body is hidden while uninstalled. - expect(screen.queryByText("All providers")).toBeNull(); + expect(screen.queryByText(/^All providers/)).toBeNull(); }); it("renders provider cards and catalog-updating state while inventory is stale", async () => { diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx index 9d73ea406..77728c2ca 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx @@ -4,8 +4,6 @@ import type { AiConfig, AiApiKeyVerificationResult, AiClaudeAvailability, - AiPiInstallationStatus, - AiPiProviderStatus, AiProviderConnectionStatus, AiSettingsStatus, ProjectConfigSnapshot, @@ -13,10 +11,6 @@ import type { import type { AiCustomProviderConfig, OpenCodeProviderAuthMethods, - PiAuthNotice, - PiAuthPrompt, - PiLoginMethod, - PiLoginProvider, } from "../../../shared/types/config"; import { getLocalModelIdTail, @@ -32,7 +26,6 @@ import { Copy, Cpu, Info, - MagnifyingGlass, WarningCircle, X, XCircle, @@ -44,6 +37,7 @@ import { MONO_FONT, SANS_FONT, LABEL_STYLE, + SECTION_LABEL_STYLE, outlineButton, primaryButton, } from "../lanes/laneDesignTokens"; @@ -54,12 +48,21 @@ import { invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; import { shouldRefreshAiStatusForChatEvent } from "../../lib/aiProviderStatus"; import { showToast } from "../app/toast/toastStore"; import { ClaudeLoginPromptButton, revealTerminalSessionInWork } from "../work/ClaudeLoginPromptButton"; -import { PiLoginPromptButton } from "../work/PiLoginPromptButton"; import { OpenCodeProviderDetailModal, type ApiKeySource, type OpenCodeProviderDetail, } from "./OpenCodeProviderDetailModal"; +import { + CollapsibleProviderCard, + ConnectedTag, + ProviderGrid, + ProviderSearchField, + ProviderTile, + ProviderTileBadge, + panel, +} from "./providerSectionPrimitives"; +import { PiProvidersPanel, buildPiMessage, getPiTone } from "./PiProvidersPanel"; type CliName = "claude" | "codex" | "cursor" | "droid"; @@ -207,25 +210,7 @@ const groupLabelStyle: React.CSSProperties = { color: COLORS.textSecondary, }; -const sectionLabelStyle: React.CSSProperties = { - fontSize: 10, - fontFamily: MONO_FONT, - textTransform: "uppercase", - letterSpacing: "1px", - color: COLORS.textMuted, - fontWeight: 700, -}; - /** Squared bordered surface — the shared "ledger" panel used across this section. */ -function panel(overrides?: React.CSSProperties): React.CSSProperties { - return { - border: `1px solid ${COLORS.border}`, - background: COLORS.recessedBg, - padding: 12, - ...overrides, - }; -} - function prettifyProviderId(id: string): string { return id .split(/[-_/]/) @@ -314,25 +299,6 @@ function SourceBadge({ source }: { source: ApiKeySource }) { ); } -function ConnectedTag() { - return ( - - Connected - - ); -} function OpenCodeProviderCard({ provider, @@ -349,43 +315,18 @@ function OpenCodeProviderCard({ ? "OAuth" : "Add"; return ( - + ) : undefined} + /> ); } @@ -491,557 +432,6 @@ function formatLocalModelLabel(modelId: string): string { return String(modelId ?? "").trim(); } -function getPiTone( - connection: AiProviderConnectionStatus | null | undefined, - installation: AiPiInstallationStatus | null | undefined, -): { color: string; label: string } { - if (!installation?.installed && !connection?.runtimeDetected) { - return { color: COLORS.textDim, label: "Not detected" }; - } - if (installation?.installed && !installation.sdkAvailable) { - return { color: COLORS.warning, label: "SDK needed" }; - } - if (connection?.runtimeAvailable) { - return { color: COLORS.success, label: "Ready" }; - } - if (installation?.sdkAvailable && connection?.authAvailable) { - return { color: COLORS.warning, label: "Configured" }; - } - if (installation?.sdkAvailable || installation?.cliAvailable) { - return { color: COLORS.warning, label: "Sign-in required" }; - } - return { color: COLORS.danger, label: "Unavailable" }; -} - -function buildPiMessage( - connection: AiProviderConnectionStatus | null | undefined, - installation: AiPiInstallationStatus | null | undefined, -): string { - if (!installation) { - return "Checking Pi installation and provider inventory."; - } - const configuredProviders = installation.providers.filter((provider) => provider.configured).length; - const availableModels = installation.availableModelIds.length; - if (connection?.runtimeAvailable) { - const version = installation.version ? `Pi ${installation.version}` : "Pi"; - return `${version} is installed. ${availableModels} model${availableModels === 1 ? " is" : "s are"} available across ${configuredProviders} configured provider${configuredProviders === 1 ? "" : "s"}.`; - } - if (connection?.blocker) { - return connection.blocker; - } - if (installation.installed && !installation.sdkAvailable) { - return installation.blocker - ?? "Pi CLI is available, but ADE's Pi SDK package is missing. Install @earendil-works/pi-coding-agent or set ADE_PI_PACKAGE_ROOT."; - } - if (!installation.installed) { - return "Pi is not installed for this user yet. Install @earendil-works/pi-coding-agent, then use Refresh. Pi keeps its own credentials and profile."; - } - return "Pi is installed, but no configured providers or available models were detected yet."; -} - -type PiSignInFlow = { - /** Identifies this attempt, so a superseded start cannot tear down its replacement. */ - attemptId: number; - providerId: string; - /** Kept so a failed flow can be retried with the button the user actually pressed. */ - method: PiLoginMethod | null; - prompt: PiAuthPrompt | null; - /** Sticky auth URL / device code the user still has to act on. */ - link: PiAuthNotice | null; - progress: string | null; -}; - -/** Cancelling is a choice, not a failure, so it gets its own state instead of an error. */ -type PiSignInOutcome = { - providerId: string; - method: PiLoginMethod | null; - state: "ok" | "cancelled" | "error"; - error?: string; -}; - -/** One provider row: what Pi already has configured, what can be signed into, or both. */ -type PiProviderRow = { - id: string; - name: string; - status: AiPiProviderStatus | null; - login: PiLoginProvider | null; -}; - -function piLoginMethodLabel(provider: PiLoginProvider, method: PiLoginMethod): string { - if (method === "api_key") return "Use an API key"; - return provider.loginLabel ?? (provider.isSubscription ? "Sign in with your subscription" : "Sign in"); -} - -/** Pi only sends options for select prompts; every other kind takes free text. */ -function piChoiceOptions(prompt: PiAuthPrompt | null | undefined): NonNullable { - return prompt?.options ?? []; -} - -/** Merges both provider sources by id so a signable, configured provider is one row, not two. */ -function buildPiProviderRows(configured: AiPiProviderStatus[], signable: PiLoginProvider[]): PiProviderRow[] { - const rows = new Map(); - for (const status of configured) { - rows.set(status.id, { id: status.id, name: status.name, status, login: null }); - } - for (const login of signable) { - const existing = rows.get(login.id); - if (existing) existing.login = login; - else rows.set(login.id, { id: login.id, name: login.name, status: null, login }); - } - return [...rows.values()]; -} - -/** Shared shell so a provider looks the same whether it is connected, signable, or both. */ -function PiProviderTile({ provider, children }: { provider: PiProviderRow; children: React.ReactNode }) { - const connected = provider.status?.configured ?? provider.login?.configured ?? false; - const noModels = connected && provider.status != null && provider.status.availableModelCount === 0; - return ( -
-
-
- - - {provider.name} - -
- {noModels ? ( - - No models - - ) : connected ? : null} -
- {children} -
- ); -} - -/** Pi's own terminal flow, offered next to the in-app one rather than behind a reveal. */ -function PiTerminalFallback({ - installation, - onRevealTerminal, -}: { - installation: AiPiInstallationStatus; - onRevealTerminal: (terminal: { terminalId: string; laneId: string }) => void; -}) { - return ( -
- {installation.cliAvailable ? ( - - ) : ( - - Install Pi to sign in from its terminal. - - )} -
- ); -} - -/** - * Runs Pi's own sign-in inside ADE: pick a provider, then answer whatever Pi - * asks. Pi writes the credential itself — nothing typed here is kept by ADE. - */ -function PiSignIn({ - installation, - onSignedIn, - onRevealTerminal, -}: { - installation: AiPiInstallationStatus; - onSignedIn: () => void; - onRevealTerminal: (terminal: { terminalId: string; laneId: string }) => void; -}) { - const [providers, setProviders] = useState(null); - const [providersError, setProvidersError] = useState(null); - const [loadingProviders, setLoadingProviders] = useState(false); - const [flow, setFlow] = useState(null); - const [promptValue, setPromptValue] = useState(""); - const [outcome, setOutcome] = useState(null); - const promptInputRef = useRef(null); - const firstChoiceRef = useRef(null); - const retryButtonRef = useRef(null); - /** Pi reports a cancel as a plain failure, so remember that the user asked for it. */ - const cancelledProviderRef = useRef(null); - const lastPromptRequestIdRef = useRef(null); - const piSignInAttemptCounter = useRef(0); - const promptFieldId = React.useId(); - const promptLabelId = `${promptFieldId}-label`; - const { copy, copied } = useCopyToClipboard(); - - const loadProviders = useCallback(async () => { - setLoadingProviders(true); - try { - const listed = await window.ade.ai.piLoginProviders(); - setProviders(listed); - setProvidersError(null); - } catch (err) { - setProvidersError(err instanceof Error ? err.message : String(err)); - } finally { - setLoadingProviders(false); - } - }, []); - - useEffect(() => { - if (!installation.sdkAvailable) return; - void loadProviders(); - }, [installation.sdkAvailable, loadProviders]); - - useEffect(() => { - const unsubscribe = window.ade.ai.onPiAuthStatus((event) => { - setFlow((current) => { - if (!current || current.providerId !== event.providerId) return current; - if (event.state === "prompt" && event.prompt) return { ...current, prompt: event.prompt }; - if (event.state !== "pending" || !event.notice) return current; - // A URL or device code is the step the user has to act on, so it stays - // on screen; plain progress lines replace each other. - return event.notice.url || event.notice.userCode - ? { ...current, link: event.notice, progress: null } - : { ...current, progress: event.notice.message }; - }); - // A local runtime delivers each status twice (direct IPC broadcast plus - // the buffered relay), and the second copy can land after the user has - // started typing. Only a genuinely new prompt clears the field, or the - // duplicate would erase a half-entered API key. - if (event.state === "prompt" && event.prompt) { - setPromptValue((current) => (lastPromptRequestIdRef.current === event.prompt!.requestId ? current : "")); - lastPromptRequestIdRef.current = event.prompt.requestId; - } - }); - return unsubscribe; - }, []); - - // Leaving Settings mid-sign-in would otherwise strand the flow: the worker - // keeps waiting for an answer for its full budget, and reopening Settings - // cannot adopt it because status events are ignored without a current flow. - const activeFlowProviderRef = useRef(null); - useEffect(() => { - activeFlowProviderRef.current = flow?.providerId ?? null; - }, [flow?.providerId]); - useEffect(() => () => { - const providerId = activeFlowProviderRef.current; - if (providerId) void window.ade.ai.piLoginCancel({ providerId }).catch(() => undefined); - }, []); - - useEffect(() => { - if (!flow?.prompt) return; - // A choice prompt unmounts whatever held focus, so hand focus to the first - // option rather than letting it fall back to the document. - if (piChoiceOptions(flow.prompt).length) firstChoiceRef.current?.focus(); - else promptInputRef.current?.focus(); - }, [flow?.prompt]); - - useEffect(() => { - // Settling unmounts the flow card, which is where focus was; without this a - // keyboard user lands back on the document body instead of the one control - // that can recover the failed attempt. - if (outcome?.state === "error") retryButtonRef.current?.focus(); - }, [outcome]); - - const start = async (providerId: string, method?: PiLoginMethod) => { - setOutcome(null); - setPromptValue(""); - cancelledProviderRef.current = null; - const attemptId = ++piSignInAttemptCounter.current; - setFlow({ attemptId, providerId, method: method ?? null, prompt: null, link: null, progress: null }); - // Every update below runs after an await, by which time "Try again" may have - // started a replacement. A superseded attempt must not report its own - // outcome or refresh providers on the newer one's behalf. - const isCurrentAttempt = () => piSignInAttemptCounter.current === attemptId; - try { - const result = await window.ade.ai.piLoginStart({ providerId, ...(method ? { method } : {}) }); - if (!isCurrentAttempt()) return; - const cancelled = !result.ok && cancelledProviderRef.current === providerId; - setOutcome({ - providerId, - method: method ?? null, - state: result.ok ? "ok" : cancelled ? "cancelled" : "error", - ...(result.ok || cancelled || !result.error ? {} : { error: result.error }), - }); - if (result.ok) { - onSignedIn(); - void loadProviders(); - } - } catch (err) { - if (!isCurrentAttempt()) return; - setOutcome({ - providerId, - method: method ?? null, - state: "error", - error: err instanceof Error ? err.message : String(err), - }); - } finally { - // A second sign-in may already own these, so only this attempt's own - // state is torn down here. - if (cancelledProviderRef.current === providerId) cancelledProviderRef.current = null; - setFlow((current) => (current?.attemptId === attemptId ? null : current)); - } - }; - - const answer = async (value: string) => { - const prompt = flow?.prompt; - if (!flow || !prompt) return; - setFlow((current) => (current ? { ...current, prompt: null } : current)); - setPromptValue(""); - const fail = (error: string) => - setOutcome({ providerId: flow.providerId, method: flow.method, state: "error", error }); - try { - // A rejected answer comes back as ok:false rather than a throw, and the - // prompt is already gone — without this the user waits out Pi's login - // timeout with nothing on screen. - const result = await window.ade.ai.piLoginSubmit({ - providerId: flow.providerId, - requestId: prompt.requestId, - value, - }); - if (!result.ok) fail(result.error ?? "Pi did not accept that answer."); - } catch (err) { - fail(err instanceof Error ? err.message : String(err)); - } - }; - - const cancel = () => { - if (!flow) return; - cancelledProviderRef.current = flow.providerId; - void window.ade.ai.piLoginCancel({ providerId: flow.providerId }).catch(() => undefined); - }; - - const signableProviders = providers ?? []; - const providerName = (providerId: string) => - signableProviders.find((provider) => provider.id === providerId)?.name - ?? installation.providers.find((provider) => provider.id === providerId)?.name - ?? providerId; - const providerRows = buildPiProviderRows( - installation.providers.filter((provider) => provider.configured), - signableProviders, - ); - const prompt = flow?.prompt ?? null; - const choiceOptions = piChoiceOptions(prompt); - const link = flow?.link ?? null; - const userCode = link?.userCode ?? null; - const verifyUrl = link?.url ?? link?.verificationUri ?? null; - - if (!installation.sdkAvailable) { - return ( - // The card's own message already states the blocker, so this branch only - // has to offer the way out. -
-
Sign in
- -
- ); - } - - return ( -
-
-
Providers
- -
- - {providersError ? ( -
- Could not list Pi providers: {providersError} -
- ) : null} - - {flow ? ( -
-
- Signing in to {providerName(flow.providerId)} -
-
- {flow.progress ?? "Waiting for Pi…"} -
- - {userCode ? ( -
- Code - - {userCode} - - -
- ) : null} - - {verifyUrl ? ( -
- - {verifyUrl} - - -
- ) : null} - - {prompt ? ( -
- {/* A label may only point at a form control, so the choice - branch names its button group instead of borrowing htmlFor. */} - - {choiceOptions.length ? ( -
- {choiceOptions.map((option, index) => ( - - ))} -
- ) : ( -
{ - event.preventDefault(); - void answer(promptValue); - }} - > - setPromptValue(event.target.value)} - style={{ flex: "1 1 220px", minWidth: 0, background: COLORS.recessedBg, border: `1px solid ${COLORS.border}`, padding: "6px 8px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} - /> - -
- )} -
- ) : null} - -
- -
-
- ) : null} - - {/* The live region stays mounted across settles: a status/alert node - inserted at the same instant its text appears is announced - unreliably. Empty, it leaves the flow so it adds no column gap. The - role alone carries urgency — an explicit aria-live would demote an - alert back to polite. */} -
- {outcome ? ( - - {outcome.state === "ok" - ? `Signed in to ${providerName(outcome.providerId)}.` - : outcome.state === "cancelled" - ? "Sign-in cancelled." - : `${providerName(outcome.providerId)}: ${outcome.error ?? "Sign-in did not finish."}`} - - ) : null} - {outcome?.state === "error" ? ( - - ) : null} -
- - {/* Starting a second sign-in cancels the first, so the list steps aside - while one is running. */} - {!flow && providerRows.length ? ( -
- {providerRows.map((row) => { - const { status, login } = row; - const modelCount = status ? status.availableModelCount || status.modelCount : 0; - return ( - - {status ? ( - <> -
- {modelCount} model{modelCount === 1 ? "" : "s"} - {status.availableModelCount > 0 && status.modelCount > status.availableModelCount ? ` · ${status.modelCount} known` : ""} -
-
- {piProviderAuthSummary(status)}{status.authLabel ? ` · ${status.authLabel}` : ""} -
- - ) : null} - {login ? ( -
- {login.authTypes.map((method) => ( - - ))} -
- ) : null} -
- ); - })} -
- ) : null} - - {!flow && providers !== null && !signableProviders.length && !providersError ? ( -
- {providerRows.length - ? "These read their keys from the environment — there is nothing to sign in to here." - : "No providers are set up in Pi yet."} -
- ) : null} - - -
- ); -} - -function piProviderAuthSummary(provider: AiPiProviderStatus): string { - if (provider.authType === "oauth") return provider.subscription ? "OAuth subscription" : "OAuth"; - if (provider.authType === "api-key") return "API key"; - if (provider.authType === "local") return "Local endpoint"; - return provider.authMethods.length ? provider.authMethods.join(" / ") : "No auth"; -} - function buildLocalProviderDrafts( snapshot: ProjectConfigSnapshot | null | undefined, status: AiSettingsStatus | null | undefined, @@ -1862,22 +1252,18 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh ? "Checking Pi installation and provider inventory." : buildPiMessage(piConnection, piInstallation); return ( -
-
-
- -
-
Pi
-
- Uses Pi’s installed SDK package and redacted auth status from its native profile. -
-
-
+ } + accentColor={tone.color} + status={(
{isInitialCheckInFlight ? : piConnection?.runtimeAvailable ? : piConnection?.authAvailable || piConnection?.runtimeDetected ? : } {tone.label}
-
+ )} + >
{message}
{piStatusLoadFailed ? (
) : null} - + ); })()}
@@ -2024,62 +1411,57 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh
{/* ── Connected ── */}
-
Connected
+
Connected
{connectedOpenCodeProviders.length === 0 ? (
No providers connected yet. Pick one below to sign in or add a key.
) : ( -
+ {connectedOpenCodeProviders.map((row) => ( openProviderDetail(row.id)} /> ))} -
+ )}
{/* ── All providers ── */}
-
All providers
-
- - setProviderSearch(event.target.value)} - placeholder="Search all OpenCode providers" - style={{ flex: 1, minWidth: 0, background: "transparent", border: "none", outline: "none", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary }} - /> -
+
All providers · {openCodeCatalog.length}
+
{!providerSearch.trim() ? ( <>
Popular
-
+ {popularOpenCodeProviders.map((row) => ( openProviderDetail(row.id)} /> ))} -
+ ) : searchableOpenCodeProviders.length === 0 ? (
No providers match your search.
) : ( -
+ {searchableOpenCodeProviders.map((row) => ( openProviderDetail(row.id)} /> ))} -
+ )}
{/* ── Local Model Servers ── */}
-
Local Model Servers
+
Local Model Servers
diff --git a/apps/desktop/src/renderer/components/settings/piProviderRow.ts b/apps/desktop/src/renderer/components/settings/piProviderRow.ts new file mode 100644 index 000000000..2263eff3e --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/piProviderRow.ts @@ -0,0 +1,52 @@ +/** + * One row in Pi's provider catalog, and the questions both the tile and the + * detail dialog ask of it. + * + * Its own module so neither of those two has to import from the other. + */ +import type { AiPiProviderStatus, AiRuntimeConnectionStatus, AiRuntimeConnections, PiLoginMethod, PiLoginProvider } from "../../../shared/types/config"; + +export type PiProviderRow = { + id: string; + name: string; + status: AiPiProviderStatus | null; + login: PiLoginProvider | null; +}; + +export function piProviderIsConnected(provider: PiProviderRow): boolean { + return provider.status?.configured ?? provider.login?.configured ?? false; +} + +export function piProviderModelCount(provider: PiProviderRow): number | null { + const status = provider.status; + if (!status) return null; + return status.availableModelCount || status.modelCount; +} + +/** Short description of how a provider authenticates. */ +export function piProviderAuthSummary(provider: AiPiProviderStatus): string { + if (provider.authType === "oauth") return provider.subscription ? "OAuth subscription" : "OAuth"; + if (provider.authType === "api-key") return "API key"; + if (provider.authType === "local") return "Local endpoint"; + return "Configured in Pi"; +} + +export function piLoginMethodLabel(provider: PiLoginProvider, method: PiLoginMethod): string { + if (method === "api_key") return "Use an API key"; + return provider.loginLabel ?? (provider.isSubscription ? "Sign in with your subscription" : "Sign in"); +} + +/** + * ADE's local-server probe for a Pi provider, or `null` when ADE has none. + * + * The probe map is keyed by ADE's own provider ids while the row carries Pi's, + * and the two namespaces only happen to agree on `ollama` and `lmstudio`. A + * named lookup keeps that assumption in one place instead of leaving a bare + * index to degrade silently into "no detection" for anything else. + */ +export function runtimeConnectionForPiProvider( + connections: AiRuntimeConnections, + piProviderId: string, +): AiRuntimeConnectionStatus | null { + return connections[piProviderId.trim().toLowerCase()] ?? null; +} diff --git a/apps/desktop/src/renderer/components/settings/providerSectionPrimitives.tsx b/apps/desktop/src/renderer/components/settings/providerSectionPrimitives.tsx new file mode 100644 index 000000000..fcee5ca3c --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/providerSectionPrimitives.tsx @@ -0,0 +1,379 @@ +/** + * The shared shape of a provider catalog. + * + * OpenCode and Pi each present ~40 providers the same way — a grid of compact + * tiles, a search field, and a detail dialog — and the two grew as independent + * copies that had already drifted apart on type weight. These are the pieces + * both sections build from, so a change to the pattern lands once. + */ +import React, { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { CaretRight, CheckCircle, MagnifyingGlass, X } from "@phosphor-icons/react"; +import { ProviderLogo } from "../shared/ProviderLogos"; +import { COLORS, MONO_FONT, SANS_FONT } from "../lanes/laneDesignTokens"; + +export function panel(overrides?: React.CSSProperties): React.CSSProperties { + return { + border: `1px solid ${COLORS.border}`, + background: COLORS.recessedBg, + padding: 12, + ...overrides, + }; +} + +export function ProviderGrid({ + minWidth = 200, + children, +}: { + minWidth?: number; + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +export function ProviderSearchField({ + label, + value, + onChange, +}: { + /** Doubles as the accessible name and the placeholder, so it must name the + * harness — both catalogs are on the same page. */ + label: string; + value: string; + onChange: (value: string) => void; +}) { + return ( +
+ + onChange(event.target.value)} + placeholder={label} + style={{ + flex: 1, + minWidth: 0, + background: "transparent", + border: "none", + outline: "none", + fontSize: 11, + fontFamily: MONO_FONT, + color: COLORS.textPrimary, + }} + /> +
+ ); +} + +/** + * One provider in a catalog grid. A real button, because opening a provider is + * an action — `aria-label` names the harness so two catalogs on one page never + * present two identically-named controls. + */ +export function ProviderTile({ + id, + name, + badge, + ariaLabel, + footer, + onOpen, +}: { + id: string; + name: string; + /** Right-hand status chip; pass an element for a connected tag. */ + badge: React.ReactNode; + ariaLabel: string; + footer?: React.ReactNode; + onOpen: () => void; +}) { + return ( + + ); +} + +export function ConnectedTag() { + return ( + + Connected + + ); +} + +/** The uppercase chip a tile shows when it is not connected. */ +export function ProviderTileBadge({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +/** + * A provider card that can be folded down to its header. + * + * The arrow belongs to the whole card, not to a band inside it: these sections + * run to dozens of providers each, and what a user scrolling Settings wants is + * to put the entire harness away, not to collapse one list within it. + */ +export function CollapsibleProviderCard({ + title, + summary, + logo, + status, + accentColor, + defaultOpen = true, + children, +}: { + title: string; + /** The one line that survives collapsing. */ + summary: React.ReactNode; + logo: React.ReactNode; + status?: React.ReactNode; + accentColor: string; + defaultOpen?: boolean; + children: React.ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+
+
+ + {logo} +
+
{title}
+
{summary}
+
+
+ {status} +
+ {open ? children : null} +
+ ); +} + +/** + * Modal shell for a provider's own page: overlay, Escape, focus in and back + * out, and the identifying header. Callers supply only the body. + */ +export function ProviderDetailDialog({ + providerId, + title, + subtitle, + suspended = false, + onClose, + children, +}: { + providerId: string; + title: string; + subtitle: React.ReactNode; + /** A dialog of its own is open on top; yield Escape and focus to it. */ + suspended?: boolean; + onClose: () => void; + children: React.ReactNode; +}) { + const dialogRef = useRef(null); + + useEffect(() => { + if (suspended) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + return; + } + // `aria-modal` tells assistive tech the page behind is inert; without a + // trap, Tab still walks into it, so a keyboard user leaves the dialog + // with no way back and no idea it is still open. + if (event.key !== "Tab") return; + const node = dialogRef.current; + if (!node) return; + const focusable = [...node.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + )].filter((element) => element.offsetParent !== null || element === node); + const first = focusable[0] ?? node; + const last = focusable[focusable.length - 1] ?? node; + const active = document.activeElement; + if (event.shiftKey && (active === first || active === node)) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose, suspended]); + + // Move focus into the dialog so keyboard users aren't acting under the overlay. + useEffect(() => { + if (suspended) return; + const node = dialogRef.current; + if (!node) return; + const previous = document.activeElement as HTMLElement | null; + node.focus(); + return () => { + previous?.focus?.(); + }; + }, [suspended]); + + return createPortal( +
+
event.stopPropagation()} + > +
+
+ +
+
+ {title} +
+
{subtitle}
+
+
+ +
+ {children} +
+
, + document.body, + ); +} diff --git a/apps/desktop/src/renderer/components/shared/useOpenProviderSignIn.ts b/apps/desktop/src/renderer/components/shared/useOpenProviderSignIn.ts index 152579b88..d72fc8f91 100644 --- a/apps/desktop/src/renderer/components/shared/useOpenProviderSignIn.ts +++ b/apps/desktop/src/renderer/components/shared/useOpenProviderSignIn.ts @@ -2,7 +2,6 @@ import { useCallback } from "react"; import { useNavigate } from "react-router-dom"; import type { AuthType, ProviderFamily } from "../../../shared/modelRegistry"; import { createClaudeLoginTerminalInWork } from "../work/ClaudeLoginPromptButton"; -import { createPiLoginTerminalInWork } from "../work/PiLoginPromptButton"; import { settingsRouteFor } from "../settings/settingsManifest"; export function useOpenProviderSignIn(): (family?: ProviderFamily, authTypes?: readonly AuthType[]) => void { @@ -14,10 +13,9 @@ export function useOpenProviderSignIn(): (family?: ProviderFamily, authTypes?: r return useCallback((family?: ProviderFamily, authTypes?: readonly AuthType[]) => { const shouldOpenClaudeLogin = family === "anthropic" && (authTypes == null || authTypes.includes("cli-subscription")); - const shouldOpenPiLogin = family === "pi"; - if (shouldOpenPiLogin) { - void createPiLoginTerminalInWork({ navigate }) - .catch(() => openAiProvidersSettings()); + // Pi signs in from Settings; there is no terminal path worth opening. + if (family === "pi") { + openAiProvidersSettings(); return; } if (!shouldOpenClaudeLogin) { diff --git a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts index f62aa83bc..676bd3e2c 100644 --- a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts +++ b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts @@ -948,6 +948,31 @@ describe("buildTrackedCliStartupCommand", () => { }); }); + // `--continue` means "the most recent session for this directory". Since + // chat and the tracked CLI share one native Pi store, that could be another + // terminal's session or an ADE chat's — terminals were silently reopening + // days-old transcripts. With no captured id, start fresh instead. + it("resumes Pi only by captured session id, never by --continue", () => { + const withId = buildTrackedCliResumeLaunchCommand({ + provider: "pi", + targetKind: "session", + targetId: "019fecac-13b8-7a10-9f24-c9f3afa33120", + launch: { permissionMode: "full-auto", model: "pi/xai/grok-4.5" }, + }); + expect(withId.args).toContain("--session"); + expect(withId.args).toContain("019fecac-13b8-7a10-9f24-c9f3afa33120"); + expect(withId.args).not.toContain("--continue"); + + const withoutId = buildTrackedCliResumeLaunchCommand({ + provider: "pi", + targetKind: "session", + targetId: null, + launch: { permissionMode: "full-auto", model: "pi/xai/grok-4.5" }, + }); + expect(withoutId.args).not.toContain("--continue"); + expect(withoutId.args).not.toContain("--session"); + }); + it("launches Pi with safe model/thinking argv, ADE guidance, and skill environment", () => { const launch = buildTrackedCliLaunchCommand({ provider: "pi", diff --git a/apps/desktop/src/renderer/components/work/PiLoginPromptButton.tsx b/apps/desktop/src/renderer/components/work/PiLoginPromptButton.tsx deleted file mode 100644 index 2bebb745c..000000000 --- a/apps/desktop/src/renderer/components/work/PiLoginPromptButton.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { useState } from "react"; -import { SpinnerGap, Terminal } from "@phosphor-icons/react"; -import { cn } from "../ui/cn"; -import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion"; - -export type PiLoginTerminal = { - terminalId: string; - ptyId: string; - laneId: string; - label: string; -}; - -type WorkNavigate = (path: string) => void; - -async function resolvePiLoginLaneId(laneId?: string | null): Promise { - if (laneId) return laneId; - const lanes = await window.ade.lanes.list({ includeArchived: false, includeStatus: false }); - const primary = lanes.find((lane) => lane.laneType === "primary") ?? lanes[0]; - if (!primary?.id) throw new Error("No active lane is available for this project."); - return primary.id; -} - -export async function createPiLoginTerminal({ - laneId, - chatSessionId, -}: { - laneId?: string | null; - chatSessionId?: string | null; -} = {}): Promise { - if (!window.ade?.pty?.create) throw new Error("Terminal sessions are not available in this ADE runtime."); - const resolvedLaneId = await resolvePiLoginLaneId(laneId); - const created = await window.ade.pty.create({ - laneId: resolvedLaneId, - ...(chatSessionId ? { chatSessionId } : {}), - cols: 100, - rows: 28, - title: "Pi login", - tracked: true, - toolType: "shell", - startupCommand: "pi", - // Pi's OAuth/device-code flow is entered from its interactive command - // palette. Sending this after startup preserves Pi's native auth UX. - initialInput: "/login\n", - initialInputDelayMs: 1_200, - }); - return { - laneId: resolvedLaneId, - terminalId: created.sessionId, - ptyId: created.ptyId, - label: "Pi login", - }; -} - -export function revealPiTerminalInWork( - navigate: WorkNavigate, - terminal: { terminalId: string; laneId: string }, - delayMs = 80, -): void { - navigate("/work"); - window.setTimeout(() => { - window.dispatchEvent(new CustomEvent("ade:work:select-session", { - detail: { sessionId: terminal.terminalId, laneId: terminal.laneId }, - })); - }, delayMs); -} - -export async function createPiLoginTerminalInWork({ - navigate, - laneId, - chatSessionId, -}: { - navigate: WorkNavigate; - laneId?: string | null; - chatSessionId?: string | null; -}): Promise { - const terminal = await createPiLoginTerminal({ laneId, chatSessionId }); - revealPiTerminalInWork(navigate, terminal); - return terminal; -} - -export function PiLoginPromptButton({ - visible, - laneId, - chatSessionId, - onRevealTerminal, - className, -}: { - visible: boolean; - laneId?: string | null; - chatSessionId?: string | null; - onRevealTerminal?: (terminal: PiLoginTerminal) => void; - className?: string; -}) { - const [opening, setOpening] = useState(false); - const [error, setError] = useState(null); - const reducedMotion = usePrefersReducedMotion(); - if (!visible) return null; - - const open = () => { - if (opening) return; - setOpening(true); - setError(null); - void createPiLoginTerminal({ laneId, chatSessionId }) - .then((terminal) => onRevealTerminal?.(terminal)) - .catch((reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason))) - .finally(() => setOpening(false)); - }; - - return ( - - - {opening ? Opening Pi’s native login terminal… : null} - {error ? {error} : null} - - ); -} diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index b7cc5eada..6e2786f76 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -2774,6 +2774,7 @@ button:active, [role="button"]:active { } .ade-chat-message-card-user { + /* Kept in sync with CHAT_USER_BUBBLE_GRADIENT_DEFAULT in chatSurfaceTheme.ts. */ background: var(--chat-user-bubble-gradient, linear-gradient(135deg, color-mix(in srgb, var(--chat-accent) 76%, #ffffff 6%) 0%, color-mix(in srgb, var(--chat-accent) 60%, #7c3aed 40%) 50%, color-mix(in srgb, var(--chat-accent) 58%, #4c1d95 42%) 100%)); border-color: color-mix(in srgb, var(--chat-accent) 26%, rgba(255, 255, 255, 0.14)); diff --git a/apps/desktop/src/renderer/lib/lobeProviderIconSrc.ts b/apps/desktop/src/renderer/lib/lobeProviderIconSrc.ts index 53af3ecd6..198e5aef5 100644 --- a/apps/desktop/src/renderer/lib/lobeProviderIconSrc.ts +++ b/apps/desktop/src/renderer/lib/lobeProviderIconSrc.ts @@ -4,17 +4,34 @@ * @see https://lobehub.com/icons/skill.md */ import anthropic from "@lobehub/icons-static-svg/icons/anthropic.svg"; +import azureColor from "@lobehub/icons-static-svg/icons/azure-color.svg"; +import baseten from "@lobehub/icons-static-svg/icons/baseten.svg"; +import bedrockColor from "@lobehub/icons-static-svg/icons/bedrock-color.svg"; +import cerebrasColor from "@lobehub/icons-static-svg/icons/cerebras-color.svg"; +import cloudflareColor from "@lobehub/icons-static-svg/icons/cloudflare-color.svg"; +import cohereColor from "@lobehub/icons-static-svg/icons/cohere-color.svg"; import deepseekColor from "@lobehub/icons-static-svg/icons/deepseek-color.svg"; +import fireworksColor from "@lobehub/icons-static-svg/icons/fireworks-color.svg"; import googleColor from "@lobehub/icons-static-svg/icons/google-color.svg"; import groq from "@lobehub/icons-static-svg/icons/groq.svg"; +import huggingfaceColor from "@lobehub/icons-static-svg/icons/huggingface-color.svg"; +import kimiColor from "@lobehub/icons-static-svg/icons/kimi-color.svg"; import lmstudio from "@lobehub/icons-static-svg/icons/lmstudio.svg"; import metaColor from "@lobehub/icons-static-svg/icons/meta-color.svg"; +import minimaxColor from "@lobehub/icons-static-svg/icons/minimax-color.svg"; import mistralColor from "@lobehub/icons-static-svg/icons/mistral-color.svg"; +import moonshot from "@lobehub/icons-static-svg/icons/moonshot.svg"; +import nvidiaColor from "@lobehub/icons-static-svg/icons/nvidia-color.svg"; import ollama from "@lobehub/icons-static-svg/icons/ollama.svg"; import openai from "@lobehub/icons-static-svg/icons/openai.svg"; import openrouter from "@lobehub/icons-static-svg/icons/openrouter.svg"; +import perplexityColor from "@lobehub/icons-static-svg/icons/perplexity-color.svg"; +import qwenColor from "@lobehub/icons-static-svg/icons/qwen-color.svg"; import togetherColor from "@lobehub/icons-static-svg/icons/together-color.svg"; +import vercel from "@lobehub/icons-static-svg/icons/vercel.svg"; import xai from "@lobehub/icons-static-svg/icons/xai.svg"; +import xiaomimimo from "@lobehub/icons-static-svg/icons/xiaomimimo.svg"; +import zai from "@lobehub/icons-static-svg/icons/zai.svg"; /** ADE `ProviderFamily` (lowercase) → bundled SVG URL */ export const LOBE_PROVIDER_ICON_SRC: Record = { @@ -34,6 +51,43 @@ export const LOBE_PROVIDER_ICON_SRC: Record = { together: togetherColor, togetherai: togetherColor, meta: metaColor, + // Pi ships roughly forty providers, most of which fell through to a grey + // letter avatar. These are the same LobeHub marks, keyed by the ids Pi and + // OpenCode actually use — including their regional and plan variants. + cerebras: cerebrasColor, + fireworks: fireworksColor, + baseten, + nvidia: nvidiaColor, + huggingface: huggingfaceColor, + cohere: cohereColor, + perplexity: perplexityColor, + minimax: minimaxColor, + "minimax-cn": minimaxColor, + moonshotai: moonshot, + "moonshotai-cn": moonshot, + moonshot, + kimi: kimiColor, + "kimi-coding": kimiColor, + "kimi-for-coding": kimiColor, + zai, + "zai-coding-cn": zai, + zhipu: zai, + "qwen-token-plan": qwenColor, + "qwen-token-plan-cn": qwenColor, + qwen: qwenColor, + xiaomi: xiaomimimo, + "xiaomi-token-plan-ams": xiaomimimo, + "xiaomi-token-plan-cn": xiaomimimo, + "xiaomi-token-plan-sgp": xiaomimimo, + "amazon-bedrock": bedrockColor, + bedrock: bedrockColor, + "azure-openai-responses": azureColor, + azure: azureColor, + "cloudflare-ai-gateway": cloudflareColor, + "cloudflare-workers-ai": cloudflareColor, + cloudflare: cloudflareColor, + "vercel-ai-gateway": vercel, + vercel, }; export function lobeProviderIconSrc(family: string): string | undefined { diff --git a/apps/desktop/src/renderer/lib/nativeLaunchControls.test.ts b/apps/desktop/src/renderer/lib/nativeLaunchControls.test.ts index 8592c2fbe..52749f8ef 100644 --- a/apps/desktop/src/renderer/lib/nativeLaunchControls.test.ts +++ b/apps/desktop/src/renderer/lib/nativeLaunchControls.test.ts @@ -5,9 +5,33 @@ import { cliPermissionModeFromNativeControls, defaultNativeControls, readUnifiedPermissionFromNativeControls, + summarizeNativeControls, } from "./nativeLaunchControls"; describe("nativeLaunchControls", () => { + // Pi's native permission field is `permissionMode`; it has no provider + // sibling, and the main process deletes `opencodePermissionMode` from a Pi + // session. Falling through to the OpenCode tail wrote back a field the + // session never carries, silently downgrading a full-auto Pi chat to edit + // on the first composer interaction. + it("summarizes a Pi session onto permissionMode alone", () => { + const controls = { ...defaultNativeControls(), opencodePermissionMode: "full-auto" as const }; + const summary = summarizeNativeControls("pi", controls); + + expect(summary.permissionMode).toBe("full-auto"); + expect(summary.opencodePermissionMode).toBeUndefined(); + expect(summary.droidPermissionMode).toBeUndefined(); + expect(summary.claudePermissionMode).toBeUndefined(); + }); + + it("keeps OpenCode carrying both its native field and the legacy mode", () => { + const controls = { ...defaultNativeControls(), opencodePermissionMode: "full-auto" as const }; + const summary = summarizeNativeControls("opencode", controls); + + expect(summary.opencodePermissionMode).toBe("full-auto"); + expect(summary.permissionMode).toBe("full-auto"); + }); + it("maps plan mode onto codex native fields", () => { const controls = applyUnifiedPermissionToNativeControls( "openai/gpt-5.5", diff --git a/apps/desktop/src/renderer/lib/nativeLaunchControls.ts b/apps/desktop/src/renderer/lib/nativeLaunchControls.ts index 2b4e6be83..ca3cc88ea 100644 --- a/apps/desktop/src/renderer/lib/nativeLaunchControls.ts +++ b/apps/desktop/src/renderer/lib/nativeLaunchControls.ts @@ -141,6 +141,14 @@ export function summarizeNativeControls( permissionMode: droidPermissionModeToLegacyPermissionMode(controls.droidPermissionMode), }; } + // Pi's native permission field IS `permissionMode` — it has no provider + // sibling, and the main process deletes `opencodePermissionMode` from a Pi + // session. Falling through to the OpenCode tail therefore reported whatever + // that absent field defaulted to, and writing it back silently downgraded a + // full-auto Pi chat to edit on the first composer interaction. + if (provider === "pi") { + return { permissionMode: controls.opencodePermissionMode }; + } return { opencodePermissionMode: controls.opencodePermissionMode, permissionMode: controls.opencodePermissionMode, diff --git a/apps/desktop/src/shared/cliLaunch.ts b/apps/desktop/src/shared/cliLaunch.ts index d33a40cec..0b22c6393 100644 --- a/apps/desktop/src/shared/cliLaunch.ts +++ b/apps/desktop/src/shared/cliLaunch.ts @@ -1364,10 +1364,12 @@ export function buildTrackedCliResumeLaunchCommand( ...piToolFlags(permissionMode), ]; // Pi's supported native continuation target is a session id/file passed to - // --session. When ADE has not captured a concrete id yet, continue the - // most recent session instead of silently launching a new one. + // --session. When ADE has not captured a concrete id, it starts a fresh + // session rather than falling back to `--continue`: "continue" means "the + // most recent session for this directory", which since chat and CLI share + // one native store can be another terminal's session or an ADE chat's. + // Terminals were silently reopening days-old transcripts that way. if (metadata.targetKind === "session" && targetId) parts.push("--session", targetId); - else parts.push("--continue"); // A bare `pi` command resolves to an npm `.cmd` shim on some Windows // installs. `cmd.exe` rewrites multiline prompts, expands `%NAME%`, and // imposes a command-line length limit, so deliver the resume prompt over diff --git a/apps/desktop/src/shared/modelRegistry.ts b/apps/desktop/src/shared/modelRegistry.ts index 8a91b426f..2a9d1a699 100644 --- a/apps/desktop/src/shared/modelRegistry.ts +++ b/apps/desktop/src/shared/modelRegistry.ts @@ -902,7 +902,7 @@ export function createDynamicPiModelDescriptor( reasoning: options?.capabilities?.reasoning ?? true, streaming: options?.capabilities?.streaming ?? true, }, - color: options?.color ?? "#F97316", + color: options?.color ?? "#181C25", providerRoute: "pi-sdk", providerModelId: `${provider}/${model}`, piProfileId: profileId, diff --git a/apps/desktop/src/shared/types/config.ts b/apps/desktop/src/shared/types/config.ts index 82e251f74..5bc4c53eb 100644 --- a/apps/desktop/src/shared/types/config.ts +++ b/apps/desktop/src/shared/types/config.ts @@ -1236,6 +1236,8 @@ export type AiPiProviderStatus = { configured: boolean; authType: "api-key" | "oauth" | "local" | "unknown" | null; authMethods: Array<"api-key" | "oauth" | "local">; + /** Endpoint a local provider serves from. Present only for `local` providers. */ + baseUrl?: string | null; authSource?: AiPiProviderAuthSource; authLabel?: string | null; subscription?: boolean; diff --git a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift index 81730c13b..3fad63819 100644 --- a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift +++ b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift @@ -356,10 +356,11 @@ enum ADEColor { "anthropic": 0xD97706, "codex": 0xE7E5E4, "openai": 0xE7E5E4, - "cursor": 0xA78BFA, - "droid": 0x8B5CF6, - "factory": 0x8B5CF6, - "opencode": 0x2563EB, + "cursor": 0x13120C, + "droid": 0xD46C2E, + "factory": 0xD46C2E, + "opencode": 0x739CEE, + "pi": 0x181C25, "google": 0xF59E0B, "gemini": 0xF59E0B, "mistral": 0xF97316, @@ -395,6 +396,45 @@ enum ADEColor { chatSurfaceAccent(modelId: nil, provider: provider) } + /// Perceived brightness of a chat accent, 0…1. Same weighting desktop uses in + /// `chatSurfaceTheme.ts` (`chatAccentLuminance`) so both platforms classify an + /// accent the same way. + static func chatAccentLuminance(_ color: Color) -> Double { + let rgb = chatAccentComponents(color) + return 0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b + } + + /// Dark enough that shading it further would erase the bubble's edges — the + /// near-black runtime accents (Cursor, Pi). Mirrors desktop `isDeepChatAccent`. + static func isDeepChatAccent(_ color: Color) -> Bool { + chatAccentLuminance(color) < 0.22 + } + + /// Accents whose user bubble keeps its original violet-mixed fill. + /// + /// Claude and Codex already read correctly, and re-colouring the other + /// runtimes was not licence to restyle them. Matched on the resolved colour + /// (not the provider id) so this stays identical to desktop's + /// `ACCENTS_KEEPING_ORIGINAL_BUBBLE`, which compares accent hex. + static func chatAccentKeepsOriginalBubble(_ color: Color) -> Bool { + let rgb = chatAccentComponents(color) + return [0xD97706 as UInt32, 0xE7E5E4].contains { packed in + let target = chatAccentComponents(Color(uiColor: hex(packed))) + return abs(rgb.r - target.r) < 0.004 + && abs(rgb.g - target.g) < 0.004 + && abs(rgb.b - target.b) < 0.004 + } + } + + /// sRGB components resolved against the dark trait, matching `workMixColors` + /// so a colour classifies the same way it will be blended. + private static func chatAccentComponents(_ color: Color) -> (r: Double, g: Double, b: Double) { + let resolved = UIColor(color).resolvedColor(with: UITraitCollection(userInterfaceStyle: .dark)) + var (r, g, b, a): (CGFloat, CGFloat, CGFloat, CGFloat) = (0, 0, 0, 0) + resolved.getRed(&r, green: &g, blue: &b, alpha: &a) + return (Double(r), Double(g), Double(b)) + } + private static func parseHexColor(_ input: String) -> Color? { var s = input if s.hasPrefix("#") { s.removeFirst() } diff --git a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift index 16ade51b1..3127f37d4 100644 --- a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift @@ -337,10 +337,27 @@ struct WorkChatMessageBubble: View { || model.hasPrefix("openai/gpt-") } + /// Flat stand-in for the desktop bubble gradient: each branch below is that + /// gradient's midpoint stop, which is the colour a small mobile bubble reads + /// as anyway. Kept in step with `CHAT_USER_BUBBLE_GRADIENT_*` in + /// `apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts`. private var userBubbleFill: Color { - isCodexChat - ? workMixColors(accent, workViolet, 0.44) - : workMixColors(accent, workViolet, 0.36) + // Claude and Codex shipped looking right, so they keep the original stops, + // which mix toward a fixed violet. + if ADEColor.chatAccentKeepsOriginalBubble(accent) { + return isCodexChat + ? workMixColors(accent, workViolet, 0.44) + : workMixColors(accent, workViolet, 0.36) + } + // Near-black accents (Cursor, Pi) lift toward white instead — mixing them + // toward violet turned two different runtimes into the same purple, and + // deepening them would sink the bubble into the transcript background. + if ADEColor.isDeepChatAccent(accent) { + return workMixColors(accent, Color.white, 0.14) + } + // Everything else shades from its own accent, so a per-provider colour is + // actually visible as that colour. + return accent } private var userBubbleBorder: Color { diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 9b385ec68..3dd9bf1f5 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -19264,6 +19264,68 @@ final class ADETests: XCTestCase { XCTAssertEqual(ADEColor.reasoningTiers(for: "gpt-5.2"), ["low", "medium", "high", "xhigh"]) } + /// Chat accents, and the bubble-fill classification they drive, are mirrored + /// from `apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts`. Drift + /// here means the same runtime reads as a different colour on each platform. + func testProviderChatAccentsAndBubbleClassificationMirrorDesktop() { + func rgb(_ color: Color) -> UInt32 { + var (r, g, b, a): (CGFloat, CGFloat, CGFloat, CGFloat) = (0, 0, 0, 0) + UIColor(color) + .resolvedColor(with: UITraitCollection(userInterfaceStyle: .dark)) + .getRed(&r, green: &g, blue: &b, alpha: &a) + return (UInt32(r * 255 + 0.5) << 16) | (UInt32(g * 255 + 0.5) << 8) | UInt32(b * 255 + 0.5) + } + + let perRuntime: [String: UInt32] = [ + "claude": 0xD97706, + "codex": 0xE7E5E4, + "opencode": 0x739CEE, + "cursor": 0x13120C, + "droid": 0xD46C2E, + "pi": 0x181C25, + ] + for (provider, expected) in perRuntime { + XCTAssertEqual(rgb(ADEColor.providerChatAccent(for: provider)), expected, provider) + } + // Distinct values, or two runtimes are indistinguishable in the transcript. + XCTAssertEqual(Set(perRuntime.values).count, perRuntime.count) + + // Aliases ride the same accent as their runtime. + XCTAssertEqual(rgb(ADEColor.providerChatAccent(for: "anthropic")), 0xD97706) + XCTAssertEqual(rgb(ADEColor.providerChatAccent(for: "openai")), 0xE7E5E4) + XCTAssertEqual(rgb(ADEColor.providerChatAccent(for: "factory")), 0xD46C2E) + XCTAssertEqual(rgb(ADEColor.providerChatAccent(for: "PI")), 0x181C25) + + // Claude and Codex keep the original violet-mixed bubble; nothing else does. + for provider in ["claude", "anthropic", "codex", "openai"] { + XCTAssertTrue( + ADEColor.chatAccentKeepsOriginalBubble(ADEColor.providerChatAccent(for: provider)), + provider + ) + } + for provider in ["opencode", "cursor", "droid", "factory", "pi"] { + XCTAssertFalse( + ADEColor.chatAccentKeepsOriginalBubble(ADEColor.providerChatAccent(for: provider)), + provider + ) + } + + // Only the near-black accents take the lifted fill; the rest paint their + // own accent, so a re-coloured provider is visible as its own colour. + for provider in ["cursor", "pi"] { + XCTAssertTrue( + ADEColor.isDeepChatAccent(ADEColor.providerChatAccent(for: provider)), + provider + ) + } + for provider in ["claude", "codex", "opencode", "droid"] { + XCTAssertFalse( + ADEColor.isDeepChatAccent(ADEColor.providerChatAccent(for: provider)), + provider + ) + } + } + func testDynamicWorkModelCatalogBuildsFromLiveHostModels() { let groups = workModelCatalogGroups( availableModelsByProvider: [ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 77b729fcc..bb9e1be94 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -628,7 +628,7 @@ Service entry points live under `apps/desktop/src/main/services/ai/`. The subsys - **CLI-wrapped** (Claude via `@anthropic-ai/claude-agent-sdk`, Codex via the pinned `@openai/codex` package) — spawned as subprocesses; Claude uses the SDK `query()` stream with ADE's async input pump and bundled Claude Code binary, while Codex uses its app-server JSON-RPC bridge. Desktop and runtime packages pin Codex `0.144.5`, including the matching native app-server binary. Authentication inherits from the user's own CLI login. ADE context is exposed through environment variables, and agents can call back into ADE with the `ade` CLI. - **API-key / OpenRouter** (Anthropic, OpenAI, Google, Mistral, DeepSeek, xAI, Groq, Together AI, OpenRouter) — routed through the **OpenCode server** (`opencode` binary, user-installed or bundled). Discovery via `openCodeInventory.ts`; replaces dynamic portion of the registry. - **Local** (Ollama, LM Studio, vLLM) — OpenAI-compatible local endpoints through OpenCode. Discovery via `localModelDiscovery.ts`. - - **Library-in-worker** (Pi) — the user's own Pi installation is resolved at runtime and loaded inside a forked Node worker (`piSdkPool.ts` / `piSdkWorker.ts`), never statically imported. The worker owns the Pi agent session, model runtime, tool registry, extension binding, and sign-in; the desktop process owns the cards that session blocks on, over a validated protocol-v2 channel (`piSdkProtocol.ts`). Installation resolution and blockers live in `services/ai/piInstallation.ts`; in-app provider sign-in lives in `services/ai/piAuthService.ts` and drives Pi's own `ModelRuntime.login` — Pi's `AuthStorage` writes `auth.json`, and ADE never receives, stores, or logs a credential. + - **Library-in-worker** (Pi) — the user's own Pi installation is resolved at runtime and loaded inside a forked Node worker (`piSdkPool.ts` / `piSdkWorker.ts`), never statically imported. The worker owns the Pi agent session, model runtime, tool registry, extension binding, and sign-in; the desktop process owns the cards that session blocks on, over a validated protocol-v2 channel (`piSdkProtocol.ts`). Installation resolution and blockers live in `services/ai/piInstallation.ts`; in-app provider sign-in lives in `services/ai/piAuthService.ts` and drives Pi's own `ModelRuntime.login` — Pi's `AuthStorage` writes `auth.json`, and ADE never receives, stores, or logs a credential. Pi is also the one runtime whose chat sessions share a native store with a tracked CLI: `piSessionStore.ts` resolves that one store (and refuses to honour a checkout's `.pi/settings.json`), `piSessionLease.ts` holds the live-writer lock, and `piSessionOwnership.ts` records the durable claim that keeps a terminal from adopting a chat's session. See [Agent Routing › Session store](features/chat/agent-routing.md#session-store). - **Detection pipeline**: - `authDetector.ts` — detects subscriptions, API keys, OpenRouter, local endpoints. - `providerCredentialSources.ts` — reads Claude OAuth credentials, Codex tokens, macOS Keychain. @@ -1237,7 +1237,7 @@ a badge would materialize a primary lane. See - **Bounded AI payloads** — narrative/proposal/PR description calls use `LaneExportStandard` or `LaneExportLite` + `ConflictExportStandard` (token-budgeted), not raw pack dumps or transcript slabs. - **Path validation** (`resolvePathWithinRoot()`) resolves symlinks via `realpathSync` before containment checks. Applied to lane env init, coordinator tools, process working dirs, sync artifact paths, ADE CLI context file resolution, computer-use artifact ingestion. - **Config trust**: process/test commands from `ade.yaml` require SHA-256 hash approval before execution. Commands in `local.yaml` are always trusted. Trust stored in `kv` with the config hash as key. -- **Provider sign-in relays, never stores**: Pi sign-in runs Pi's own `ModelRuntime.login` in a worker; Pi's `AuthStorage` writes `auth.json` under its cross-process lock. ADE receives prompt text only, passes the user's answer straight back, and keeps nothing on the status event. `ade.ai.piLoginSubmit`'s `value` argument is in `registerIpc.ts`'s per-channel redaction map because for an API-key prompt that argument *is* the credential. +- **Provider sign-in relays, never stores**: Pi sign-in runs Pi's own `ModelRuntime.login` in a worker; Pi's `AuthStorage` writes `auth.json` under its cross-process lock. ADE receives prompt text only, passes the user's answer straight back, and keeps nothing on the status event. `ade.ai.piLoginSubmit`'s `value` argument is in the per-channel redaction map because for an API-key prompt that argument *is* the credential. That map lives in `services/ipc/ipcChannelRedaction.ts` (`ipcChannelRedactionMap` + `redactIpcArgsForChannel`) rather than inside `registerIpc.ts` so the contract is reachable from a test — a channel silently dropping out of it leaks into verbose IPC traces and nothing else fails. - **Untrusted project code stays unexecuted**: the Pi worker constructs Pi's `SettingsManager` with `projectTrusted: false` and passes it to both the resource loader and the session, so only the user's own `~/.pi/agent/extensions` can load — never a checked-out repository's `.pi/extensions`. If a Pi build will not accept that flag, extensions are disabled rather than loaded unconstrained. Related trust-boundary docs: [Computer-use artifact broker](./features/computer-use/artifact-broker.md), [Computer-use backends](./features/computer-use/backends.md), and [Configuration schema](./features/onboarding-and-settings/configuration-schema.md). diff --git a/docs/features/agents/README.md b/docs/features/agents/README.md index a83827f38..54e4af693 100644 --- a/docs/features/agents/README.md +++ b/docs/features/agents/README.md @@ -12,7 +12,7 @@ The former worker/hiring agents were removed. There is one persistent identity | `apps/desktop/src/main/services/cto/ctoMemoryService.ts` | The CTO's smart-memory file store (`MEMORY.md`, `thread-state.md`, daily logs, search, injection sections). | | `apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts` | CTO operator tools for chat spawning, lanes/PRs/git/tests, Linear reads/writes, and the `saveMemory` / `searchMemory` / `readMemory` memory tools. | | `apps/desktop/src/main/services/agentTools/agentToolsService.ts` | Detects external CLI tools on PATH. | -| `apps/desktop/src/main/services/ai/piInstallation.ts` | Resolves the user's Pi installation — CLI path, SDK package root/entry, agent dir, `auth.json` / models / settings paths, provider inventory, and a `blocker` when the SDK path is unusable. `sdkAvailable` and `cliAvailable` are independent signals. | +| `apps/desktop/src/main/services/ai/piInstallation.ts` | Resolves the user's Pi installation — CLI path, SDK package root/entry, agent dir, `auth.json` / models / settings paths, provider inventory, and a `blocker` when the SDK path is unusable. `sdkAvailable` and `cliAvailable` are independent signals. Provider rows are the shared `AiPiProviderStatus` shape; a provider whose `baseUrl` is a loopback host is classified `local` and carries that endpoint through, so a model server the user runs is never mistaken for an API provider on the strength of a placeholder key. | | `apps/desktop/src/main/services/ai/piAuthService.ts` | In-app Pi sign-in: enumerates signable providers, drives Pi's own `ModelRuntime.login` on a dedicated inventory-only worker, and relays Pi's prompts and notices to whatever surface is listening. Relays credentials, never stores or logs them. | | `apps/ade-cli/src/cli.ts` | Agent-focused `ade` command surface and text/JSON output formatters. `ade new chat --mode chat|cli ... --type ` mirrors the desktop New Chat toggle; parented agent sessions inherit `ADE_CHAT_SESSION_ID` and must choose a type, while `--no-parent` creates an independent top-level session. `ade chat read --limit --max-chars ` silently reads a bounded project-backed transcript window across registered projects, and `--page --cursor ` walks older content. Personal chats remain on `ade chat ... --personal`. The file also owns typed Work status, scheduled work, Linear attachment, secrets, iOS Simulator, App Control, and browser command families. | | `apps/ade-cli/src/services/account/accountAuthService.ts` | Optional ADE account auth for humans, remote agents, and CI: loopback OAuth, account-directory device authorization, shared `account.session.v1` refresh storage, JWT-`exp`-authoritative access-token refresh, one cross-process refresh-rotation recovery attempt after `invalid_grant`, and ephemeral `ADE_ACCOUNT_TOKEN` credentials. | @@ -204,9 +204,12 @@ Pi's own `ModelRuntime.login` through `piAuthService.ts` and renders whatever Pi asks for — an auth URL, a device code with a copyable user code, a text or secret field, or a choice list — as ADE cards. ADE relays the user's answers and never reads, stores, or logs a credential; Pi's own `AuthStorage` owns -`auth.json`. Pi's terminal `/login` remains available beside the in-app flow and -is the only path when the Pi SDK is unavailable (missing package, or a Node -older than `PI_SDK_MIN_NODE`). See +`auth.json`. ADE does not drive Pi's terminal `/login`: that path typed +`/login` into Pi's TUI after a fixed delay, which raced Pi's startup and usually +submitted empty lines. When the Pi SDK is unavailable (missing package, or a +Node older than `PI_SDK_MIN_NODE`) the card states the terminal instruction +instead of running it, and a Pi sign-in requested from anywhere else in the app +opens Settings → Providers rather than spawning a terminal. See [Agent Routing › Pi sign-in](../chat/agent-routing.md#pi-sign-in). ### ADE account auth for agents and CI diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index da4003c0a..f12c389c1 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -77,7 +77,10 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/main/services/chat/piSdkProtocol.ts` | Worker IPC types and validators, at protocol version 2. Adds the `ui_request` / `ui_notice` / `ui_cancel` / `ui_response` frames and `login` / `login_cancel` on top of version 1, plus the `extensions` / `askUserTool` / `approvalTools` init flags and the `extensions` / `extensionsError` / `ungateableTools` fields on `PiSdkReady`. Every frame is validated in both directions. | | `apps/desktop/src/main/services/chat/piSdkUiBridge.ts` | Worker-side bridge from Pi's callback-shaped UI APIs to ADE cards, with no Pi imports of its own. `createPiUiBridge` is the never-rejecting request channel; `createPiAskUserTool` / `createPiApprovalGate` / `withPiApproval` build the `ask_user` tool and the per-call approval wrapper; `createPiAuthInteraction` implements Pi's `AuthInteraction`; `createPiExtensionUiContext` implements `ExtensionUIContext`. | | `apps/desktop/src/main/services/chat/piSdkEventMapper.ts` | Pi SDK event → `AgentChatEvent` translation, plus the card helpers: `piUiRequestToPendingInput` (blocking worker request → `PendingInputRequest` with `source: "pi"`), `piUiResponseFromAnswer` (card answer → worker reply, mapping `accept` / `accept_for_session` onto the gate's `allow` / `allow_session` values), `piUiNoticeToChatEvents`, and `piExtensionLoadNotice`. | -| `apps/desktop/src/main/services/ai/piAuthService.ts` | In-app Pi sign-in on a dedicated inventory-only worker: provider enumeration, one flow per provider, prompt/notice fan-out through `addPiAuthStatusListener`, prompt answers, cancellation, and a 10-minute bound. Relays credentials, never retains them. | +| `apps/desktop/src/main/services/chat/piSessionStore.ts` | The one native Pi session store ADE chat, tracked Pi CLI terminals, and external-session discovery all resolve against. `piSessionStoreForEnvironment` returns a `{ root, storageDir }` pair — the root is the authorization boundary, `storageDir` is set only when the user configured one, because Pi nests per-cwd subdirectories only when it is told nothing. Also owns header reads (`readPiSessionHeader`, `piSessionHeaderMatchesCwd`), file authorization (`resolvePiSessionFile`, `classifyPiSessionFile`), the per-cwd listing, and `repositoryOverridesPiSessionDir`. A checkout's `.pi/settings.json` is deliberately never read. | +| `apps/desktop/src/main/services/chat/piSessionLease.ts` | The `.ade-lease` live-writer lock: a pid + process-start-keyed cross-process claim (`owner: "sdk" \| "cli"`) removed on release and reclaimable once its owner is gone. `piSessionLeaseIsHeld` is the cheap pre-launch probe; `piSessionCreationLeaseTarget(sessionRoot, cwd)` is the synthetic per-cwd token held while a session has no JSONL yet (hashed per working directory so one lane's starting chat cannot block every other lane's). | +| `apps/desktop/src/main/services/chat/piSessionOwnership.ts` | The `.ade-owner` durable claim — `{ owner, ownerSessionId }`, never removed, because chat and the tracked CLI share one store and creation-time proximity cannot tell their sessions apart. `piSessionIsAdoptableByTerminal` leaves unclaimed sessions adoptable (a `pi` run started outside ADE) and `piSessionCouldBelongToTerminal` rejects a stored resume pointer at a session older than the terminal itself. | +| `apps/desktop/src/main/services/ai/piAuthService.ts` | In-app Pi sign-in on a dedicated inventory-only worker: provider enumeration, one flow per provider, prompt/notice fan-out through `addPiAuthStatusListener`, prompt answers, cancellation, and a 10-minute bound. A user-pressed cancel gives Pi `PI_LOGIN_CANCEL_GRACE_MS` to report a login it had already completed; a supersede (a replacement attempt) settles the outgoing flow at once and silently, so it cannot clear the card the newer attempt owns. Relays credentials, never retains them. | | `apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts` | Resolves the OpenCode CLI: PATH first, then the bundled `node_modules/.bin/opencode`. Cache entries are re-validated with `canRunBinaryCandidate` on every lookup so user installs after launch are picked up; missing-binary lookups are intentionally not cached. `clearOpenCodeBinaryCache()` is wired into the AI integration's full cache reset. | | `apps/desktop/src/main/services/opencode/openCodeInventory.ts` | OpenCode provider/model probe. Now classifies model variants into `reasoningTiers` + `serviceTiers` (alias map covering `minimal`/`mini`/`med`/`xhigh`/`extra-high`), reads `capabilities` (tools/vision/reasoning) into descriptor capabilities, and tracks both `modelIds` (connected providers only) and `catalogModelIds` (the full browseable catalog). Anthropic rows normalize generic `opus` to Opus 5 with its `high` default reasoning effort and Fast capability; retired Sonnet 4.6 / basic Opus 4.7 ids still resolve to Sonnet 5 / Opus 4.8 so runtime catalogs cannot reintroduce removed picker rows. `OpenCodeProviderInfo.availableModelCount` exposes the connected count separately from `modelCount`. **Cross-launch persistence:** `persistOpenCodeInventory(projectRoot, providers)` writes each successful probe's provider list (keyed by project root, with `savedAt`) to `opencode-inventory-cache.json` under Electron `userData` (override via `ADE_OPENCODE_INVENTORY_CACHE_FILE`); on a cold start the Settings page reloads that persisted list flagged stale (`opencodeProvidersStale`) so the ~160-provider chip cloud renders immediately instead of blanking until the first live probe (stale-while-revalidate). Writes are best-effort and never break the probe. | | `apps/desktop/src/main/services/opencode/openCodeAuthService.ts` | Drives the managed OpenCode server's auth API for subscription connect + API-key seeding, reusing the shared inventory server lease (never spawning its own process). `listAuthMethods` reads `GET /provider/auth`; `startOAuth` authorizes (`POST /provider/{id}/oauth/authorize`), opens the returned URL, and polls `provider.list().connected` every 2s until connected or a 5-min timeout, re-probing inventory on success; `cancelOAuth` stops the poller; `setProviderKey` does `PUT /auth/{id}` and mirrors the key into ADE's `apiKeyStore` so it is re-injected on future launches. One flow per `providerId` at a time (a new start supersedes the prior). Transitions are published through `addOpenCodeOAuthStatusListener` (`pending`/`connected`/`cancelled`/`timeout`/`failed`), a multi-sink fan-out so the same event reaches desktop windows and the remote/web runtime event buffer. Seeded credentials land in ADE's isolated managed OpenCode dir (XDG roots under `userData/opencode-runtime/xdg-v*`), never the user's `~/.local/share/opencode`. | @@ -126,7 +129,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/components/chat/ChatCursorCloudPanel.tsx` | Side panel for Cursor Cloud (background agents): lists existing cloud agents and runs for the lane, lets the user open an existing cloud chat in ADE, archive/unarchive/cancel, and stream run output. Backed by `ade.ai.cursorCloud.*` IPC. | | `apps/desktop/src/renderer/components/chat/CursorCloudInlineLaunch.tsx` | Inline composer affordance for "Send to Cursor Cloud": picks repo + branch + Cursor Cloud-eligible model, optionally targeting a detected PR, and dispatches the prompt to a fresh cloud agent. | | `apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx` | Shell that wraps every chat surface (desktop pane, mobile lane, CTO chat) with a unified header/footer slot and `--chat-accent` CSS variable. Supports a `layoutVariant="mobile"` mode that the iOS companion mirrors. | -| `apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts` | Chat chrome tokens. Exports `PROVIDER_CHAT_ACCENTS` (claude → amber, codex → warm white, cursor → violet, opencode → blue, etc.) and `providerChatAccent(provider)`. iOS mirrors this table in `ADEDesignSystem.swift`. | +| `apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts` | Chat chrome tokens. Exports `PROVIDER_CHAT_ACCENTS` (claude → amber, codex → warm white, cursor → near-black, droid/factory → burnt orange, opencode → periwinkle, pi → near-black, etc.) and `providerChatAccent(provider)`. The user bubble shades from `--chat-accent` itself rather than mixing toward a fixed violet, so two runtimes with different accents no longer come out the same purple; Claude and Codex are pinned to the original gradient (`ACCENTS_KEEPING_ORIGINAL_BUBBLE`) because they already read correctly, and near-black accents take a lifted gradient (`isDeepChatAccent`, luminance < 0.22) so the bubble does not disappear into the transcript background. iOS mirrors this table in `ADEDesignSystem.swift`. | | `apps/desktop/src/renderer/components/chat/AskQuestionComposer.tsx` | The ask-question surface, anchored **in the composer** — it replaces the textarea inside the same prompt-box frame while a question blocks (there is no longer a separate `AgentQuestionModal`, no `InlineQuestionRequestCard`, and no question-kind `pendingBanner`). Header is the provider mark + a kind-derived verb (`{Provider} asks` / `{Provider} · Plan ready` via `pendingInputHeaderLabel`) plus a dot rail for paged sets, a minimize `⌄`, and a decline `×`; body shows the question's `header` kicker then the question text once; options render as a one-column ledger with radio/checkbox a11y roles and a flush-right `✓`; option previews render through `QuestionOptionPreview` — a column-preserving monospace `
` for wireframes/ASCII (detected via `looksLikeWireframe`) and the code-fence-aware `ChatMarkdown` for prose — inside a natural-height, capped option region, disclosed by an explicit click rather than hover. Only genuinely long option content scrolls; header, note row, and footer stay pinned. Chrome inherits `--chat-accent` (per-provider), used in exactly two places plus one structural hairline. Keyboard: `1-9` pick, `↵` next/send, `←→` page, `esc` decline. Selecting marks and never submits; a pick and a typed note both travel (see `shared/pendingInputAnswers.ts`). Nothing is preselected. `QuestionReceipts.tsx` renders the transcript record: an "awaiting you" row while open, a one-line expandable receipt once resolved. |
 | `apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts` | Two-layer event-to-row pipeline (render events + grouped envelopes) that powers the message list. It threads per-subagent anchor state through the collapse pass to emit identity-keyed `subagent_spawn_anchor` / `subagent_result_card` / `background_job_line` render events (keys `subagent-spawn:` / `subagent-result:` / `background-chip:`), mutating anchors in place as progress/result events arrive and repairing row positions when a `transcript_retraction` splices a row out — so the virtualizer's measured heights survive rebind. `background_job_line` is upserted by two producers on one shared key space — the live runtime's `scheduled_work_update {kind:"background_task"}` and legacy `subagent_*` events carrying `taskType: background` — so a transcript holding both shapes still renders exactly one row per job; a settled row is never reopened by a late progress tick, a task that has opened a job line stays a background job even if a late `agentType` would reclassify it, and a real subagent reported through the background stream has its job line spliced out before its spawn anchor lands. It derives a `scheduled_wake_divider` immediately before every synthetic `user_message` carrying `metadata.scheduledWake` and a `spawn_wake_divider` before completion deliveries carrying `metadata.spawnCompletion`; the latter renders as **Subagent returned** whether the completion steered an active turn or woke an idle chat. It also diffs `todo_update` snapshots per turn so only changed tasks render, normalizes dotted `subagent.*` lifecycle events into the legacy renderer shape while providers migrate, and falls back to a full collapse when incremental append would miss todo state. A second-layer grouping pass (`groupStoppedSubagentResultCards`) folds a run of two or more consecutive interrupt-stopped `subagent_result_card` rows into one `subagent_stopped_group` event; completed/failed cards and a lone stopped card stay individual. |
 | `apps/desktop/src/main/services/ai/tools/` | Tool tiers consumed by the service when it provisions a Claude/Codex/OpenCode runtime (see [Tool System](tool-system.md)). |
diff --git a/docs/features/chat/agent-routing.md b/docs/features/chat/agent-routing.md
index 989b4e0ef..e48067434 100644
--- a/docs/features/chat/agent-routing.md
+++ b/docs/features/chat/agent-routing.md
@@ -39,7 +39,7 @@ for vendored runtimes without changing the union.
 | `opencode` | OpenCode server runtime: Anthropic/OpenAI/Google/Mistral/DeepSeek/xAI/Groq/Together AI API keys, OpenRouter, and local (Ollama, LM Studio, vLLM). | `agentChatService.ts` (OpenCode adapter); model discovery in `localModelDiscovery.ts` and `modelsDevService.ts`. |
 | `cursor` | Official `@cursor/sdk` running in a Node worker pool. ADE owns permissions, hooks, and the system prompt; the SDK owns the model + tool execution. Slash commands are discovered from `.cursor/commands/`, `.cursor/agents/`, built-in subagents, and Agent Skill roots via `cursorSlashCommandDiscovery.ts`. | `cursorSdkPool.ts`, `cursorSdkWorker.ts`, `cursorSdkProtocol.ts`, `cursorSdkPolicy.ts`, `cursorSdkSystemPrompt.ts`, `cursorSdkEventMapper.ts`, `cursorSlashCommandDiscovery.ts`. |
 | `droid` | Factory Droid models exposed as dynamic `droid/` descriptors and driven through the official `@factory/droid-sdk` running in a forked Node worker pool. The legacy ACP bridge (`droidAcpPool.ts`) has been retired. | `droidSdkPool.ts`, `droidSdkWorker.ts`, `droidSdkProtocol.ts`, `droidSdkEventMapper.ts`, `droidModelsDiscovery.ts`; model helpers in `modelRegistry.ts`. |
-| `pi` | The user's own Pi installation, loaded as a library inside a forked Node worker (never a static import — the worker resolves the installation only after init validation). The worker owns the Pi agent session, its model runtime, its tool registry, and its sign-in; ADE owns the cards the session blocks on. | `piSdkPool.ts`, `piSdkWorker.ts`, `piSdkProtocol.ts`, `piSdkEventMapper.ts`, `piSdkUiBridge.ts`, `piSdkEnvironment.ts`, `piSessionLease.ts`; installation and sign-in in `services/ai/piInstallation.ts` and `services/ai/piAuthService.ts`. |
+| `pi` | The user's own Pi installation, loaded as a library inside a forked Node worker (never a static import — the worker resolves the installation only after init validation). The worker owns the Pi agent session, its model runtime, its tool registry, and its sign-in; ADE owns the cards the session blocks on. | `piSdkPool.ts`, `piSdkWorker.ts`, `piSdkProtocol.ts`, `piSdkEventMapper.ts`, `piSdkUiBridge.ts`, `piSdkEnvironment.ts`; the shared native session store in `piSessionStore.ts` (resolving the tree, reading headers, authorizing files), `piSessionLease.ts` (the live-writer lock), and `piSessionOwnership.ts` (the durable ownership claim); installation and sign-in in `services/ai/piInstallation.ts` and `services/ai/piAuthService.ts`. |
 
 ## Model registry
 
@@ -179,13 +179,94 @@ interactive `login`, so ADE does not offer to sign into it. One flow runs per
 provider at a time — starting another supersedes the first, including a start
 that is still acquiring its worker.
 
+A **local model server** is not a sign-in either. `piInstallation.ts` classifies
+a provider as `local` from a loopback `baseUrl` rather than from the absence of
+a key, and carries that endpoint through on `AiPiProviderStatus.baseUrl`.
+Host-based is the point: LM Studio ships `apiKey: "lmstudio"` in `models.json` —
+a placeholder its OpenAI-compatible endpoint requires and ignores — so keying
+off a key classified a server the user runs as an API provider, offered to sign
+into it, and called it connected on the strength of a config file rather than a
+reachable server. A stored auth entry still wins, so a provider behind a local
+gateway keeps its sign-in.
+
 Signing in unlocks models, so both the IPC handler and the ADE action path
 invalidate the provider-readiness caches on success and log
 `ai.pi_auth_cache_invalidation_failed` if that fails.
 
-Pi's own terminal `/login` remains available as a secondary path, offered
-beside the in-app flow rather than behind a reveal, and it is the only path
-when `sdkAvailable` is false.
+A sign-in settles in the main process, so its outcome travels on the
+`piAuthStatus` `success` / `error` event rather than only on the resolution of
+the `piLoginStart` call. Settings is destroyed by any navigation, so binding
+the result to that one promise — and cancelling the flow when the card
+unmounted — meant a sign-in the user completed in their browser could report
+nothing, then land as "Sign-in cancelled" over a provider that was in fact
+connected. Leaving Settings no longer cancels anything; an abandoned flow is
+reclaimed by the ten-minute bound. A user-pressed **Cancel** gives Pi a short
+grace window (`PI_LOGIN_CANCEL_GRACE_MS`) to report a login it had already
+completed, so a cancel that lost the race is reported as the success it was; a
+supersede skips that window. Auth URLs open automatically, with the URL and
+device code left on screen for a blocked browser.
+
+ADE does not drive Pi's terminal `/login`. That path typed `/login` into Pi's
+TUI after a fixed delay, which raced Pi's startup and usually submitted empty
+lines; when `sdkAvailable` is false the card states the instruction instead.
+
+### Session store
+
+SDK chat, tracked Pi CLI terminals, and external-session discovery share **one**
+native store, resolved by `piSessionStoreForEnvironment` with Pi's own
+precedence: `--session-dir`, then `PI_CODING_AGENT_SESSION_DIR`, then the
+profile's global `settings.json` `sessionDir`, then `/sessions`. ADE
+never passes `--session-dir`, so the environment variable is effectively the top
+of the list. A checkout's `.pi/settings.json` is deliberately not read — it
+would let any cloned repository redirect where ADE authorizes and leases
+sessions. Pi's own CLI does merge project settings, so a tracked `pi` terminal
+launched in such a repository writes somewhere ADE does not authorize;
+`repositoryOverridesPiSessionDir` detects that and the launch says so instead of
+failing with a generic "could not be verified".
+
+The store **root** and the directory Pi writes into are different things. Pi
+nests one directory per working directory (`/sessions//`)
+only when it is told nothing; an explicit directory is used flat and verbatim.
+So the worker receives the root as `sessionRoot` (an authorization boundary) and
+`sessionStorageDir` only when the user configured one. Handing Pi the root
+would scatter files directly into `/sessions`, where Pi's own
+subdirectory-only discovery could never read them back.
+
+Pi buffers a new session in memory and writes its JSONL only at the first
+assistant message, so a fresh chat names a path that does not exist yet.
+`classifyPiSessionFile` answers `pending` for such a path once it is confined to
+the store, and the chat leases that exact path straight away — Pi fixes a
+session's file name when it creates the session, so no directory-wide token is
+needed and two chats in one lane never contend. Only the header check waits for
+the flush. The tracked CLI still holds a per-cwd creation lease, because it has
+to discover which session Pi made. Discovery scopes by
+containment so a project's whole tree stays browsable, but ownership is exact —
+`selectPiStorageSessionCandidate` requires the header cwd to match, since a lane
+worktree sits inside the primary lane's root.
+
+Two sidecars sit next to each session's JSONL, and they answer different
+questions:
+
+- **`.ade-lease`** (`piSessionLease.ts`) — "is someone writing this
+  right now". A cross-process lock keyed to the writer's pid and process start
+  time, `owner: "sdk" | "cli"`, removed on release and reclaimable once the
+  owning process is gone. `piSessionLeaseIsHeld` lets a launch skip a session
+  another live writer already holds instead of discovering it by failing to
+  acquire the lease after the launch has committed.
+- **`.ade-owner`** (`piSessionOwnership.ts`) — "whose session is this".
+  A durable `{ owner, ownerSessionId }` claim that is **never** removed, because
+  chat and the tracked CLI share one store and time proximity cannot tell a
+  chat's session from a terminal's created minutes apart. An unclaimed session
+  stays adoptable, so a `pi` run started outside ADE can still be picked up.
+  `ownerSessionId` is the ADE chat or terminal session id, deliberately not the
+  lease's `ownerId` (a live PTY handle), because it has to survive relaunches.
+
+Before Pi writes anything there is no JSONL to lock, so a tracked CLI launch
+holds `piSessionCreationLeaseTarget(sessionRoot, cwd)` — a synthetic token
+hashed **per working directory**, since the store root is shared by every
+project on the machine and a root-wide token would make one lane's starting Pi
+chat block every other lane's. Pi ignores the file; all of its own scans filter
+on `.jsonl`.
 
 ## Permission modes
 
@@ -446,11 +527,17 @@ translates the abstract value into the correct provider-native fields:
 - `codex`: `codexApprovalPolicy` + `codexSandbox` pair.
 - `opencode`: `opencodePermissionMode = "plan" | "edit" | "full-auto"`.
 - `droid`: `droidPermissionMode = "read-only" | "auto-low" | "auto-medium" | "auto-high"`.
-- `pi`: no native permission field. The abstract mode is read directly by
+- `pi`: no provider-specific permission field — the abstract `permissionMode`
+  *is* Pi's native field. It is read directly by
   `piSdkToolPolicyForPermissionMode` (chat) or `piToolsForPermissionMode`
   (tracked CLI) and becomes a tool allowlist plus an approval-tool list. Chat
   reads the session's own mode rather than the collapsed harness mode, because
   the approval gate makes `default` meaningfully different from `edit` there.
+  The renderer's `summarizeNativeControls` therefore branches on `pi` and
+  writes back only `permissionMode`: the main process deletes
+  `opencodePermissionMode` from a Pi session, so falling through to the
+  OpenCode tail reported whatever that absent field defaulted to and silently
+  downgraded a full-auto Pi chat to edit on the first composer interaction.
 
 The abstract field is persisted alongside the native fields so the UI
 can summarize session state consistently, and so legacy flows that only
diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md
index bf5722b6f..93931aacf 100644
--- a/docs/features/onboarding-and-settings/README.md
+++ b/docs/features/onboarding-and-settings/README.md
@@ -482,19 +482,40 @@ Renderer — settings:
   `updateConfig`). When the OpenCode provider inventory is served from the
   persisted disk cache on a cold start, `opencodeProvidersStale` renders an
   italic "updating…" hint until the first live probe. When the OpenCode
-  binary is missing the group collapses to an install card. The **Pi** card
-  additionally hosts in-app sign-in (`PiSignIn`): one merged tile per provider
-  — configured status, signable methods, or both, keyed by provider id so a
-  signable configured provider is one row rather than two. A method button
-  starts `piLoginStart`, and the flow card then renders whatever Pi asks for
-  live off `onPiAuthStatus`: an auth URL with an Open button, a device code with
-  a Copy control, a free-text or password field, or a labelled choice group.
-  Auth URLs and device codes stay pinned while plain progress lines replace each
-  other. Cancel is a distinct outcome from failure, and only a failure offers
-  **Try again** (retrying with the method the user actually pressed). Pi's own
-  terminal `/login` is offered beside the in-app flow, not behind a reveal, and
-  becomes the whole surface when `sdkAvailable` is false. Nothing typed into a
-  Pi prompt is stored by ADE — see
+  binary is missing the group collapses to an install card. The **Pi** card is a
+  `CollapsibleProviderCard` (readiness tone, version, CLI path, and Open
+  `settings.json` / `auth.json` / `models.json` shortcuts) whose body is
+  `PiProvidersPanel.tsx`.
+- `apps/desktop/src/renderer/components/settings/PiProvidersPanel.tsx`
+  — Pi's half of Settings → Providers: the sign-in flow and the provider
+  catalog. Its own module because Pi's catalog is the size of OpenCode's and the
+  two together made `ProvidersSection.tsx` unreadable. A running sign-in owns
+  the surface — `PiSignInFlowCard` renders whatever Pi asks for live off
+  `onPiAuthStatus` (an auth URL, which is opened automatically and left on
+  screen for a blocked browser; a device code with a Copy control; a free-text
+  or password field; a labelled choice group), and the catalog steps aside while
+  it runs, because starting a second sign-in supersedes the first. Auth URLs and
+  device codes stay pinned while plain progress lines replace each other. Cancel
+  is a distinct outcome from failure, and only a failure offers **Try again**
+  (retrying with the method the user actually pressed); leaving Settings no
+  longer cancels anything. With no flow on screen, `PiProviderBrowser` shows
+  **Connected** first, then **All providers** behind a search field (a "Popular"
+  grid — everything with an interactive sign-in, most models first — until the
+  user types), then **Local Model Servers**. A tile opens
+  `PiProviderDetailModal.tsx`, the provider's own page: the models it
+  contributes and how it can be signed into; starting a sign-in closes the
+  dialog so the device code and prompts survive it. Local servers are a separate
+  section with **no sign-in at all** — their login options are dropped rather
+  than hidden, which is what put an API-key prompt in front of LM Studio — and
+  their status comes from ADE's live endpoint probe (Running / Load a model /
+  Not detected), never from the presence of a `models.json` entry; ADE probes
+  only Ollama and LM Studio, so any other loopback server falls back to
+  "Configured in Pi". When `sdkAvailable` is false the panel collapses to a
+  stated instruction to run `pi` and use its `/login` — ADE does not automate
+  it. Row shape and the questions asked of it live in `piProviderRow.ts`; the
+  grid/tile/search/dialog primitives shared with the OpenCode half live in
+  `providerSectionPrimitives.tsx`. Nothing typed into a Pi prompt is stored by
+  ADE — see
   [Agent Routing › Pi sign-in](../chat/agent-routing.md#pi-sign-in).
 - `apps/desktop/src/renderer/components/settings/OAuthConnectModal.tsx`
   — subscription OAuth connect dialog for OpenCode providers. Runs a
diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md
index 4aa0d3184..1f352098a 100644
--- a/docs/features/terminals-and-sessions/README.md
+++ b/docs/features/terminals-and-sessions/README.md
@@ -155,6 +155,13 @@ and in tests.
   collector coalescing/timeout/bounds and role-classification tests.
 - `apps/desktop/src/main/services/pty/ptyService.test.ts` — PTY behavior
   tests. Branch updated.
+- `apps/desktop/src/main/services/chat/piSessionStore.ts`,
+  `piSessionLease.ts`, `piSessionOwnership.ts` — the native Pi session store
+  shared by ADE chat, tracked `pi` terminals, and external-session discovery:
+  store resolution and file authorization, the `.ade-lease` live-writer lock,
+  and the durable `.ade-owner` claim. They live under `services/chat/` because
+  chat is the other writer, but `ptyService` is a first-class consumer — see
+  [Pi session tracking](pty-and-sessions.md#pi-session-tracking).
 - `apps/desktop/src/main/services/sessions/sessionService.ts` — persistence
   layer for `terminal_sessions` rows. CRUD, continuation metadata
   normalization, `reattach`, `reconcileStaleRunningSessions`, and the durable
@@ -1142,7 +1149,7 @@ Renderer surfaces:
 - `apps/desktop/src/shared/cliLaunch.ts` — canonical CLI launch
   payload builder, shared between the desktop renderer Work tab and
   the main-process `syncRemoteCommandService` mobile launcher. Exposes
-  `CliProvider = "claude" | "codex" | "cursor" | "droid" | "opencode"`
+  `CliProvider = "claude" | "codex" | "cursor" | "droid" | "opencode" | "pi"`
   and `LaunchProfile = CliProvider | "shell"`;
   `LAUNCH_PROFILE_TOOL_TYPE` and `LAUNCH_PROFILE_TITLE` map a launch
   profile to the recorded `TerminalToolType` (`cursor-cli`, `droid`,
@@ -1471,7 +1478,7 @@ schema is used for:
 
 - interactive shell PTYs (`toolType = "shell"`)
 - tracked CLI agent terminals (`claude`, `codex`, `cursor-cli`, `droid`,
-  `opencode`)
+  `opencode`, `pi`)
 - agent chat sessions that run through the Claude/Codex/Cursor/Droid/
   OpenCode SDKs rather than a PTY (`claude-chat`, `codex-chat`,
   `opencode-chat`, `cursor-chat`, `droid-chat`)
@@ -1903,6 +1910,12 @@ runtime and agent chat runtime both layer the same identity envs
   directly is only allowed through `sessionService.setResumeCommand` or
   `updateMeta`, both of which re-derive the metadata; target-id refreshes merge
   the current metadata so tracked CLI spawn lineage is not discarded.
+  `defaultResumeCommandForTool("pi")` deliberately returns **null**: `pi
+  --continue` means "the most recent session for this directory", and because
+  ADE chat and the tracked Pi CLI share one native session store that can be
+  another terminal's session or a chat's. Pi is resumed only by a session id
+  ADE captured for that terminal — see
+  [Pi session tracking](pty-and-sessions.md#pi-session-tracking).
 - Transcript output is not dropped at 16 MiB. When the retained physical file
   would cross that ceiling, the PTY pauses when possible and atomically keeps a
   UTF-8-safe recent window targeted near 8 MiB plus output that arrived during
diff --git a/docs/features/terminals-and-sessions/pty-and-sessions.md b/docs/features/terminals-and-sessions/pty-and-sessions.md
index 1fb88c712..4e703977c 100644
--- a/docs/features/terminals-and-sessions/pty-and-sessions.md
+++ b/docs/features/terminals-and-sessions/pty-and-sessions.md
@@ -834,6 +834,73 @@ control the `ade-*` namespace so it never collides with user-chosen
 names. The append is well under PIPE_BUF and safe vs. concurrent
 codex writers.
 
+### Pi session tracking
+
+Pi is the only tracked CLI whose sessions live in a store ADE chat also writes
+to. Both resolve it through `piSessionStoreForEnvironment`
+(`services/chat/piSessionStore.ts`); see
+[Agent Routing › Session store](../chat/agent-routing.md#session-store) for the
+precedence rules and the two sidecar files. Everything below is what sharing
+that store forces on the PTY side.
+
+**Pi is never resumed by "most recent".** `defaultResumeCommandForTool("pi")`
+returns null, `buildTrackedCliResumeLaunchCommand` emits `--session ` or
+starts fresh rather than falling back to `--continue`, and
+`PI_CONTINUATION_FLAG_RE` / `stripPiContinuationArgs` remove `--continue`, `-c`,
+and `-r` (Pi's interactive session *picker*) whenever ADE rebuilds a launch.
+"Most recent session for this directory" can be another terminal's session or an
+ADE chat's, and terminals were silently reopening days-old transcripts that way.
+A user-typed `--continue`/`-c`/`-r` is still resolved to a concrete id *before*
+spawning, so Pi cannot start writing a JSONL before ADE has upgraded its
+creation lease to the adjacent session lease.
+
+**A stored resume target is re-checked, not trusted.**
+`piSessionCouldBelongToTerminal` rejects a pointer at a session created before
+the terminal existed (10-minute grace, matching the adoption window ADE itself
+uses) — a terminal creates its own session, so an older one is somebody else's.
+The launch then logs `pty.pi_resume_target_discarded`, clears **both**
+`resumeCommand` and `resumeMetadata` (clearing only the command is a no-op,
+because `setResumeCommand(null)` re-derives from the metadata), and rebuilds the
+launch through `buildTrackedCliResumeLaunchCommand` with a null target so the
+model/thinking/tool flags survive. A target the user chose explicitly
+(`importedFrom`) is exempt: an imported session is old by definition.
+
+**Candidate selection is exact and ownership-aware.**
+`selectPiStorageSessionCandidate` takes the launch `cwd` and requires the native
+header cwd to match exactly — discovery scopes by containment so a project's
+whole tree stays browsable, but a lane worktree sits inside the primary lane's
+root, so containment would hand one directory's launch another's session. When
+matching against a launch time it scores **creation** time only: a session
+created days ago whose file was appended to a moment ago has a current mtime,
+and scoring on that let a fresh terminal adopt a transcript an ADE chat had just
+written to. Candidates that survive those cheap filters are then checked against
+the sidecars via `isOwnedByAnotherWriter` — `piSessionLeaseIsHeld` (a live
+writer holds it) or `piSessionIsAdoptableByTerminal` (someone else durably owns
+it). Skipping such a session here beats discovering it by failing to acquire the
+lease after the launch has committed, which disposes the terminal.
+
+**Ownership is recorded only once confirmed.** `recordPiSessionOwner` writes the
+durable `.ade-owner` claim on the resume path and, for a fresh launch, only on
+the branch where the terminal wins the race to upgrade its creation lease. The
+sidecar is never deleted, so claiming on the losing branch would permanently
+block every other terminal from that session.
+
+**Launch errors name the real cause.** A latest session that cannot be verified
+inside the authorized root reports either that the repository's
+`.pi/settings.json` redirects Pi's session directory (ADE does not follow a
+checkout's setting — set `PI_CODING_AGENT_SESSION_DIR` for the profile instead)
+or that the specific session id could not be verified in the store root.
+
+**Initial input waits for quiescence, not a clock.** For `toolType === "pi"`
+with an `initialInputDelayMs`, the create flow waits for the PTY to stop
+emitting for that interval (capped at 15 s) instead of sleeping, then logs
+`pty.pi_initial_input_ready`. Pi prints a banner, the skill list, and a
+skill-conflict report before its prompt accepts input, which on a large profile
+runs past any delay worth hard-coding — the first message was being typed into a
+screen that was still painting and vanished. The wait is cancellable through
+`PtyEntry.initialInputCancel`, which disposal calls exactly where every sibling
+path clears `initialInputTimer`.
+
 ### Dispose and orphan disposal
 
 `dispose({ ptyId, sessionId? })` kills the PTY process tree via
diff --git a/docs/playbooks/ship-lane.md b/docs/playbooks/ship-lane.md
index 64c1a5c90..49395db6b 100644
--- a/docs/playbooks/ship-lane.md
+++ b/docs/playbooks/ship-lane.md
@@ -510,7 +510,7 @@ Record in the state file which path was used (`prCreatedVia: "ade" | "gh"`). If
 
 ### 0.4 Post initial bot pings
 
-See Phase 4 rules. Do not ping GitHub Copilot. Add `@greptile` and `@coderabbit`
+See Phase 4 rules. Do not ping GitHub Copilot or `@codex`. Add `@greptile` and `@coderabbit`
 only when the diff touches more than 250 files. Stack mode uses the same rules —
 a stacked layer is reviewed like any other PR, and its review feedback is the
 lane's to address.
@@ -1009,7 +1009,7 @@ git diff --stat
 QUALITY_COMMIT_MESSAGE="ship: iteration 6 (force-finalize, review skipped) — fix $CI_JOBS"
 ```
 
-Post the Phase 4 bot ping (a force-finalize push is a re-push → `@codex review`). Update state:
+Run Phase 4 (a force-finalize push sends a ping only on a >250-file diff). Update state:
 
 ```json
 {
@@ -1043,18 +1043,12 @@ There is no second force-finalize. Iteration 6 is one shot at landing the lane.
 
 ## Phase 4 — Post-push bot pings
 
-Runs after **any** push. Never ping GitHub Copilot. The ping depends on whether
-this is the initial PR push or a later fix iteration:
+Runs after **any** push. **Never ping GitHub Copilot and never ping `@codex`** —
+neither is an expected review signal for this repo, so a ping only adds noise
+and a bot the loop then has to reason about. No push, initial or fix-iteration,
+gets a direct review ping.
 
-- **Initial push** (Phase 0, PR just created): no direct review ping.
-
-- **Subsequent fix-iteration re-pushes:**
-
-```bash
-gh pr comment "$PR_NUMBER" --body "@codex review"
-```
-
-If the PR touches more than 250 files (on any push):
+The one exception, if the PR touches more than 250 files (on any push):
 
 ```bash
 FILE_COUNT=$(gh pr diff "$PR_NUMBER" --name-only | wc -l | tr -d ' ')
@@ -1066,6 +1060,8 @@ fi
 
 These are separate comments (not a single body) so each bot handler parses its own mention reliably.
 
+On a diff of 250 files or fewer, Phase 4 posts nothing and falls straight through to Phase 5.
+
 ---
 
 ## Phase 5 — Bookkeeping + schedule next wake