diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 10284f389..afbbbb48f 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -9,6 +9,8 @@ on: type: string permissions: + actions: read + checks: read contents: read jobs: diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index eb2b89c46..b503117cd 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -18,6 +18,8 @@ on: type: boolean permissions: + actions: read + checks: read contents: write jobs: @@ -34,6 +36,36 @@ jobs: git fetch origin main:refs/remotes/origin/main git merge-base --is-ancestor HEAD refs/remotes/origin/main + - name: Ensure CI passed for release commit + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TARGET_REF: ${{ inputs.target_ref }} + run: | + set -euo pipefail + + ci_pass_json="$( + gh api "repos/$GH_REPO/commits/$TARGET_REF/check-runs" \ + -f per_page=100 \ + --paginate \ + --jq '[.check_runs[] | select(.name == "ci-pass")] | sort_by(.completed_at // "") | last // empty' + )" + + if [ -z "$ci_pass_json" ] || [ "$ci_pass_json" = "null" ]; then + echo "::error::No ci-pass check run was found for $TARGET_REF. Run CI before releasing." + exit 1 + fi + + status="$(printf '%s' "$ci_pass_json" | jq -r '.status // ""')" + conclusion="$(printf '%s' "$ci_pass_json" | jq -r '.conclusion // ""')" + url="$(printf '%s' "$ci_pass_json" | jq -r '.html_url // ""')" + if [ "$status" != "completed" ] || [ "$conclusion" != "success" ]; then + echo "::error::ci-pass for $TARGET_REF is $status/$conclusion. Release packaging requires green CI. $url" + exit 1 + fi + + echo "ci-pass succeeded for $TARGET_REF: $url" + build-mac-release: needs: - verify diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e0c98683..c34da7510 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,8 @@ on: type: string permissions: + actions: read + checks: read contents: write jobs: diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 6faed0c80..8d7026460 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -79,6 +79,7 @@ describe("ADE CLI", () => { if (plan.kind !== "help") return; expect(plan.text).toContain("ade code --socket /tmp/ade.sock"); expect(plan.text).toContain("ade code --require-socket"); + expect(plan.text).toContain("ade code --lane "); expect(plan.text).toContain("Command palette"); }); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index e6153df42..3a5d99831 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -855,6 +855,7 @@ const HELP_BY_COMMAND: Record = { $ ade code --embedded Force the embedded runtime fallback $ ade code --require-socket Fail instead of embedding when no socket exists $ ade code --socket /tmp/ade.sock Attach to a specific runtime socket + $ ade code --lane Launch focused on a specific lane $ ade --project-root code Launch against a specific ADE project Keys: diff --git a/apps/ade-cli/src/multiProjectRpcServer.test.ts b/apps/ade-cli/src/multiProjectRpcServer.test.ts index a35f71d65..07a827335 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.test.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.test.ts @@ -426,4 +426,73 @@ describe("multi-project RPC server", () => { handler.dispose(); }); + + it("can subscribe to project runtime events without replaying buffered history", async () => { + const { projectRoot, registry } = createRegistry(); + const added = registry.add(projectRoot); + const eventBuffer = createEventBuffer(); + eventBuffer.push({ + timestamp: "2026-05-10T00:00:00.000Z", + category: "runtime", + payload: { type: "file_change", event: { path: "old.ts" } }, + }); + const scopeRegistry = { + get: vi.fn(async () => ({ + registryProjectId: added.projectId, + record: added, + runtime: { + eventBuffer, + dispose: vi.fn(), + }, + dispose: vi.fn(), + })), + ensureSyncHost: vi.fn(), + dispose: vi.fn(), + disposeAll: vi.fn(), + } as unknown as ProjectScopeRegistry; + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + projectRegistry: registry, + scopeRegistry, + }); + const notify = vi.fn(); + handler.setNotifier(notify); + + await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: {}, + }); + + const subscribed = await handler({ + jsonrpc: "2.0", + id: 2, + method: "runtimeEvents.subscribe", + params: { + projectId: added.projectId, + category: "runtime", + replay: false, + }, + }) as { subscriptionId: string; hasMore: boolean; nextCursor: number }; + + expect(subscribed.hasMore).toBe(false); + expect(notify).not.toHaveBeenCalled(); + + eventBuffer.push({ + timestamp: "2026-05-10T00:00:01.000Z", + category: "runtime", + payload: { type: "file_change", event: { path: "new.ts" } }, + }); + + expect(notify).toHaveBeenCalledTimes(1); + expect(notify).toHaveBeenCalledWith("runtime/event", expect.objectContaining({ + subscriptionId: subscribed.subscriptionId, + event: expect.objectContaining({ + payload: { type: "file_change", event: { path: "new.ts" } }, + }), + })); + + handler.dispose(); + }); }); diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index dff4ae43f..04eff2f36 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -323,6 +323,7 @@ export function createMultiProjectRpcRequestHandler( } const cursor = readCursor(params.cursor); const limit = readLimit(params.limit); + const replay = params.replay !== false; const scope = await scopeRegistry.get(projectId); const subscriptionId = `runtime-events-${nextSubscriptionId++}`; const shouldForward = (event: BufferedEvent): boolean => @@ -337,15 +338,17 @@ export function createMultiProjectRpcRequestHandler( unsubscribe, }); - const replay = scope.runtime.eventBuffer.drain(cursor, limit); - for (const event of replay.events) { + const replayResult = replay + ? scope.runtime.eventBuffer.drain(cursor, limit) + : { events: [], nextCursor: cursor, hasMore: false }; + for (const event of replayResult.events) { if (shouldForward(event)) emitRuntimeEvent(subscriptionId, projectId, event); } return { subscriptionId, - nextCursor: replay.nextCursor, - hasMore: replay.hasMore, + nextCursor: replayResult.nextCursor, + hasMore: replayResult.hasMore, }; }; diff --git a/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx new file mode 100644 index 000000000..d7f12064c --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx @@ -0,0 +1,228 @@ +import React from "react"; +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "ink-testing-library"; +import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; +import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; +import type { AdeCodeConnection, ProjectLaunchContext } from "../types"; + +const mocks = vi.hoisted(() => ({ + connectToAde: vi.fn(), + startTuiHeartbeat: vi.fn(), + listLanes: vi.fn(), + listChatSessions: vi.fn(), + listTerminalSessions: vi.fn(), + getChatHistory: vi.fn(), + getSlashCommands: vi.fn(), + getAvailableModels: vi.fn(), +})); + +vi.mock("../connection", () => ({ + connectToAde: mocks.connectToAde, +})); + +vi.mock("../heartbeat", () => ({ + startTuiHeartbeat: mocks.startTuiHeartbeat, +})); + +vi.mock("../state", async () => { + const actual = await vi.importActual("../state"); + return { + ...actual, + loadAdeCodeState: () => ({ + lastChatByLane: {}, + lastChatByProjectLane: { "/repo": { "lane-1": "chat-1" } }, + lastLaneByProject: { "/repo": "lane-1" }, + lastLaneId: null, + }), + saveAdeCodeProjectState: vi.fn(), + }; +}); + +vi.mock("../adeApi", async () => { + const actual = await vi.importActual("../adeApi"); + return { + ...actual, + listLanes: mocks.listLanes, + listChatSessions: mocks.listChatSessions, + listTerminalSessions: mocks.listTerminalSessions, + getChatHistory: mocks.getChatHistory, + getSlashCommands: mocks.getSlashCommands, + getAvailableModels: mocks.getAvailableModels, + }; +}); + +import { AdeCodeApp, shouldHydrateRefreshHistory } from "../app"; + +function lane(overrides: Partial = {}): LaneSummary { + return { + id: "lane-1", + name: "main", + laneType: "primary", + baseRef: "main", + branchRef: "main", + worktreePath: "/repo", + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + createdAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +function chat(overrides: Partial = {}): AgentChatSessionSummary { + return { + sessionId: "chat-1", + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + modelId: "openai/gpt-5.5", + status: "active", + startedAt: "2026-01-01T00:00:00.000Z", + endedAt: null, + lastActivityAt: "2026-01-01T00:00:00.000Z", + lastOutputPreview: null, + summary: null, + ...overrides, + }; +} + +function event(sessionId: string, sequence: number, type: string): AgentChatEventEnvelope { + return { + sessionId, + sequence, + timestamp: `2026-01-01T00:00:0${sequence}.000Z`, + event: { type } as AgentChatEventEnvelope["event"], + }; +} + +async function flushAsyncEffects() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("AdeCodeApp polling", () => { + let chatListeners: Set<(event: AgentChatEventEnvelope) => void>; + let connection: AdeCodeConnection; + const project: ProjectLaunchContext = { + launchCwd: "/repo", + projectRoot: "/repo", + workspaceRoot: "/repo", + laneHint: null, + }; + + beforeEach(() => { + vi.useFakeTimers(); + chatListeners = new Set(); + connection = { + mode: "attached", + projectRoot: "/repo", + workspaceRoot: "/repo", + socketPath: "/tmp/ade.sock", + request: vi.fn(), + tool: vi.fn(), + action: vi.fn(), + actionList: vi.fn(), + onChatEvent: vi.fn((callback) => { + chatListeners.add(callback); + return () => { + chatListeners.delete(callback); + }; + }), + subscribeRuntimeEvents: vi.fn(async () => () => {}), + close: vi.fn(async () => {}), + }; + mocks.connectToAde.mockResolvedValue(connection); + mocks.startTuiHeartbeat.mockReturnValue({ stop: vi.fn() }); + mocks.listLanes.mockResolvedValue([lane()]); + mocks.listChatSessions.mockResolvedValue([chat()]); + mocks.listTerminalSessions.mockResolvedValue([]); + mocks.getChatHistory.mockResolvedValue({ + sessionId: "chat-1", + events: [event("chat-1", 1, "user_message")], + truncated: false, + }); + mocks.getSlashCommands.mockResolvedValue([]); + mocks.getAvailableModels.mockResolvedValue([]); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("keeps active polling on summary refreshes without hydrating chat history", async () => { + const instance = render(); + await flushAsyncEffects(); + + expect(mocks.getChatHistory).toHaveBeenCalledTimes(0); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + await flushAsyncEffects(); + + expect(mocks.listChatSessions).toHaveBeenCalledTimes(2); + expect(mocks.getChatHistory).toHaveBeenCalledTimes(0); + + instance.unmount(); + }); + + it("refreshes summaries for background chat events without hydrating active history", async () => { + const instance = render(); + await flushAsyncEffects(); + + expect(chatListeners.size).toBe(1); + expect(mocks.getChatHistory).toHaveBeenCalledTimes(0); + + await act(async () => { + [...chatListeners][0]?.(event("background-chat", 2, "status")); + await Promise.resolve(); + }); + await flushAsyncEffects(); + + expect(mocks.listChatSessions).toHaveBeenCalledTimes(2); + expect(mocks.getChatHistory).toHaveBeenCalledTimes(0); + + instance.unmount(); + }); +}); + +describe("shouldHydrateRefreshHistory", () => { + it("hydrates by default and when a lightweight refresh changes sessions", () => { + expect(shouldHydrateRefreshHistory({ + currentSessionId: "chat-1", + loadedSessionId: "chat-1", + nextSessionId: "chat-1", + })).toBe(true); + expect(shouldHydrateRefreshHistory({ + hydrateHistory: false, + currentSessionId: "chat-1", + loadedSessionId: "chat-1", + nextSessionId: "chat-2", + })).toBe(true); + expect(shouldHydrateRefreshHistory({ + hydrateHistory: false, + currentSessionId: "chat-1", + loadedSessionId: null, + nextSessionId: "chat-1", + })).toBe(true); + }); + + it("skips history for lightweight refreshes of the already-loaded active session", () => { + expect(shouldHydrateRefreshHistory({ + hydrateHistory: false, + currentSessionId: "chat-1", + loadedSessionId: "chat-1", + nextSessionId: "chat-1", + })).toBe(false); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts index bcdbfb181..af322dcf9 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts @@ -221,6 +221,93 @@ describe("connectToAde embedded mode", () => { expect(requests.at(-1)?.params).toMatchObject({ projectId: "project-daemon" }); }); + it("adapts multi-project runtime chat events into the TUI chat stream", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-connection-")); + const socketPath = path.join(tmpDir, "ade.sock"); + const serverSocketRef: { current: net.Socket | null } = { current: null }; + const requests: Array<{ method: string; params?: Record }> = []; + const server = net.createServer((socket) => { + serverSocketRef.current = socket; + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + while (true) { + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (!line) continue; + const request = JSON.parse(line) as { id: number; method: string; params?: Record }; + requests.push({ method: request.method, params: request.params }); + const result = (() => { + if (request.method === "ade/initialize") { + return { + runtimeInfo: { multiProject: true }, + capabilities: { projects: true }, + }; + } + if (request.method === "projects.add") { + return { projectId: "project-daemon", rootPath: project.projectRoot }; + } + if (request.method === "runtimeEvents.subscribe") { + return { subscriptionId: "runtime-sub-1" }; + } + if (request.method === "runtimeEvents.unsubscribe") { + return { removed: true }; + } + return null; + })(); + socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: request.id, result })}\n`); + } + }); + }); + await new Promise((resolve) => server.listen(socketPath, resolve)); + + const connection = await connectToAde({ + project, + socketPath, + }); + try { + const delivered = new Promise((resolve) => { + connection.onChatEvent(resolve); + }); + await vi.waitUntil( + () => requests.some((request) => request.method === "runtimeEvents.subscribe"), + { timeout: 1000 }, + ); + expect(requests.find((request) => request.method === "runtimeEvents.subscribe")?.params) + .toMatchObject({ projectId: "project-daemon", category: "runtime", replay: false }); + + const envelope = { + sessionId: "chat-1", + timestamp: "2026-05-14T00:00:00.000Z", + event: { type: "text", text: "hello from daemon" }, + sequence: 1, + } as AgentChatEventEnvelope; + serverSocketRef.current?.write(`${JSON.stringify({ + jsonrpc: "2.0", + method: "runtime/event", + params: { + subscriptionId: "runtime-sub-1", + projectId: "project-daemon", + event: { + id: 1, + timestamp: "2026-05-14T00:00:00.000Z", + category: "runtime", + payload: envelope, + }, + }, + })}\n`); + + await expect(delivered).resolves.toEqual(envelope); + } finally { + await connection.close(); + serverSocketRef.current?.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("spawns the standalone binary directly when no CLI script entrypoint exists", async () => { const socketPath = useMissingMachineSocket(); const missingEntrypointDir = fs.mkdtempSync( @@ -452,7 +539,9 @@ describe("ade-code TUI state", () => { const { loadAdeCodeState } = await loadStateModule(home); expect(loadAdeCodeState()).toEqual({ lastChatByLane: { "lane-1": "chat-1" }, + lastChatByProjectLane: {}, lastLaneId: null, + lastLaneByProject: {}, }); }); @@ -462,12 +551,16 @@ describe("ade-code TUI state", () => { saveAdeCodeState({ lastChatByLane: { "lane-2": "chat-9" }, + lastChatByProjectLane: {}, lastLaneId: "lane-2", + lastLaneByProject: {}, }); expect(loadAdeCodeState()).toEqual({ lastChatByLane: { "lane-2": "chat-9" }, + lastChatByProjectLane: {}, lastLaneId: "lane-2", + lastLaneByProject: {}, }); }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/project.test.ts b/apps/ade-cli/src/tuiClient/__tests__/project.test.ts index 3e380f253..f56899b7c 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/project.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/project.test.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { chooseInitialLane, chooseMostRecentSessionLane, chooseTuiLaunchLane, resolveTuiChatRefreshTarget } from "../project"; +import { chooseInitialLane, chooseMostRecentSessionLane, chooseTuiLaunchLane, detectProjectLaunchContext, resolveTuiChatRefreshTarget } from "../project"; import type { AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; @@ -42,6 +42,17 @@ function chat(sessionId: string, laneId: string, lastActivityAt: string): AgentC } describe("chooseInitialLane", () => { + it("accepts an explicit lane hint from the CLI launch context", () => { + const context = detectProjectLaunchContext({ + cwd: "/tmp", + projectRoot: "/tmp", + workspaceRoot: "/tmp", + laneHint: "feature-a", + }); + + expect(context.laneHint).toBe("feature-a"); + }); + it("prefers the ADE worktree lane hint", () => { const lanes = [ lane({ id: "main", name: "main", laneType: "primary", worktreePath: "/repo" }), diff --git a/apps/ade-cli/src/tuiClient/__tests__/state.test.ts b/apps/ade-cli/src/tuiClient/__tests__/state.test.ts new file mode 100644 index 000000000..1c70fd560 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/state.test.ts @@ -0,0 +1,96 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadAdeCodeState, normalizeAdeCodeState, saveAdeCodeProjectState, scopedAdeCodeState } from "../state"; + +afterEach(() => { + delete process.env.ADE_CODE_STATE_DIR; +}); + +describe("ade code persisted state", () => { + it("prefers project-scoped lane and chat state over legacy global fallback", () => { + const state = normalizeAdeCodeState({ + lastChatByLane: { main: "legacy-chat" }, + lastLaneId: "legacy-lane", + lastChatByProjectLane: { + "/repo-a": { main: "repo-a-chat" }, + "/repo-b": { main: "repo-b-chat" }, + }, + lastLaneByProject: { + "/repo-a": "repo-a-lane", + "/repo-b": "repo-b-lane", + }, + }); + + expect(scopedAdeCodeState(state, "/repo-b")).toEqual({ + lastChatByLane: { main: "repo-b-chat" }, + lastLaneId: "repo-b-lane", + }); + }); + + it("uses legacy state as a migration fallback for projects without scoped entries", () => { + const state = normalizeAdeCodeState({ + lastChatByLane: { main: "legacy-chat" }, + lastLaneId: "legacy-lane", + lastChatByProjectLane: { + "/repo-a": { main: "repo-a-chat" }, + }, + lastLaneByProject: { + "/repo-a": "repo-a-lane", + }, + }); + + expect(scopedAdeCodeState(state, "/repo-b")).toEqual({ + lastChatByLane: { main: "legacy-chat" }, + lastLaneId: "legacy-lane", + }); + }); + + it("ignores malformed persisted records", () => { + const state = normalizeAdeCodeState({ + lastChatByLane: { main: "legacy-chat", bad: 1 }, + lastLaneId: 7, + lastChatByProjectLane: { + "/repo-a": { main: "repo-a-chat", bad: false }, + "/empty": { bad: false }, + }, + lastLaneByProject: { + "/repo-a": "repo-a-lane", + "/repo-b": null, + }, + }); + + expect(state).toEqual({ + lastChatByLane: { main: "legacy-chat" }, + lastChatByProjectLane: { "/repo-a": { main: "repo-a-chat" } }, + lastLaneId: null, + lastLaneByProject: { "/repo-a": "repo-a-lane" }, + }); + }); + + it("merges project-scoped saves with existing state under the shared state file", () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-state-")); + process.env.ADE_CODE_STATE_DIR = stateDir; + + saveAdeCodeProjectState("/repo-a", { + lastChatByLane: { main: "repo-a-chat" }, + lastLaneId: "repo-a-lane", + }); + saveAdeCodeProjectState("/repo-b", { + lastChatByLane: { main: "repo-b-chat" }, + lastLaneId: "repo-b-lane", + }); + + const persisted = loadAdeCodeState(); + expect(persisted.lastChatByProjectLane).toEqual({ + [path.resolve("/repo-a")]: { main: "repo-a-chat" }, + [path.resolve("/repo-b")]: { main: "repo-b-chat" }, + }); + expect(persisted.lastLaneByProject).toEqual({ + [path.resolve("/repo-a")]: "repo-a-lane", + [path.resolve("/repo-b")]: "repo-b-lane", + }); + expect(fs.existsSync(path.join(stateDir, "ade-code-state.json.lock"))).toBe(false); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index b58a0170e..ffebb7b6c 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -107,7 +107,7 @@ import { latestExpandableFailureId, renderObject, summarizeDiffChanges } from ". import { startTuiHeartbeat, type TuiHeartbeat } from "./heartbeat"; import { isImageFilePath, latestOpenableImageTarget, readClipboardImageAttachment, readImageDimensions } from "./imageTargets"; import { appendReservedTuiEvent, reserveTuiEventDedupKey, syncTuiEventDedupKeys } from "./eventDedup"; -import { loadAdeCodeState, saveAdeCodeState } from "./state"; +import { loadAdeCodeState, saveAdeCodeProjectState, scopedAdeCodeState } from "./state"; import { SpinTickProvider } from "./spinTick"; import { buildLinearToolRequest } from "./linearCommands"; import { @@ -209,6 +209,21 @@ type AdeCodeAppProps = { socketPath?: string | null; }; +type RefreshStateOptions = { + hydrateHistory?: boolean; +}; + +export function shouldHydrateRefreshHistory(args: { + hydrateHistory?: boolean; + currentSessionId: string | null; + loadedSessionId: string | null; + nextSessionId: string; +}): boolean { + return args.hydrateHistory !== false + || args.currentSessionId !== args.nextSessionId + || args.loadedSessionId !== args.nextSessionId; +} + function initialModelState(): AdeCodeModelState { const descriptor = getDefaultModelDescriptor("codex"); return { @@ -1668,6 +1683,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const eventCountRef = useRef(0); const eventDedupKeysRef = useRef>(new Set()); const eventDedupKeyOrderRef = useRef([]); + const refreshGenerationRef = useRef(0); const chatScrollOffsetRowsRef = useRef(0); const chatScrollMaxOffsetRef = useRef(0); const lastSeenAtBottomEventCountRef = useRef(0); @@ -1676,7 +1692,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const draftSeededFromHistoryRef = useRef(false); const initialNewChatPreviewRef = useRef(true); const attachProbeInFlightRef = useRef(false); - const [initialAdeCodeState] = useState(loadAdeCodeState); + const [initialAdeCodeState] = useState(() => scopedAdeCodeState(loadAdeCodeState(), project.projectRoot)); const lastChatByLaneRef = useRef>(new Map(Object.entries(initialAdeCodeState.lastChatByLane))); const lastLaneIdRef = useRef(initialAdeCodeState.lastLaneId); const lastChatByLaneWriteTimerRef = useRef(null); @@ -1705,9 +1721,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } for (const [laneId, sessionId] of lastChatByLaneRef.current) { lastChatByLane[laneId] = sessionId; } - saveAdeCodeState({ lastChatByLane, lastLaneId: lastLaneIdRef.current }); + saveAdeCodeProjectState(project.projectRoot, { lastChatByLane, lastLaneId: lastLaneIdRef.current }); }, 500); - }, []); + }, [project.projectRoot]); const setChatScrollOffset = useCallback((value: number | ((previous: number) => number)) => { setChatScrollOffsetRows((previous) => { @@ -2240,6 +2256,19 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } activeTerminalSessionRef.current = activeTerminalSession; }, [activeTerminalSession]); + useEffect(() => { + eventCountRef.current = events.length; + if (!activeSessionId || activeTerminalSession) return; + const fallbackContext = activeSession?.modelId + ? getModelById(activeSession.modelId)?.contextWindow ?? null + : null; + const stats = latestTokenStats(events, fallbackContext); + setCurrentGoal(latestGoal(events)); + setContextPercent(stats.percent); + setTokenSummary(formatTokenSummary(stats)); + setStatusLineStats(stats); + }, [activeSession?.modelId, activeSessionId, activeTerminalSession, events]); + useEffect(() => { terminalSessionsRef.current = terminalSessions; }, [terminalSessions]); @@ -2854,6 +2883,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } lastUserOpenedPaneRef.current = null; eventDedupKeysRef.current.clear(); eventDedupKeyOrderRef.current = []; + eventCountRef.current = 0; setEvents([]); setClearedAt(null); chatDraftRef.current = ""; @@ -2992,9 +3022,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }; }, [activeMentionRange, displaySessions, lanes, selectedMentions]); - const refreshState = useCallback(async () => { + const refreshState = useCallback(async (options: RefreshStateOptions = {}) => { const conn = connectionRef.current; if (!conn) return; + const generation = refreshGenerationRef.current + 1; + refreshGenerationRef.current = generation; + const isCurrentRefresh = () => + refreshGenerationRef.current === generation && connectionRef.current === conn; const nextLanes = await listLanes(conn); const nextSessions = await listChatSessions(conn); const nextTerminalSessions = await listTerminalSessions(conn).catch(() => []); @@ -3026,22 +3060,32 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (previewMode) { newChatPreviewLaneIdRef.current = nextLaneId; } - let nextEvents: AgentChatEventEnvelope[] = []; + let nextEvents: AgentChatEventEnvelope[] | null = null; if (nextSessionId && !nextTerminalSession) { - const history = await getChatHistory(conn, nextSessionId); - setCurrentGoal(latestGoal(history.events)); - nextEvents = clearedAt - ? history.events.filter((event) => event.timestamp > clearedAt) - : history.events; - const activeModelId = nextSession?.modelId ?? null; - const fallbackContext = activeModelId ? getModelById(activeModelId)?.contextWindow ?? null : null; - const stats = latestTokenStats(history.events, fallbackContext); - setContextPercent(stats.percent); - setTokenSummary(formatTokenSummary(stats)); - setStatusLineStats(stats); + const shouldHydrateHistory = shouldHydrateRefreshHistory({ + hydrateHistory: options.hydrateHistory, + currentSessionId: activeSessionIdRef.current, + loadedSessionId: loadedSessionIdRef.current, + nextSessionId, + }); + if (shouldHydrateHistory) { + const history = await getChatHistory(conn, nextSessionId); + if (!isCurrentRefresh()) return; + setCurrentGoal(latestGoal(history.events)); + nextEvents = clearedAt + ? history.events.filter((event) => event.timestamp > clearedAt) + : history.events; + const activeModelId = nextSession?.modelId ?? null; + const fallbackContext = activeModelId ? getModelById(activeModelId)?.contextWindow ?? null : null; + const stats = latestTokenStats(history.events, fallbackContext); + setContextPercent(stats.percent); + setTokenSummary(formatTokenSummary(stats)); + setStatusLineStats(stats); + eventCountRef.current = history.events.length; + loadedSessionIdRef.current = nextSessionId; + } setStreaming(nextSession?.status === "active"); if (nextSession?.status === "active") setInterrupted(false); - eventCountRef.current = history.events.length; } else { setContextPercent(null); setTokenSummary(null); @@ -3050,6 +3094,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setStreaming(false); setInterrupted(false); eventCountRef.current = 0; + loadedSessionIdRef.current = null; + nextEvents = []; } const configSession = nextTerminalSession ? null : nextSession ?? (!draftSeededFromHistoryRef.current ? seedSession : null); const nextProvider = terminalSessionProvider(nextTerminalSession) ?? configSession?.provider ?? modelState.provider ?? "codex"; @@ -3060,6 +3106,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ? { laneId: nextLaneId, provider: nextProvider } : null; const remoteCommands = commandArgs ? await getSlashCommands(conn, commandArgs).catch(() => []) : []; + if (!isCurrentRefresh()) return; const projectCommands = discoverProjectSlashCommands(nextLane?.worktreePath || project.workspaceRoot); const nextCommands = remoteCommands.length ? remoteCommands : projectCommands; const provider = normalizeProvider(nextProvider); @@ -3081,8 +3128,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } selectActiveLaneId(nextLaneId); selectActiveSessionId(nextSessionId); - eventDedupKeyOrderRef.current = syncTuiEventDedupKeys(eventDedupKeysRef.current, nextEvents); - setEvents(nextEvents); + if (nextEvents !== null) { + eventDedupKeyOrderRef.current = syncTuiEventDedupKeys(eventDedupKeysRef.current, nextEvents); + setEvents(nextEvents); + } setSlashCommands(nextCommands); setModels(nextModels); if (launchToNewChatPreview) { @@ -3260,6 +3309,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } selectActiveSessionId(null); eventDedupKeysRef.current.clear(); eventDedupKeyOrderRef.current = []; + eventCountRef.current = 0; setEvents([]); await refreshState(); } catch (err) { @@ -3279,7 +3329,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } for (const [laneId, sessionId] of lastChatByLaneRef.current) { lastChatByLane[laneId] = sessionId; } - saveAdeCodeState({ lastChatByLane, lastLaneId: lastLaneIdRef.current }); + saveAdeCodeProjectState(project.projectRoot, { lastChatByLane, lastLaneId: lastLaneIdRef.current }); } if (pendingModelCommitTimerRef.current) { clearTimeout(pendingModelCommitTimerRef.current); @@ -3296,16 +3346,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (!connection) return; return connection.onChatEvent((envelope) => { if (envelope.sessionId !== activeSessionIdRef.current) { - void refreshState().catch(() => undefined); + void refreshState({ hydrateHistory: false }).catch(() => undefined); return; } if (clearedAt && envelope.timestamp <= clearedAt) return; const event = envelope.event as Record; - if (event.type === "codex_goal_updated") { - setCurrentGoal((event as { goal?: CodexThreadGoal | null }).goal ?? null); - } else if (event.type === "codex_goal_cleared") { - setCurrentGoal(null); - } const reservedKey = reserveTuiEventDedupKey(envelope, eventDedupKeysRef.current); if (reservedKey !== null) { setEvents((prev) => { @@ -3317,6 +3362,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } reservedKey, ); eventDedupKeyOrderRef.current = next.eventKeys; + eventCountRef.current = next.events.length; return next.events; }); } @@ -3360,7 +3406,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (!connection) return; let disposed = false; let unsubscribe: (() => void) | null = null; - void connection.subscribeRuntimeEvents({ category: "runtime", cursor: 0, limit: 50 }, (event) => { + void connection.subscribeRuntimeEvents({ category: "runtime", cursor: 0, limit: 50, replay: false }, (event) => { const payload = event.payload as { type?: unknown; event?: unknown }; const terminalEvent = payload.event as { sessionId?: unknown; data?: unknown } | undefined; const sessionId = typeof terminalEvent?.sessionId === "string" ? terminalEvent.sessionId : null; @@ -3373,7 +3419,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (payload.type === "pty_exit") { - void refreshState().catch(() => undefined); + void refreshState({ hydrateHistory: false }).catch(() => undefined); } }).then((stop) => { if (disposed) { @@ -3394,8 +3440,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (loadedSessionIdRef.current === activeSessionId) return; - loadedSessionIdRef.current = activeSessionId; - void refreshState().catch((err) => { + void refreshState({ hydrateHistory: true }).catch((err) => { setError(err instanceof Error ? err.message : String(err)); }); }, [activeSessionId, connection, refreshState]); @@ -3408,7 +3453,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (!connection) return; const intervalMs = chatRefreshPollActive ? 1_000 : 15_000; const timer = setInterval(() => { - void refreshState().catch((err) => { + void refreshState({ hydrateHistory: false }).catch((err) => { setError(err instanceof Error ? err.message : String(err)); }); }, intervalMs); @@ -4373,6 +4418,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setClearedAt(new Date().toISOString()); eventDedupKeysRef.current.clear(); eventDedupKeyOrderRef.current = []; + eventCountRef.current = 0; setEvents([]); setChatScrollOffset(0); addNotice("Local transcript view cleared. The durable chat remains in ADE.", "info"); @@ -5280,6 +5326,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setClearedAt(new Date().toISOString()); eventDedupKeysRef.current.clear(); eventDedupKeyOrderRef.current = []; + eventCountRef.current = 0; setEvents([]); setChatScrollOffset(0); addNotice("Cleared local transcript view.", "success"); @@ -5835,6 +5882,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setClearedAt(new Date().toISOString()); eventDedupKeysRef.current.clear(); eventDedupKeyOrderRef.current = []; + eventCountRef.current = 0; setEvents([]); setChatScrollOffset(0); addNotice("Viewport cleared. Durable chat history is unchanged.", "info"); diff --git a/apps/ade-cli/src/tuiClient/cli.tsx b/apps/ade-cli/src/tuiClient/cli.tsx index 5087c2ac0..7ec25e3e8 100644 --- a/apps/ade-cli/src/tuiClient/cli.tsx +++ b/apps/ade-cli/src/tuiClient/cli.tsx @@ -10,6 +10,7 @@ type CliOptions = { requireSocket: boolean; projectRoot: string | null; workspaceRoot: string | null; + laneHint: string | null; socketPath: string | null; }; @@ -21,6 +22,7 @@ function parseArgs(argv: string[]): CliOptions { requireSocket: false, projectRoot: null, workspaceRoot: null, + laneHint: null, socketPath: null, }; for (let i = 0; i < argv.length; i += 1) { @@ -31,6 +33,7 @@ function parseArgs(argv: string[]): CliOptions { else if (arg === "--require-socket") options.requireSocket = true; else if (arg === "--project-root") options.projectRoot = argv[++i] ?? null; else if (arg === "--workspace-root") options.workspaceRoot = argv[++i] ?? null; + else if (arg === "--lane") options.laneHint = argv[++i] ?? null; else if (arg === "--socket") options.socketPath = argv[++i] ?? null; } return options; @@ -42,7 +45,7 @@ function printHelp(): void { Terminal-native ADE Work chat. Usage: - ade code [--project-root ] [--workspace-root ] [--socket ] + ade code [--project-root ] [--workspace-root ] [--lane ] [--socket ] ade code --embedded ade code --require-socket ade code --print-state @@ -87,6 +90,7 @@ async function printState(options: CliOptions): Promise { const project = detectProjectLaunchContext({ projectRoot: options.projectRoot, workspaceRoot: options.workspaceRoot, + laneHint: options.laneHint, }); const connection = await connectToAde({ project, @@ -126,6 +130,7 @@ export async function runAdeCodeCli(argv: string[] = process.argv.slice(2)): Pro const project = detectProjectLaunchContext({ projectRoot: options.projectRoot, workspaceRoot: options.workspaceRoot, + laneHint: options.laneHint, }); const instance = render( ("projects.add", { rootPath: args.project.projectRoot, }); @@ -382,10 +397,54 @@ async function connectAttachedSocket(args: { socketPath: args.socketPath, request, ...createAdeActionHelpers(request), - onChatEvent: (callback: (event: AgentChatEventEnvelope) => void) => - attachedClient.onNotification("chat/event", (params) => + onChatEvent: (callback: (event: AgentChatEventEnvelope) => void) => { + const stopChatNotification = attachedClient.onNotification("chat/event", (params) => callback(params as AgentChatEventEnvelope), - ), + ); + if (!multiProjectRuntime) return stopChatNotification; + + let disposed = false; + let subscriptionId: string | null = null; + const pending: RuntimeEventNotification[] = []; + const stopRuntimeNotification = attachedClient.onNotification("runtime/event", (params) => { + const payload = params as RuntimeEventNotification; + if (!isBufferedEvent(payload.event)) return; + if (!subscriptionId) { + pending.push(payload); + return; + } + if (payload.subscriptionId !== subscriptionId) return; + const envelope = runtimeChatEnvelope(payload.event); + if (envelope) callback(envelope); + }); + request<{ subscriptionId: string }>("runtimeEvents.subscribe", { + category: "runtime", + cursor: 0, + limit: 100, + replay: false, + }).then((response) => { + if (disposed) { + request("runtimeEvents.unsubscribe", { subscriptionId: response.subscriptionId }).catch(() => {}); + return; + } + subscriptionId = response.subscriptionId; + for (const payload of pending.splice(0)) { + if (payload.subscriptionId !== subscriptionId || !isBufferedEvent(payload.event)) continue; + const envelope = runtimeChatEnvelope(payload.event); + if (envelope) callback(envelope); + } + }).catch(() => { + stopRuntimeNotification(); + }); + return () => { + disposed = true; + stopChatNotification(); + stopRuntimeNotification(); + if (subscriptionId) { + request("runtimeEvents.unsubscribe", { subscriptionId }).catch(() => {}); + } + }; + }, subscribeRuntimeEvents: async (subscriptionArgs, callback) => { let subscriptionId: string | null = null; const pending: PendingRuntimeEvent[] = []; @@ -403,6 +462,7 @@ async function connectAttachedSocket(args: { category: subscriptionArgs.category ?? "runtime", cursor: subscriptionArgs.cursor ?? 0, limit: subscriptionArgs.limit ?? 100, + replay: subscriptionArgs.replay, }); subscriptionId = response.subscriptionId; for (const payload of pending) { @@ -596,7 +656,7 @@ export async function connectToAde(args: { const eventBuffer = runtime.eventBuffer; if (!eventBuffer) return () => {}; const shouldForward = (event: BufferedEvent) => !category || event.category === category; - const replay = typeof eventBuffer.drain === "function" + const replay = subscriptionArgs.replay !== false && typeof eventBuffer.drain === "function" ? eventBuffer.drain(subscriptionArgs.cursor ?? 0, subscriptionArgs.limit ?? 100) : { events: [] }; for (const event of replay.events) { diff --git a/apps/ade-cli/src/tuiClient/project.ts b/apps/ade-cli/src/tuiClient/project.ts index 192154de7..bab465f86 100644 --- a/apps/ade-cli/src/tuiClient/project.ts +++ b/apps/ade-cli/src/tuiClient/project.ts @@ -45,6 +45,7 @@ export function detectProjectLaunchContext(args: { cwd?: string; projectRoot?: string | null; workspaceRoot?: string | null; + laneHint?: string | null; } = {}): ProjectLaunchContext { const launchCwd = normalizeRoot(args.cwd ?? process.cwd()); const explicitProjectRoot = args.projectRoot?.trim(); @@ -76,7 +77,7 @@ export function detectProjectLaunchContext(args: { launchCwd, projectRoot, workspaceRoot, - laneHint: worktree?.laneHint ?? null, + laneHint: args.laneHint?.trim() || worktree?.laneHint || null, }; } diff --git a/apps/ade-cli/src/tuiClient/state.ts b/apps/ade-cli/src/tuiClient/state.ts index 829eab72b..1508fbb08 100644 --- a/apps/ade-cli/src/tuiClient/state.ts +++ b/apps/ade-cli/src/tuiClient/state.ts @@ -4,38 +4,180 @@ import path from "node:path"; export type AdeCodeState = { lastChatByLane: Record; + lastChatByProjectLane: Record>; lastLaneId: string | null; + lastLaneByProject: Record; }; const STATE_DIR = path.join(os.homedir(), ".ade"); -const STATE_PATH = path.join(STATE_DIR, "ade-code-state.json"); +const STATE_FILE = "ade-code-state.json"; +const STATE_LOCK_FILE = `${STATE_FILE}.lock`; +const STATE_LOCK_TIMEOUT_MS = 2_000; +const STATE_LOCK_STALE_MS = 30_000; +const STATE_LOCK_RETRY_MS = 25; export function loadAdeCodeState(): AdeCodeState { try { - const raw = fs.readFileSync(STATE_PATH, "utf8"); - const parsed = JSON.parse(raw) as Partial; - const lastChatByLane: Record = {}; - if (parsed && typeof parsed.lastChatByLane === "object" && parsed.lastChatByLane) { - for (const [laneId, sessionId] of Object.entries(parsed.lastChatByLane)) { - if (typeof laneId === "string" && typeof sessionId === "string") { - lastChatByLane[laneId] = sessionId; - } - } - } - return { - lastChatByLane, - lastLaneId: typeof parsed.lastLaneId === "string" ? parsed.lastLaneId : null, - }; + const raw = fs.readFileSync(getStatePath(), "utf8"); + return normalizeAdeCodeState(JSON.parse(raw)); } catch { - return { lastChatByLane: {}, lastLaneId: null }; + return emptyAdeCodeState(); } } export function saveAdeCodeState(state: AdeCodeState): void { + withStateLock(() => writeAdeCodeStateUnlocked(state)); +} + +function writeAdeCodeStateUnlocked(state: AdeCodeState): void { try { - fs.mkdirSync(STATE_DIR, { recursive: true }); - fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2), "utf8"); + const { dir, path: statePath } = getStatePaths(); + fs.mkdirSync(dir, { recursive: true }); + const tempPath = `${statePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(state, null, 2), "utf8"); + fs.renameSync(tempPath, statePath); } catch { // best-effort persistence; ignore } } + +export function scopedAdeCodeState( + state: AdeCodeState, + projectRoot: string, +): Pick { + const projectKey = normalizeProjectKey(projectRoot); + return { + lastChatByLane: state.lastChatByProjectLane[projectKey] ?? state.lastChatByLane, + lastLaneId: state.lastLaneByProject[projectKey] ?? state.lastLaneId, + }; +} + +export function saveAdeCodeProjectState( + projectRoot: string, + projectState: Pick, +): void { + withStateLock(() => { + const current = loadAdeCodeState(); + const projectKey = normalizeProjectKey(projectRoot); + current.lastChatByProjectLane[projectKey] = { ...projectState.lastChatByLane }; + if (projectState.lastLaneId) { + current.lastLaneByProject[projectKey] = projectState.lastLaneId; + } else { + delete current.lastLaneByProject[projectKey]; + } + current.lastChatByLane = { ...projectState.lastChatByLane }; + current.lastLaneId = projectState.lastLaneId; + writeAdeCodeStateUnlocked(current); + }); +} + +export function normalizeAdeCodeState(value: unknown): AdeCodeState { + const parsed = value && typeof value === "object" && !Array.isArray(value) + ? value as Partial + : {}; + return { + lastChatByLane: normalizeStringRecord(parsed.lastChatByLane), + lastChatByProjectLane: normalizeNestedStringRecord(parsed.lastChatByProjectLane), + lastLaneId: typeof parsed.lastLaneId === "string" ? parsed.lastLaneId : null, + lastLaneByProject: normalizeStringRecord(parsed.lastLaneByProject), + }; +} + +function emptyAdeCodeState(): AdeCodeState { + return { + lastChatByLane: {}, + lastChatByProjectLane: {}, + lastLaneId: null, + lastLaneByProject: {}, + }; +} + +function normalizeProjectKey(projectRoot: string): string { + return path.resolve(projectRoot); +} + +function getStatePaths(): { dir: string; path: string; lockPath: string } { + const dir = process.env.ADE_CODE_STATE_DIR || STATE_DIR; + return { + dir, + path: path.join(dir, STATE_FILE), + lockPath: path.join(dir, STATE_LOCK_FILE), + }; +} + +function getStatePath(): string { + return getStatePaths().path; +} + +function withStateLock(write: () => void): void { + const { dir, lockPath } = getStatePaths(); + let fd: number | null = null; + const deadline = Date.now() + STATE_LOCK_TIMEOUT_MS; + try { + fs.mkdirSync(dir, { recursive: true }); + while (fd === null) { + try { + fd = fs.openSync(lockPath, "wx"); + fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST") throw error; + removeStaleStateLock(lockPath); + if (Date.now() >= deadline) return; + sleepSync(STATE_LOCK_RETRY_MS); + } + } + write(); + } catch { + // best-effort persistence; ignore + } finally { + if (fd !== null) { + try { + fs.closeSync(fd); + } catch { + // ignore + } + try { + fs.unlinkSync(lockPath); + } catch { + // ignore + } + } + } +} + +function removeStaleStateLock(lockPath: string): void { + try { + const stat = fs.statSync(lockPath); + if (Date.now() - stat.mtimeMs > STATE_LOCK_STALE_MS) { + fs.unlinkSync(lockPath); + } + } catch { + // ignore + } +} + +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function normalizeStringRecord(value: unknown): Record { + const normalized: Record = {}; + if (!value || typeof value !== "object" || Array.isArray(value)) return normalized; + for (const [key, entry] of Object.entries(value)) { + if (typeof key === "string" && typeof entry === "string") { + normalized[key] = entry; + } + } + return normalized; +} + +function normalizeNestedStringRecord(value: unknown): Record> { + const normalized: Record> = {}; + if (!value || typeof value !== "object" || Array.isArray(value)) return normalized; + for (const [key, entry] of Object.entries(value)) { + const child = normalizeStringRecord(entry); + if (Object.keys(child).length > 0) normalized[key] = child; + } + return normalized; +} diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index 29ca23b60..cbee23cf9 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -54,7 +54,7 @@ export type AdeCodeConnection = { actionList(domain: string, action: string, argsList: unknown[]): Promise; onChatEvent(callback: (event: AgentChatEventEnvelope) => void): () => void; subscribeRuntimeEvents( - args: { category?: BufferedEvent["category"] | null; cursor?: number; limit?: number }, + args: { category?: BufferedEvent["category"] | null; cursor?: number; limit?: number; replay?: boolean }, callback: (event: BufferedEvent) => void, ): Promise<() => void>; close(): Promise; diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a2ad7c074..dddf4e406 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -228,6 +228,13 @@ "filter": [ "**/*" ] + }, + { + "from": "resources/agent-skills", + "to": "agent-skills", + "filter": [ + "**/*" + ] } ], "afterPack": "./scripts/after-pack-runtime-fixes.cjs", diff --git a/apps/desktop/resources/ade-cli-help.txt b/apps/desktop/resources/ade-cli-help.txt index 310a97705..87be9fcf6 100644 --- a/apps/desktop/resources/ade-cli-help.txt +++ b/apps/desktop/resources/ade-cli-help.txt @@ -10,17 +10,27 @@ _ ____ _____ Agent-focused command-line interface for ADE. - ADE CLI commands operate on the same project database and live desktop socket - used by the ADE app. By default the CLI connects to the app socket when it is - running; otherwise it falls back to a headless runtime for local-safe actions. + ADE CLI commands operate through the machine ADE runtime daemon by default. + If the daemon is not running, the CLI starts it, registers the selected + project, and routes project actions through that runtime. $ ade help Display help for a command $ ade auth status Check local ADE CLI readiness + $ ade code Open ADE Work chat in the terminal + $ ade desktop Launch the installed desktop app + $ ade runtime start | stop | status Manage the machine runtime daemon + $ ade serve Run the ADE runtime daemon in foreground + $ ade rpc --stdio Speak ADE JSON-RPC over stdin/stdout + $ ade init [path] Register a project with this machine runtime + $ ade projects list List projects registered on this machine + $ ade sync status | pin generate Manage machine sync and phone pairing $ ade doctor Inspect project, socket, runtime, and tool availability $ ade lanes list | show | create | child Work with lanes and lane stacks $ ade git status | commit | push | stash Run ADE-aware git operations - $ ade diff changes | file Inspect lane diffs + $ ade operations status | wait Poll operation/test/chat/run/mission status + $ ade diff changes | file | patch Inspect lane diffs (including raw git patch text) $ ade files tree | read | write | search Read and edit lane workspaces + $ ade missions launch | watch | graph Create, start, and inspect mission runs $ ade prs list | create | path-to-merge Manage PRs, queues, and Path to Merge repair rounds $ ade run defs | ps | start | logs Manage Run tab process definitions and runtime $ ade shell start | write | resize | close Launch and control tracked shell sessions @@ -33,21 +43,23 @@ _ ____ _____ $ ade coordinator Call coordinator runtime tools $ ade tests list | run | stop | runs | logs Run configured test suites $ ade proof status | list | screenshot | record Manage proof and computer-use artifacts - $ ade ios-sim devices | apps | launch | tap Control iOS Simulator apps, capture, and input - $ ade app-control launch | snapshot | click Inspect and drive Electron apps + $ ade ios-sim devices | apps | launch | tap Control iOS Simulator apps, capture, and input + $ ade app-control launch | snapshot | click Inspect and drive Electron apps + $ ade macos-vm status | start | guide Run lane-tied macOS VMs for agent work $ ade browser open | tabs | screenshot Use ADE's built-in browser pane $ ade memory add | search | pin Use ADE memory + $ ade usage snapshot | refresh | budget Read provider quota usage and edit automation guardrails $ ade settings action Call project config actions $ ade update status | check | install | dismiss Read auto-update state and drive install - $ ade actions list | run | status Escape hatch for every ADE service action + $ ade actions list | run | status | wait Escape hatch for every ADE service action $ ade cursor cloud agents | runs | artifacts | repos | models | me Drive Cursor Cloud agents via @cursor/sdk Global options: --project-root ADE project root. Inside .ade/worktrees/, this resolves to the parent project. --workspace-root Lane/worktree to treat as the active workspace. - --headless Skip the desktop socket and run an in-process ADE runtime. - --socket Require the desktop socket; fail instead of falling back to headless. + --headless Skip the runtime daemon and run an in-process ADE runtime. + --socket Require a live ADE socket; fail instead of falling back to headless. --json Print machine-readable JSON. This is the default output mode. --text Print a compact human-readable summary when a formatter exists. --timeout-ms Per-request timeout. Long agent/PR workflows may need several minutes. @@ -57,14 +69,19 @@ _ ____ _____ $ ade lanes list --text $ ade lanes create --name fix-login --description "Repair login redirect" $ ade git status --lane --text + $ ade git status --full --lane --text + $ ade git sync --lane --rebase --base main $ ade git stage --lane src/index.ts $ ade git commit --lane -m "Fix login redirect" + $ ade missions launch --prompt "Fix onboarding" --manual --text $ ade prs create --lane --base main --draft $ ade prs path-to-merge --model --max-rounds 3 --no-auto-merge $ ade proof record --seconds 20 $ ade ios-sim apps --text $ ade ios-sim launch --target --text $ ade app-control launch --command "pnpm dev" --text + $ ade macos-vm start --lane --create --text + $ ade macos-vm guide --lane --text $ ade --socket browser open http://localhost:5173 --new-tab --text $ ade terminal read --chat-session --text @@ -94,17 +111,27 @@ _ ____ _____ Agent-focused command-line interface for ADE. - ADE CLI commands operate on the same project database and live desktop socket - used by the ADE app. By default the CLI connects to the app socket when it is - running; otherwise it falls back to a headless runtime for local-safe actions. + ADE CLI commands operate through the machine ADE runtime daemon by default. + If the daemon is not running, the CLI starts it, registers the selected + project, and routes project actions through that runtime. $ ade help Display help for a command $ ade auth status Check local ADE CLI readiness + $ ade code Open ADE Work chat in the terminal + $ ade desktop Launch the installed desktop app + $ ade runtime start | stop | status Manage the machine runtime daemon + $ ade serve Run the ADE runtime daemon in foreground + $ ade rpc --stdio Speak ADE JSON-RPC over stdin/stdout + $ ade init [path] Register a project with this machine runtime + $ ade projects list List projects registered on this machine + $ ade sync status | pin generate Manage machine sync and phone pairing $ ade doctor Inspect project, socket, runtime, and tool availability $ ade lanes list | show | create | child Work with lanes and lane stacks $ ade git status | commit | push | stash Run ADE-aware git operations - $ ade diff changes | file Inspect lane diffs + $ ade operations status | wait Poll operation/test/chat/run/mission status + $ ade diff changes | file | patch Inspect lane diffs (including raw git patch text) $ ade files tree | read | write | search Read and edit lane workspaces + $ ade missions launch | watch | graph Create, start, and inspect mission runs $ ade prs list | create | path-to-merge Manage PRs, queues, and Path to Merge repair rounds $ ade run defs | ps | start | logs Manage Run tab process definitions and runtime $ ade shell start | write | resize | close Launch and control tracked shell sessions @@ -117,21 +144,23 @@ _ ____ _____ $ ade coordinator Call coordinator runtime tools $ ade tests list | run | stop | runs | logs Run configured test suites $ ade proof status | list | screenshot | record Manage proof and computer-use artifacts - $ ade ios-sim devices | apps | launch | tap Control iOS Simulator apps, capture, and input - $ ade app-control launch | snapshot | click Inspect and drive Electron apps + $ ade ios-sim devices | apps | launch | tap Control iOS Simulator apps, capture, and input + $ ade app-control launch | snapshot | click Inspect and drive Electron apps + $ ade macos-vm status | start | guide Run lane-tied macOS VMs for agent work $ ade browser open | tabs | screenshot Use ADE's built-in browser pane $ ade memory add | search | pin Use ADE memory + $ ade usage snapshot | refresh | budget Read provider quota usage and edit automation guardrails $ ade settings action Call project config actions $ ade update status | check | install | dismiss Read auto-update state and drive install - $ ade actions list | run | status Escape hatch for every ADE service action + $ ade actions list | run | status | wait Escape hatch for every ADE service action $ ade cursor cloud agents | runs | artifacts | repos | models | me Drive Cursor Cloud agents via @cursor/sdk Global options: --project-root ADE project root. Inside .ade/worktrees/, this resolves to the parent project. --workspace-root Lane/worktree to treat as the active workspace. - --headless Skip the desktop socket and run an in-process ADE runtime. - --socket Require the desktop socket; fail instead of falling back to headless. + --headless Skip the runtime daemon and run an in-process ADE runtime. + --socket Require a live ADE socket; fail instead of falling back to headless. --json Print machine-readable JSON. This is the default output mode. --text Print a compact human-readable summary when a formatter exists. --timeout-ms Per-request timeout. Long agent/PR workflows may need several minutes. @@ -141,14 +170,19 @@ _ ____ _____ $ ade lanes list --text $ ade lanes create --name fix-login --description "Repair login redirect" $ ade git status --lane --text + $ ade git status --full --lane --text + $ ade git sync --lane --rebase --base main $ ade git stage --lane src/index.ts $ ade git commit --lane -m "Fix login redirect" + $ ade missions launch --prompt "Fix onboarding" --manual --text $ ade prs create --lane --base main --draft $ ade prs path-to-merge --model --max-rounds 3 --no-auto-merge $ ade proof record --seconds 20 $ ade ios-sim apps --text $ ade ios-sim launch --target --text $ ade app-control launch --command "pnpm dev" --text + $ ade macos-vm start --lane --create --text + $ ade macos-vm guide --lane --text $ ade --socket browser open http://localhost:5173 --new-tab --text $ ade terminal read --chat-session --text @@ -178,17 +212,27 @@ _ ____ _____ Agent-focused command-line interface for ADE. - ADE CLI commands operate on the same project database and live desktop socket - used by the ADE app. By default the CLI connects to the app socket when it is - running; otherwise it falls back to a headless runtime for local-safe actions. + ADE CLI commands operate through the machine ADE runtime daemon by default. + If the daemon is not running, the CLI starts it, registers the selected + project, and routes project actions through that runtime. $ ade help Display help for a command $ ade auth status Check local ADE CLI readiness + $ ade code Open ADE Work chat in the terminal + $ ade desktop Launch the installed desktop app + $ ade runtime start | stop | status Manage the machine runtime daemon + $ ade serve Run the ADE runtime daemon in foreground + $ ade rpc --stdio Speak ADE JSON-RPC over stdin/stdout + $ ade init [path] Register a project with this machine runtime + $ ade projects list List projects registered on this machine + $ ade sync status | pin generate Manage machine sync and phone pairing $ ade doctor Inspect project, socket, runtime, and tool availability $ ade lanes list | show | create | child Work with lanes and lane stacks $ ade git status | commit | push | stash Run ADE-aware git operations - $ ade diff changes | file Inspect lane diffs + $ ade operations status | wait Poll operation/test/chat/run/mission status + $ ade diff changes | file | patch Inspect lane diffs (including raw git patch text) $ ade files tree | read | write | search Read and edit lane workspaces + $ ade missions launch | watch | graph Create, start, and inspect mission runs $ ade prs list | create | path-to-merge Manage PRs, queues, and Path to Merge repair rounds $ ade run defs | ps | start | logs Manage Run tab process definitions and runtime $ ade shell start | write | resize | close Launch and control tracked shell sessions @@ -201,21 +245,23 @@ _ ____ _____ $ ade coordinator Call coordinator runtime tools $ ade tests list | run | stop | runs | logs Run configured test suites $ ade proof status | list | screenshot | record Manage proof and computer-use artifacts - $ ade ios-sim devices | apps | launch | tap Control iOS Simulator apps, capture, and input - $ ade app-control launch | snapshot | click Inspect and drive Electron apps + $ ade ios-sim devices | apps | launch | tap Control iOS Simulator apps, capture, and input + $ ade app-control launch | snapshot | click Inspect and drive Electron apps + $ ade macos-vm status | start | guide Run lane-tied macOS VMs for agent work $ ade browser open | tabs | screenshot Use ADE's built-in browser pane $ ade memory add | search | pin Use ADE memory + $ ade usage snapshot | refresh | budget Read provider quota usage and edit automation guardrails $ ade settings action Call project config actions $ ade update status | check | install | dismiss Read auto-update state and drive install - $ ade actions list | run | status Escape hatch for every ADE service action + $ ade actions list | run | status | wait Escape hatch for every ADE service action $ ade cursor cloud agents | runs | artifacts | repos | models | me Drive Cursor Cloud agents via @cursor/sdk Global options: --project-root ADE project root. Inside .ade/worktrees/, this resolves to the parent project. --workspace-root Lane/worktree to treat as the active workspace. - --headless Skip the desktop socket and run an in-process ADE runtime. - --socket Require the desktop socket; fail instead of falling back to headless. + --headless Skip the runtime daemon and run an in-process ADE runtime. + --socket Require a live ADE socket; fail instead of falling back to headless. --json Print machine-readable JSON. This is the default output mode. --text Print a compact human-readable summary when a formatter exists. --timeout-ms Per-request timeout. Long agent/PR workflows may need several minutes. @@ -225,14 +271,19 @@ _ ____ _____ $ ade lanes list --text $ ade lanes create --name fix-login --description "Repair login redirect" $ ade git status --lane --text + $ ade git status --full --lane --text + $ ade git sync --lane --rebase --base main $ ade git stage --lane src/index.ts $ ade git commit --lane -m "Fix login redirect" + $ ade missions launch --prompt "Fix onboarding" --manual --text $ ade prs create --lane --base main --draft $ ade prs path-to-merge --model --max-rounds 3 --no-auto-merge $ ade proof record --seconds 20 $ ade ios-sim apps --text $ ade ios-sim launch --target --text $ ade app-control launch --command "pnpm dev" --text + $ ade macos-vm start --lane --create --text + $ ade macos-vm guide --lane --text $ ade --socket browser open http://localhost:5173 --new-tab --text $ ade terminal read --chat-session --text @@ -269,6 +320,7 @@ _ ____ _____ $ ade lanes show --text Inspect one lane status $ ade lanes create --name Create a lane from the current project context $ ade lanes create --linear-issue-json '{...}' Create a lane linked to a Linear issue + $ ade lanes create --branch-name Override the auto-generated branch name $ ade lanes child --lane --name Create a child lane under a parent $ ade lanes import --branch Register an existing branch/worktree $ ade lanes archive Archive a lane in ADE @@ -289,15 +341,24 @@ _ ____ _____ refresh lane state. Use --lane for anything other than the active workspace. $ ade git status --lane --text Show ADE-aware sync status + $ ade git status --full --lane --text Show full lane status, diff, and conflict state + $ ade git fetch --lane Fetch remote refs + $ ade git pull --lane Pull with ADE's ff-only lane operation + $ ade git sync --lane --rebase --base main + Sync the lane with its base branch $ ade git stage --lane src/file.ts Stage one file $ ade git stage-all --lane Stage all current changes $ ade git unstage --lane src/file.ts Unstage one file $ ade git commit --lane [-m ] Commit, adding Refs on linked Linear lanes $ ade git push --lane --set-upstream Push through ADE + $ ade git push --lane --force-with-lease Force-push through ADE with lease $ ade git branches --lane --text List branches with last-commit metadata $ ade git user-identity --lane --text Read lane checkout's git user.name/email $ ade git stash push|list|apply|pop Use ADE lane stash actions $ ade git rebase --lane --ai Rebase with ADE conflict support + $ ade git rebase continue --lane Continue an in-progress rebase + $ ade git conflict show --lane --text Inspect merge/rebase conflict state + $ ade git conflict resolve --kind rebase Continue after manual conflict resolution $ ade diff changes --lane --text Inspect changed files ## ade diff --help @@ -310,7 +371,8 @@ _ ____ _____ Diffs $ ade diff changes --lane --text Summarize staged/unstaged file changes - $ ade diff file --lane --text Show one file diff + $ ade diff file --lane --text Show one file diff (side-by-side text) + $ ade diff patch --lane --text Raw unified diff / patch for one file $ ade diff file --mode staged Inspect staged diff for one file $ ade diff actions --text List diff service actions @@ -372,8 +434,8 @@ _ ____ _____ Run tab - Run tab commands mirror ADE desktop process definitions and runtime state. - They require the desktop socket when live process state is needed. + Run tab commands mirror ADE process definitions and runtime state. They use + the machine runtime daemon when live process state is needed. $ ade run defs --text List configured run commands $ ade run ps --lane --text List process runtime state @@ -397,6 +459,8 @@ _ ____ _____ $ ade shell start --lane -- npm test Start a tracked shell session $ ade shell start --lane -c "npm test" Start with a command string + $ ade shell start-cli codex --lane --permission-mode edit + $ ade shell start --provider claude --lane --message "fix tests" $ ade shell start --lane --chat-session -c "npm test" $ ade shell write --data "q" Write data to a PTY $ ade shell resize --cols 120 --rows 36 @@ -412,7 +476,7 @@ _ ____ _____ Chat terminal Terminal commands control the active in-chat terminal for an ADE chat. Use - desktop socket mode when you want the same terminal the user sees in the app. + attached runtime mode when you want the same terminal the app is viewing. $ ade terminal list --chat-session --text List terminals for a chat $ ade terminal active --chat-session --text Show the active chat terminal @@ -431,13 +495,13 @@ _ ____ _____ Work chats Chat commands use ADE agent chat sessions. Live provider-backed chat normally - requires the desktop socket because the app owns provider/session state. + requires an attached runtime because the daemon owns provider/session state. $ ade chat list --text List chat sessions $ ade chat create --lane --provider codex --model [--fast] $ ade chat send --text "next step" Send a message $ ade chat interrupt Stop an active turn - $ ade chat resume Resume a session + $ ade chat slash --text List slash commands for a session $ ade agent spawn --lane --prompt "fix" Start a new agent work session ## ade agent --help @@ -479,7 +543,10 @@ _ ____ _____ Linear workflows - $ ade linear quick-view --text Show connected workspace, projects, and issues + $ ade --role cto linear quick-view --text Show connected workspace, projects, and issues + $ ade --role cto linear picker-data --text Read projects/users/states for the issue picker + $ ade --role cto linear search-issues --query "auth" --state-type started,unstarted --first 50 + Search issues for the lane Linear-issue picker $ ade linear workflows --text List configured workflows $ ade linear sync dashboard --text Show sync dashboard $ ade linear sync run Trigger a sync run @@ -502,13 +569,15 @@ _ ____ _____ $ ade automations update --from-file $ ade automations delete Remove a local rule $ ade automations toggle --enabled true|false - $ ade automations run [--dry-run] Trigger a rule manually + $ ade automations run [--lane ] [--dry-run] + $ ade automations trigger [--lane ] + Trigger a rule manually $ ade automations runs [--rule ] [--status ] [--limit 50] $ ade automations run-show [--json] Inspect a run $ ade automations example Print an example rule (stdout) Lane mode flags (apply to create/update on top of --from-file/--stdin/--text): - --lane-mode Spawn a new lane per run, or reuse one + --lane-mode Create, reuse, or require lane at trigger time --lane Target lane (only with --lane-mode reuse) --lane-name-preset --lane-name-template Template (only with preset custom) @@ -577,8 +646,8 @@ _ ____ _____ Prefer screenshots/images, screen recordings, and browser captures/traces. Console logs are supporting diagnostics, not a replacement for visual proof. Local screenshot/video fallback is macOS-only and runs headless by default - unless --socket is explicitly requested. Desktop socket mode has the best - parity for UI-owned proof state. + unless --socket is explicitly requested. Runtime socket mode has the best + parity for shared proof state. $ ade proof status --text Show proof backend capabilities $ ade proof list --text List captured artifacts @@ -599,7 +668,7 @@ _ ____ _____ iOS simulator commands build, launch, mirror, inspect, and control the ADE drawer simulator. Aliases: `ade ios` and `ade simulator` route to the same - surface. For drawer/shared session state, prefer desktop socket mode + surface. For drawer/shared session state, prefer runtime socket mode (--socket) so launch/select/tap operate on the same long-lived ADE service. Launch is headless by default; use --foreground only when you need the native Simulator window in front. idb is optional for direct @@ -641,7 +710,7 @@ _ ____ _____ $ ade ios-sim stream-stop Stop preview/live streaming (stopStream) Input and selection: - $ ade --socket ios-sim select --x 120 --y 420 Add UI context to drawer chat (selectPoint) + $ ade --socket ios-sim select --x 120 --y 420 Return/select simulator UI context (chat-owned sessions auto-attach) $ ade ios-sim tap 120 420 Tap active simulator app (tap) $ ade ios-sim drag 120 700 120 250 Drag active simulator app (drag) $ ade ios-sim swipe 120 700 120 250 Swipe active simulator app (swipe) @@ -695,7 +764,7 @@ _ ____ _____ $ ade app-control screenshot --text Capture the active renderer screenshot $ ade app-control snapshot --text Screenshot + DOM element refs $ ade app-control inspect --x 120 --y 420 Hit-test a point without committing context - $ ade app-control select --x 120 --y 420 Add selected app context to the drawer chat + $ ade app-control select --x 120 --y 420 Return/select app context (chat-owned sessions auto-attach) Input: $ ade app-control click 120 420 Click screenshot coordinates @@ -773,17 +842,27 @@ _ ____ _____ Agent-focused command-line interface for ADE. - ADE CLI commands operate on the same project database and live desktop socket - used by the ADE app. By default the CLI connects to the app socket when it is - running; otherwise it falls back to a headless runtime for local-safe actions. + ADE CLI commands operate through the machine ADE runtime daemon by default. + If the daemon is not running, the CLI starts it, registers the selected + project, and routes project actions through that runtime. $ ade help Display help for a command $ ade auth status Check local ADE CLI readiness + $ ade code Open ADE Work chat in the terminal + $ ade desktop Launch the installed desktop app + $ ade runtime start | stop | status Manage the machine runtime daemon + $ ade serve Run the ADE runtime daemon in foreground + $ ade rpc --stdio Speak ADE JSON-RPC over stdin/stdout + $ ade init [path] Register a project with this machine runtime + $ ade projects list List projects registered on this machine + $ ade sync status | pin generate Manage machine sync and phone pairing $ ade doctor Inspect project, socket, runtime, and tool availability $ ade lanes list | show | create | child Work with lanes and lane stacks $ ade git status | commit | push | stash Run ADE-aware git operations - $ ade diff changes | file Inspect lane diffs + $ ade operations status | wait Poll operation/test/chat/run/mission status + $ ade diff changes | file | patch Inspect lane diffs (including raw git patch text) $ ade files tree | read | write | search Read and edit lane workspaces + $ ade missions launch | watch | graph Create, start, and inspect mission runs $ ade prs list | create | path-to-merge Manage PRs, queues, and Path to Merge repair rounds $ ade run defs | ps | start | logs Manage Run tab process definitions and runtime $ ade shell start | write | resize | close Launch and control tracked shell sessions @@ -796,21 +875,23 @@ _ ____ _____ $ ade coordinator Call coordinator runtime tools $ ade tests list | run | stop | runs | logs Run configured test suites $ ade proof status | list | screenshot | record Manage proof and computer-use artifacts - $ ade ios-sim devices | apps | launch | tap Control iOS Simulator apps, capture, and input - $ ade app-control launch | snapshot | click Inspect and drive Electron apps + $ ade ios-sim devices | apps | launch | tap Control iOS Simulator apps, capture, and input + $ ade app-control launch | snapshot | click Inspect and drive Electron apps + $ ade macos-vm status | start | guide Run lane-tied macOS VMs for agent work $ ade browser open | tabs | screenshot Use ADE's built-in browser pane $ ade memory add | search | pin Use ADE memory + $ ade usage snapshot | refresh | budget Read provider quota usage and edit automation guardrails $ ade settings action Call project config actions $ ade update status | check | install | dismiss Read auto-update state and drive install - $ ade actions list | run | status Escape hatch for every ADE service action + $ ade actions list | run | status | wait Escape hatch for every ADE service action $ ade cursor cloud agents | runs | artifacts | repos | models | me Drive Cursor Cloud agents via @cursor/sdk Global options: --project-root ADE project root. Inside .ade/worktrees/, this resolves to the parent project. --workspace-root Lane/worktree to treat as the active workspace. - --headless Skip the desktop socket and run an in-process ADE runtime. - --socket Require the desktop socket; fail instead of falling back to headless. + --headless Skip the runtime daemon and run an in-process ADE runtime. + --socket Require a live ADE socket; fail instead of falling back to headless. --json Print machine-readable JSON. This is the default output mode. --text Print a compact human-readable summary when a formatter exists. --timeout-ms Per-request timeout. Long agent/PR workflows may need several minutes. @@ -820,14 +901,19 @@ _ ____ _____ $ ade lanes list --text $ ade lanes create --name fix-login --description "Repair login redirect" $ ade git status --lane --text + $ ade git status --full --lane --text + $ ade git sync --lane --rebase --base main $ ade git stage --lane src/index.ts $ ade git commit --lane -m "Fix login redirect" + $ ade missions launch --prompt "Fix onboarding" --manual --text $ ade prs create --lane --base main --draft $ ade prs path-to-merge --model --max-rounds 3 --no-auto-merge $ ade proof record --seconds 20 $ ade ios-sim apps --text $ ade ios-sim launch --target --text $ ade app-control launch --command "pnpm dev" --text + $ ade macos-vm start --lane --create --text + $ ade macos-vm guide --lane --text $ ade --socket browser open http://localhost:5173 --new-tab --text $ ade terminal read --chat-session --text diff --git a/apps/desktop/resources/agent-skills/ade-app-control/SKILL.md b/apps/desktop/resources/agent-skills/ade-app-control/SKILL.md new file mode 100644 index 000000000..6267ae912 --- /dev/null +++ b/apps/desktop/resources/agent-skills/ade-app-control/SKILL.md @@ -0,0 +1,51 @@ +--- +name: ade-app-control +description: Use this skill when inspecting, launching, logging, clicking, typing, or selecting context from Electron apps through ADE App Control and the `ade app-control` CLI. +--- + +# ADE App Control + +## Use socket mode + +App Control is a live desktop drawer service. Prefer socket-backed commands: + +```bash +ade help app-control +ade --socket app-control status --text +ade --socket app-control launch --command "npm run dev" --text +ade --socket app-control connect --cdp-port --text +``` + +ADE sets `ADE_APP_CONTROL_CDP_PORT` and `ADE_APP_CONTROL_DEBUG_FLAGS` for launches. Custom Electron launchers should forward one of those values to `--remote-debugging-port`. + +## Inspect + +```bash +ade --socket app-control snapshot --text +ade --socket app-control elements --text +ade --socket app-control select --x --y --text +``` + +Use Inspect mode or `select` to return screenshot-backed DOM, selector, and source context. When the session is chat-owned, ADE can attach the selection to the drawer chat. + +## Act + +```bash +ade --socket app-control click --x --y --text +ade --socket app-control type --value "text" --text +``` + +Use Control mode for input. Re-snapshot after meaningful UI changes. + +## Logs and terminal + +Start with App Control status, then prefer App Control terminal/log commands: + +```bash +ade --socket app-control logs --text --max-bytes 8388608 +ade --socket app-control terminal write --data "y\n" +ade --socket app-control terminal signal --signal SIGINT +``` + +Only fall back to `ade --socket terminal list --text` and `ade --socket terminal read ...` when no App Control terminal is active. + diff --git a/apps/desktop/resources/agent-skills/ade-browser/SKILL.md b/apps/desktop/resources/agent-skills/ade-browser/SKILL.md new file mode 100644 index 000000000..fb72ef03d --- /dev/null +++ b/apps/desktop/resources/agent-skills/ade-browser/SKILL.md @@ -0,0 +1,37 @@ +--- +name: ade-browser +description: Use this skill when using ADE's built-in browser pane, shared browser tabs, screenshots, page inspection, or browser context selection through `ade browser`. +--- + +# ADE browser + +## Scope + +The ADE browser is global, not lane-scoped. Use socket mode so CLI calls and the Work sidebar share the same tabs. + +## Common commands + +```bash +ade help browser +ade --socket browser panel --text +ade --socket browser status --text +ade --socket browser open --new-tab --text +ade --socket browser tabs --text +ade --socket browser switch --tab --text +ade --socket browser screenshot --text +``` + +For inspection and chat context: + +```bash +ade --socket browser inspect-start --text +ade --socket browser select-current --text +ade --socket browser clear-selection --text +``` + +## Gotchas + +- Open localhost URLs and chat-output links in the ADE browser when the user expects them to show in the Work sidebar. +- Because tabs are global, confirm the active tab before taking a screenshot or selecting context. +- If there is no active browser panel/session, report the blocker rather than pretending to inspect the page. + diff --git a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md new file mode 100644 index 000000000..43c0feff4 --- /dev/null +++ b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md @@ -0,0 +1,32 @@ +--- +name: ade-cli-control-plane +description: Use this skill when an agent needs to inspect or operate ADE itself through the `ade` CLI, including lanes, chats, actions, memory, proof, runtime/socket state, or help/flag discovery. +--- + +# ADE CLI control plane + +## Core rule + +Use normal shell commands for local repo edits, tests, and Git inspection. Use `ade` when you need ADE state or ADE-owned services: lanes, chats, missions, PR metadata, memory, proof/artifacts, managed terminals, App Control, iOS Simulator, browser, macOS VM, settings, usage, updates, or service actions. + +## First checks + +1. Run `ade doctor --text` when the ADE environment is unclear. +2. Run `ade help ` or `ade help ` before guessing flags. +3. Prefer `--text` for human-readable output and JSON output when scripting. +4. Use `ade actions list --text` or `ade actions list --domain --text` as the escape hatch for service methods without a typed command. + +## Socket mode + +Use `--socket` when the CLI and ADE desktop drawer must share live state. This matters for App Control, iOS Simulator, Preview Lab, browser tabs, terminal logs, context selection, and proof drawer updates. + +## Fallback path + +If `command -v ade` fails: + +1. Try `${ADE_CLI_PATH:-}` if set. +2. Try `${ADE_CLI_BIN_DIR:-}/ade` if set. +3. In an ADE source checkout, after confirming it exists, use `node apps/ade-cli/dist/cli.cjs ...`. + +The normal reason to skip ADE CLI is that it is truly unreachable after these fallbacks. + diff --git a/apps/desktop/resources/agent-skills/ade-cto-missions/SKILL.md b/apps/desktop/resources/agent-skills/ade-cto-missions/SKILL.md new file mode 100644 index 000000000..acb58b78b --- /dev/null +++ b/apps/desktop/resources/agent-skills/ade-cto-missions/SKILL.md @@ -0,0 +1,45 @@ +--- +name: ade-cto-missions +description: Use this skill when operating ADE CTO, missions, coordinator tools, worker agents, Linear routing, multi-agent orchestration, or mission run inspection. +--- + +# ADE CTO and missions + +## CTO + +Use CTO commands for team-lead state and Work chats: + +```bash +ade cto state --text +ade cto chats --text +ade help cto +``` + +## Missions + +Use missions for orchestrated multi-step work: + +```bash +ade missions list --text +ade missions launch --prompt "..." --manual --text +ade missions watch --text +ade missions graph --text +ade missions runs --text +``` + +## Coordinator and Linear + +```bash +ade coordinator --help +ade linear workflows --text +ade linear run --text +ade linear sync --text +``` + +## Operating rules + +- Keep worker briefs small and specific. +- Use project memory for non-obvious conventions and past pitfalls, not for information derivable from code or git. +- When polling long-running mission/worker state, return compact summaries instead of pasting full logs. +- If a worker result conflicts with repo evidence, inspect the files yourself before merging its conclusion. + diff --git a/apps/desktop/resources/agent-skills/ade-ios-simulator/SKILL.md b/apps/desktop/resources/agent-skills/ade-ios-simulator/SKILL.md new file mode 100644 index 000000000..dbd16fac7 --- /dev/null +++ b/apps/desktop/resources/agent-skills/ade-ios-simulator/SKILL.md @@ -0,0 +1,73 @@ +--- +name: ade-ios-simulator +description: Use this skill when working with ADE iOS Simulator, Preview Lab, SwiftUI preview rendering, simulator screenshots, taps, streams, or iOS drawer context via `ade ios-sim`. +--- + +# ADE iOS Simulator and Preview Lab + +## Start here + +Use socket mode so CLI actions and the desktop drawer share one simulator session: + +```bash +ade --socket ios-sim status --text +ade --socket ios-sim devices --text +ade --socket ios-sim apps --text +ade help ios-sim launch +``` + +Launch with a target from `apps`: + +```bash +ade --socket ios-sim launch --target --text +``` + +## Inspect and interact + +Capture current screen/context before acting: + +```bash +ade --socket ios-sim snapshot --text +ade --socket ios-sim elements --text +ade --socket ios-sim select --x --y --text +``` + +Interact with the running app: + +```bash +ade --socket ios-sim tap --x --y --text +ade --socket ios-sim drag --start-x --start-y --end-x --end-y --text +ade --socket ios-sim type --value "text" --text +``` + +## Streams + +Use `stream-status` to explain the active backend, latency, fallback reason, and blockers: + +```bash +ade --socket ios-sim window-start --fps 60 --text +ade --socket ios-sim live-start --fps 30 --text +ade --socket ios-sim stream-status --text +ade --socket ios-sim stream-stop --text +``` + +Low idle fps is normal on `iosurface-indigo` because frames are event-driven when the simulator is still. + +## Preview Lab + +For SwiftUI preview work: + +```bash +ade --socket ios-sim preview-status --text +ade --socket ios-sim previews --source --text +ade --socket ios-sim preview-render --source --index --text +``` + +Add a preview only when no useful nearby preview already exists. Preview fixtures must not require live sync, keychain, network, push, sockets, or production databases. + +## Gotchas + +- Do not create symlink projects, fake schemes, or repo-layout shims as the first fix for app detection. Re-run `ade --socket ios-sim apps --text` and report the selected project, scheme, and build output. +- If no simulator/session/snapshot exists, report the exact blocker instead of guessing the screen. +- When you own the simulator session and the task no longer needs it, run `ade --socket ios-sim shutdown --text`. + diff --git a/apps/desktop/resources/agent-skills/ade-lanes-git/SKILL.md b/apps/desktop/resources/agent-skills/ade-lanes-git/SKILL.md new file mode 100644 index 000000000..49e2f8453 --- /dev/null +++ b/apps/desktop/resources/agent-skills/ade-lanes-git/SKILL.md @@ -0,0 +1,49 @@ +--- +name: ade-lanes-git +description: Use this skill when creating, inspecting, syncing, committing, pushing, archiving, or rebasing ADE lanes and lane worktrees through `ade lanes` and `ade git`. +--- + +# ADE lanes and git + +## Lanes + +Lanes are ADE-managed git worktrees and branches. + +```bash +ade lanes list --text +ade lanes show --text +ade lanes create --name --description "..." --text +ade lanes child --lane --name --text +ade lanes archive --text +``` + +## ADE-aware Git + +Use ADE git commands when the operation should update ADE operation state and refresh lane status: + +```bash +ade git status --lane --text +ade git status --full --lane --text +ade git sync --lane --rebase --base main --text +ade git stage --lane +ade git stage-all --lane +ade git commit --lane -m "message" +ade git push --lane --set-upstream --text +``` + +## Rebase and conflicts + +```bash +ade git rebase --lane --ai --text +ade git conflict show --lane --text +ade git rebase continue --lane --text +``` + +For conflicts, inspect both sides and preserve intent from both branches. Do not blindly accept ours/theirs. + +## Gotchas + +- Use `--lane` for anything other than the active workspace. +- Use `ade diff changes --lane --text` when you need ADE's view of file changes. +- Do not archive or delete lanes unless the user asked for cleanup or the release workflow explicitly requires it. + diff --git a/apps/desktop/resources/agent-skills/ade-macos-vm/SKILL.md b/apps/desktop/resources/agent-skills/ade-macos-vm/SKILL.md new file mode 100644 index 000000000..1ad8a0f01 --- /dev/null +++ b/apps/desktop/resources/agent-skills/ade-macos-vm/SKILL.md @@ -0,0 +1,35 @@ +--- +name: ade-macos-vm +description: Use this skill when starting, inspecting, guiding, screenshotting, selecting, clicking, typing, or troubleshooting ADE lane-tied macOS VMs through `ade macos-vm`. +--- + +# ADE macOS VM + +## Start + +macOS VMs are lane-tied agent workspaces. + +```bash +ade help macos-vm +ade --socket macos-vm status --lane --text +ade --socket macos-vm start --lane --create --text +ade --socket macos-vm guide --lane --text +``` + +## Interact + +```bash +ade --socket macos-vm screenshot --lane --text +ade --socket macos-vm select --lane --x --y --text +ade --socket macos-vm click --lane +ade --socket macos-vm type --lane --value "text" +``` + +Click/select coordinates are window-relative by default. + +## Gotchas + +- Keep code edits under the guest shared path described by `guide`. +- Confirm provider readiness from `status` before promising VM interaction. +- If the VM is missing or blocked, report the exact status and next action. + diff --git a/apps/desktop/resources/agent-skills/ade-pr-workflows/SKILL.md b/apps/desktop/resources/agent-skills/ade-pr-workflows/SKILL.md new file mode 100644 index 000000000..b362f4f78 --- /dev/null +++ b/apps/desktop/resources/agent-skills/ade-pr-workflows/SKILL.md @@ -0,0 +1,37 @@ +--- +name: ade-pr-workflows +description: Use this skill when working with ADE PR workflows including PR tab data, PR checks/comments, queues, Path to Merge, rebase resolver, issue resolver agents, CI fixes, or merge readiness. +--- + +# ADE PR workflows + +## Start with typed PR commands + +```bash +ade prs list --text +ade prs show --text +ade prs checks --text +ade prs comments --text +ade prs path-to-merge --model --max-rounds 3 --no-auto-merge --text +``` + +Use `ade help prs` and `ade help git rebase` before guessing PR or rebase flags. + +## Use actions for niche surfaces + +```bash +ade actions list --domain pr --text +ade actions list --domain issue_inventory --text +ade actions run --input-json '{"key":"value"}' +``` + +## Resolver rules + +- Preserve both the lane's intent and main's intent during conflicts. +- Read conflict files and surrounding call sites before choosing a side. +- For review-thread or CI work, fetch current checks/comments first; do not rely on stale PR tab state. +- Prefer focused fixes and rerun the smallest relevant check before escalating to broader validation. + +## Release readiness + +Before treating a PR as merge-ready, verify working tree cleanliness, pushed branch status, required checks, unresolved review threads, and whether rebasing/merging main introduced conflicts or semantic drift. diff --git a/apps/desktop/resources/agent-skills/ade-proof-artifacts/SKILL.md b/apps/desktop/resources/agent-skills/ade-proof-artifacts/SKILL.md new file mode 100644 index 000000000..79e9a1341 --- /dev/null +++ b/apps/desktop/resources/agent-skills/ade-proof-artifacts/SKILL.md @@ -0,0 +1,33 @@ +--- +name: ade-proof-artifacts +description: Use this skill when the user asks for proof, screenshots, video, artifacts, test evidence, computer-use capture, or when work should appear in ADE's proof drawer. +--- + +# ADE proof and artifacts + +## Rule + +When the user asks to capture, send, attach, or provide proof, create evidence with the relevant tool, then register it through ADE so it appears in the proof drawer for the active chat, mission, or lane. + +## Commands + +```bash +ade proof status --text +ade proof list --text +ade proof screenshot --text +ade proof record --seconds 20 --text +ade help proof +``` + +## What counts as proof + +- Screenshot or video of the UI state. +- App Control, iOS Simulator, ADE browser, or macOS VM capture. +- Test output or log bundle when visual proof is not the right artifact. + +## Gotchas + +- Do not leave proof as an unregistered local file when the user expects ADE to show it. +- Include enough context in the artifact name/description to understand what was verified. +- Clean up stale processes you started before declaring proof complete. + diff --git a/apps/desktop/scripts/validate-mac-artifacts.mjs b/apps/desktop/scripts/validate-mac-artifacts.mjs index c5361b6df..9561dbab3 100644 --- a/apps/desktop/scripts/validate-mac-artifacts.mjs +++ b/apps/desktop/scripts/validate-mac-artifacts.mjs @@ -322,6 +322,7 @@ async function validatePackagedRuntime(appPath, description) { const adeCliTuiPath = path.join(resourcesPath, "ade-cli", "tuiClient", "cli.mjs"); const adeCliBinPath = path.join(resourcesPath, "ade-cli", "bin", "ade"); const adeCliInstallerPath = path.join(resourcesPath, "ade-cli", "install-path.sh"); + const bundledAgentSkillsPath = path.join(resourcesPath, "agent-skills", "ade-cli-control-plane", "SKILL.md"); const iosSimHelperRoot = path.join(resourcesPath, "native", "ios-sim-helpers"); const iosSimHelperBuildScript = path.join(iosSimHelperRoot, "build.sh"); const nodeModulesPath = path.join(unpackedPath, "node_modules"); @@ -338,6 +339,7 @@ async function validatePackagedRuntime(appPath, description) { await assertPathExists(adeCliTuiPath, "bundled ADE CLI TUI entry"); await assertPathExists(adeCliBinPath, "bundled ADE CLI wrapper"); await assertPathExists(adeCliInstallerPath, "bundled ADE CLI PATH installer"); + await assertPathExists(bundledAgentSkillsPath, "bundled ADE agent skills"); await assertPathExists(iosSimHelperBuildScript, "bundled iOS simulator helper build script"); await assertPathExists(path.join(iosSimHelperRoot, "sim-capture.swift"), "bundled iOS simulator capture helper source"); await assertPathExists(path.join(iosSimHelperRoot, "sim-input.m"), "bundled iOS simulator input helper source"); diff --git a/apps/desktop/scripts/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs index 96450589d..8d0d1acc0 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -456,6 +456,7 @@ async function validatePackagedRuntime(appDir) { const adeCliTuiPath = path.join(resourcesPath, "ade-cli", "tuiClient", "cli.mjs"); const adeCliBinPath = path.join(resourcesPath, "ade-cli", "bin", "ade.cmd"); const adeCliInstallerPath = path.join(resourcesPath, "ade-cli", "install-path.cmd"); + const bundledAgentSkillsPath = path.join(resourcesPath, "agent-skills", "ade-cli-control-plane", "SKILL.md"); const nodeModulesPath = path.join(unpackedPath, "node_modules"); const nodePtyModulePath = path.join(nodeModulesPath, "node-pty"); const sqlJsModulePath = path.join(nodeModulesPath, "sql.js"); @@ -482,6 +483,7 @@ async function validatePackagedRuntime(appDir) { await assertPathExists(adeCliTuiPath, "bundled ADE CLI TUI entry"); await assertPathExists(adeCliBinPath, "bundled ADE CLI wrapper"); await assertPathExists(adeCliInstallerPath, "bundled ADE CLI PATH installer"); + await assertPathExists(bundledAgentSkillsPath, "bundled ADE agent skills"); await assertPathExists(nodePtyModulePath, "unpacked node-pty module"); await assertPathExists(sqlJsModulePath, "unpacked sql.js module"); await assertPathExists(path.join(onnxRuntimeWinPath, "onnxruntime_binding.node"), "Windows ONNX Runtime native addon"); diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts index 8d6008179..a095fe47e 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts @@ -302,9 +302,9 @@ describe("buildCodingAgentSystemPrompt", () => { expect(result).toContain("## Operating Loop"); expect(result).toContain("## ADE CLI"); expect(result).toContain("only normal reason to skip ADE CLI"); - expect(result).toContain("can be driven from ADE CLI sessions"); - expect(result).toContain("ade help ios-sim"); - expect(result).toContain("preview-render --source "); + expect(result).toContain("ADE ships Agent Skills for deeper operating details"); + expect(result).toContain("ade-ios-simulator"); + expect(result).toContain("ade-cli-control-plane"); expect(result).toContain("## Editing Rules"); expect(result).toContain("## Verification Rules"); expect(result).toContain("## User-Facing Progress"); diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index edb88e9f0..21aed911b 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -1806,7 +1806,7 @@ describe("createAgentChatService", () => { expect(opts?.systemPrompt?.append).toContain("/ship-lane — Drive a lane through CI + review"); }); - it("omits the project slash commands section when no commands exist in the lane", async () => { + it("lists bundled ADE skills when no lane command files exist", async () => { vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ send: vi.fn(), stream: vi.fn(async function* () { @@ -1829,7 +1829,50 @@ describe("createAgentChatService", () => { const opts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as { systemPrompt?: { append?: string } } | undefined; expect(opts?.systemPrompt?.append).toBeTruthy(); - expect(opts?.systemPrompt?.append).not.toContain("## Project slash commands"); + expect(opts?.systemPrompt?.append).toContain("## Project slash commands and skills"); + expect(opts?.systemPrompt?.append).toContain("/ade-cli-control-plane"); + expect(opts?.systemPrompt?.append).not.toContain("Commands (file-backed prompts):"); + }); + + it("caps discovered command listings in the injected Claude prompt", async () => { + const commandsDir = path.join(tmpRoot, ".claude", "commands"); + fs.mkdirSync(commandsDir, { recursive: true }); + for (let index = 0; index < 25; index += 1) { + fs.writeFileSync(path.join(commandsDir, `cmd-${String(index).padStart(2, "0")}.md`), [ + "---", + `description: Command ${index}`, + "---", + "", + `Run command ${index}.`, + "", + ].join("\n")); + } + + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send: vi.fn(), + stream: vi.fn(async function* () { + return; + }), + close: vi.fn(), + sessionId: "sdk-session-many-slash-commands", + } as any); + + const { service } = createService(); + await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await vi.waitFor(() => { + expect(claudeSdkCreateSessionCompat).toHaveBeenCalled(); + }); + + const opts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as { systemPrompt?: { append?: string } } | undefined; + expect(opts?.systemPrompt?.append).toContain("/cmd-00 — Command 0"); + expect(opts?.systemPrompt?.append).toContain("/cmd-19 — Command 19"); + expect(opts?.systemPrompt?.append).not.toContain("/cmd-24 — Command 24"); + expect(opts?.systemPrompt?.append).toContain("5 more command(s) hidden to keep startup context lean"); }); it("does not attach ADE-owned tool definitions to Claude SDK sessions", async () => { @@ -3035,13 +3078,15 @@ describe("createAgentChatService", () => { usage: { input_tokens: 1, output_tokens: 1 }, }; })()); - vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + const sdkHandle = { send, stream, close: vi.fn(), sessionId: "sdk-session-1", setPermissionMode, - } as any); + } as any; + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue(sdkHandle); + vi.mocked(claudeSdkResumeSessionCompat).mockReturnValue(sdkHandle); const { service } = createService(); const session = await service.createSession({ @@ -3114,13 +3159,15 @@ describe("createAgentChatService", () => { usage: { input_tokens: 1, output_tokens: 1 }, }; })()); - vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + const sdkHandle = { send, stream, close: vi.fn(), sessionId: "sdk-session-non-identity", setPermissionMode, - } as any); + } as any; + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue(sdkHandle); + vi.mocked(claudeSdkResumeSessionCompat).mockReturnValue(sdkHandle); const { service } = createService(); const session = await service.createSession({ @@ -3200,6 +3247,7 @@ describe("createAgentChatService", () => { }); const persistedAfterPrime = readPersistedChatState(session.id); expect(persistedAfterPrime.lastLaneDirectiveKey).toBeTruthy(); + await service.dispose({ sessionId: session.id }); writePersistedChatState(session.id, { ...persistedAfterPrime, @@ -3253,7 +3301,10 @@ describe("createAgentChatService", () => { }); expect(result.outputText).toContain("Recovered"); - expect(claudeSdkResumeSessionCompat).toHaveBeenCalledWith("sdk-stale", expect.any(Object)); + expect(claudeSdkResumeSessionCompat).toHaveBeenCalledWith( + "sdk-stale", + expect.objectContaining({ resume: "sdk-stale" }), + ); expect(claudeSdkCreateSessionCompat).toHaveBeenCalledWith(expect.objectContaining({ permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, @@ -5215,6 +5266,28 @@ describe("createAgentChatService", () => { ); expect(sessionService.deleteSession).toHaveBeenCalledWith(session.id); }); + + it("does not follow transcript symlinks outside ADE during purge", async () => { + const { service, sessionService } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "opencode", + model: "", + modelId: "opencode/anthropic/claude-sonnet-4-6", + }); + + const mainTranscriptPath = sessionService.get(session.id)?.transcriptPath ?? ""; + const outsideTranscriptPath = path.join(tmpHomeRoot, "outside-transcript.jsonl"); + fs.writeFileSync(outsideTranscriptPath, "{\"event\":\"done\"}\n", "utf8"); + fs.mkdirSync(path.dirname(mainTranscriptPath), { recursive: true }); + fs.rmSync(mainTranscriptPath, { force: true }); + fs.symlinkSync(outsideTranscriptPath, mainTranscriptPath); + + await service.deleteSession({ sessionId: session.id }); + + expect(fs.existsSync(outsideTranscriptPath)).toBe(true); + expect(sessionService.deleteSession).toHaveBeenCalledWith(session.id); + }); }); // -------------------------------------------------------------------------- @@ -7461,6 +7534,33 @@ describe("createAgentChatService", () => { ]); }); + it("does not hydrate transcript symlinks that resolve outside ADE", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + const envelope: AgentChatEventEnvelope = { + sessionId: session.id, + timestamp: new Date().toISOString(), + event: { type: "text", text: "outside-transcript" }, + sequence: 1, + }; + const transcriptFile = path.join(tmpRoot, "transcripts", `${session.id}.chat.jsonl`); + const outsideTranscriptPath = path.join(tmpHomeRoot, "outside-transcript.jsonl"); + fs.writeFileSync(outsideTranscriptPath, `${JSON.stringify(envelope)}\n`, "utf8"); + fs.rmSync(transcriptFile, { force: true }); + fs.symlinkSync(outsideTranscriptPath, transcriptFile); + vi.mocked(parseAgentChatTranscript).mockReturnValue([envelope]); + + const history = service.getChatEventHistory(session.id); + + expect(history.events).toEqual([]); + expect(parseAgentChatTranscript).not.toHaveBeenCalled(); + }); + it("drops history when the underlying session is deleted", async () => { // We don't rely on sendMessage emitting events (mock streams vary across // providers), so we seed the transcript directly to verify the cleanup diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 648cbc8db..db4d642ce 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -1152,6 +1152,8 @@ type ClaudeDaemonResponse = { present?: boolean; }; +const MAX_CLAUDE_DAEMON_RESPONSE_BYTES = 1024 * 1024; + function sleepMs(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -1271,8 +1273,17 @@ function extractClaudeBackgroundTranscriptText(state: ClaudeBackgroundJobState | const configRoot = path.resolve(claudeConfigDir()); if (!resolvedPath.startsWith(`${configRoot}${path.sep}`)) return ""; if (!fs.existsSync(resolvedPath)) return ""; + let realPath = resolvedPath; + let realConfigRoot = configRoot; + try { + realPath = fs.realpathSync(resolvedPath); + realConfigRoot = fs.realpathSync(configRoot); + } catch { + return ""; + } + if (!realPath.startsWith(`${realConfigRoot}${path.sep}`)) return ""; try { - const text = readFileTailUtf8(resolvedPath); + const text = readFileTailUtf8(realPath); let latest = ""; for (const line of text.split(/\n+/)) { if (!line.trim()) continue; @@ -1330,6 +1341,10 @@ async function sendClaudeDaemonRequest( socket.write(`${JSON.stringify({ proto: 1, ...request })}\n`); }); socket.on("data", (chunk) => { + if (Buffer.byteLength(buffer, "utf8") + chunk.length > MAX_CLAUDE_DAEMON_RESPONSE_BYTES) { + finish(() => reject(new Error("Claude daemon response exceeded the 1MB limit."))); + return; + } buffer += chunk.toString("utf8"); const newline = buffer.indexOf("\n"); const raw = (newline >= 0 ? buffer.slice(0, newline) : buffer).trim(); @@ -1548,6 +1563,8 @@ type ResolvedChatConfig = { }; const MAX_PENDING_STEERS = 10; +const MAX_INJECTED_PROJECT_COMMANDS = 20; +const MAX_INJECTED_PROJECT_SKILLS = 20; const CURSOR_SDK_AGENT_PROTOCOL_VERSION = 2; const CLAUDE_WARMUP_WAIT_TIMEOUT_MS = 20_000; @@ -5276,8 +5293,9 @@ export function createAgentChatService(args: { const readTranscriptConversationEntries = (managed: ManagedChatSession): string[] => { try { - flushQueuedTranscriptWrite(managed.transcriptPath); - const raw = fs.readFileSync(managed.transcriptPath, "utf8"); + const transcriptPath = resolveReadableChatPath(managed.transcriptPath, "agent_chat.transcript_read_skipped_path_outside_ade"); + if (!transcriptPath) return []; + const raw = fs.readFileSync(transcriptPath, "utf8"); return parseAgentChatTranscript(raw) .filter((entry) => entry.sessionId === managed.session.id) .flatMap((entry) => { @@ -5298,8 +5316,9 @@ export function createAgentChatService(args: { const readTranscriptEntries = (managed: ManagedChatSession): AgentChatTranscriptEntry[] => { try { - flushQueuedTranscriptWrite(managed.transcriptPath); - const raw = fs.readFileSync(managed.transcriptPath, "utf8"); + const transcriptPath = resolveReadableChatPath(managed.transcriptPath, "agent_chat.transcript_read_skipped_path_outside_ade"); + if (!transcriptPath) return []; + const raw = fs.readFileSync(transcriptPath, "utf8"); const entries: AgentChatTranscriptEntry[] = []; for (const entry of parseAgentChatTranscript(raw)) { if (entry.sessionId !== managed.session.id) continue; @@ -5339,8 +5358,9 @@ export function createAgentChatService(args: { managed: ManagedChatSession, ): Extract["items"] => { try { - flushQueuedTranscriptWrite(managed.transcriptPath); - const raw = fs.readFileSync(managed.transcriptPath, "utf8"); + const transcriptPath = resolveReadableChatPath(managed.transcriptPath, "agent_chat.transcript_read_skipped_path_outside_ade"); + if (!transcriptPath) return []; + const raw = fs.readFileSync(transcriptPath, "utf8"); let latest: Extract["items"] = []; for (const entry of parseAgentChatTranscript(raw)) { if (entry.sessionId !== managed.session.id) continue; @@ -5441,8 +5461,9 @@ export function createAgentChatService(args: { const readTranscriptEnvelopes = (managed: ManagedChatSession): AgentChatEventEnvelope[] => { try { - flushQueuedTranscriptWrite(managed.transcriptPath); - return parseAgentChatTranscript(fs.readFileSync(managed.transcriptPath, "utf8")) + const transcriptPath = resolveReadableChatPath(managed.transcriptPath, "agent_chat.transcript_read_skipped_path_outside_ade"); + if (!transcriptPath) return []; + return parseAgentChatTranscript(fs.readFileSync(transcriptPath, "utf8")) .filter((entry) => entry.sessionId === managed.session.id); } catch { return []; @@ -5478,8 +5499,9 @@ export function createAgentChatService(args: { const managed = managedSessions.get(sessionId); if (managed?.transcriptPath) { try { - flushQueuedTranscriptWrite(managed.transcriptPath); - return parseAgentChatTranscript(fs.readFileSync(managed.transcriptPath, "utf8")) + const transcriptPath = resolveReadableChatPath(managed.transcriptPath, "agent_chat.transcript_read_skipped_path_outside_ade"); + if (!transcriptPath) return []; + return parseAgentChatTranscript(fs.readFileSync(transcriptPath, "utf8")) .filter((entry) => entry.sessionId === sessionId); } catch { return []; @@ -5494,9 +5516,9 @@ export function createAgentChatService(args: { ]; for (const candidatePath of candidates) { try { - flushQueuedTranscriptWrite(candidatePath); - if (!fs.existsSync(candidatePath)) continue; - const raw = fs.readFileSync(candidatePath, "utf8"); + const transcriptPath = resolveReadableChatPath(candidatePath, "agent_chat.transcript_read_skipped_path_outside_ade"); + if (!transcriptPath) continue; + const raw = fs.readFileSync(transcriptPath, "utf8"); return parseAgentChatTranscript(raw).filter((entry) => entry.sessionId === sessionId); } catch { // try next candidate @@ -6742,32 +6764,57 @@ export function createAgentChatService(args: { const metadataPathFor = (sessionId: string): string => path.join(chatSessionsDir, `${sessionId}.json`); - const deletePersistedChatFile = (filePath: string | null | undefined): void => { + const safeRealpath = (p: string): string | null => { + try { return fs.realpathSync(p); } catch { return null; } + }; + + const isWithinRoot = (target: string, root: string | null): boolean => { + if (!root) return false; + const rel = path.relative(root, target); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); + }; + + const resolveContainedChatPath = ( + filePath: string | null | undefined, + warnEvent = "agent_chat.chat_file_skipped_path_outside_ade", + allowMissing = false, + ): string | null => { const trimmed = typeof filePath === "string" ? filePath.trim() : ""; - if (!trimmed.length) return; + if (!trimmed.length) return null; const resolvedPath = path.resolve(trimmed); - // Resolve symlinks on the target and both roots before comparing, so a - // symlink placed inside the chat dir cannot redirect rmSync outside. - const safeRealpath = (p: string): string | null => { - try { return fs.realpathSync(p); } catch { return null; } - }; + const resolvedAdeDir = path.resolve(layout.adeDir); + const resolvedTranscriptRoot = path.resolve(transcriptsDir); + if (!isWithinRoot(resolvedPath, resolvedAdeDir) && !isWithinRoot(resolvedPath, resolvedTranscriptRoot)) { + logger.warn(warnEvent, { filePath: resolvedPath }); + return null; + } const realTarget = safeRealpath(resolvedPath); - // Missing target is safe to skip — nothing to delete. - if (!realTarget) return; + if (!realTarget) return allowMissing ? resolvedPath : null; const realAdeDir = safeRealpath(layout.adeDir); const realTranscriptRoot = safeRealpath(path.resolve(transcriptsDir)); - const isWithin = (root: string | null): boolean => { - if (!root) return false; - const rel = path.relative(root, realTarget); - return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); - }; - if (!isWithin(realAdeDir) && !isWithin(realTranscriptRoot)) { - logger.warn("agent_chat.delete_skipped_path_outside_ade", { + if (!isWithinRoot(realTarget, realAdeDir) && !isWithinRoot(realTarget, realTranscriptRoot)) { + logger.warn(warnEvent, { filePath: resolvedPath, realTarget, }); - return; + return null; } + return realTarget; + }; + + const resolveReadableChatPath = ( + filePath: string | null | undefined, + warnEvent = "agent_chat.transcript_read_skipped_path_outside_ade", + ): string | null => { + if (!resolveContainedChatPath(filePath, warnEvent, true)) return null; + const trimmed = typeof filePath === "string" ? filePath.trim() : ""; + flushQueuedTranscriptWrite(path.resolve(trimmed)); + return resolveContainedChatPath(filePath, warnEvent); + }; + + const deletePersistedChatFile = (filePath: string | null | undefined): void => { + const realTarget = resolveContainedChatPath(filePath, "agent_chat.delete_skipped_path_outside_ade"); + if (!realTarget) return; try { fs.rmSync(realTarget, { force: true }); } catch (error) { @@ -9162,16 +9209,28 @@ export function createAgentChatService(args: { bumpClaudeIdleDeadline(); markFirstStreamEvent(msg.type); - // Capture session_id from any message - if (!runtime.sdkSessionId && (msg as any).session_id) { - runtime.sdkSessionId = (msg as any).session_id; + // Capture the provider's canonical session_id from any message. The + // SDK can replace a stale/resume-rejected id when ADE starts fresh. + const messageSessionId = typeof (msg as any).session_id === "string" + ? (msg as any).session_id.trim() + : ""; + if (messageSessionId.length > 0 && runtime.sdkSessionId !== messageSessionId) { + runtime.sdkSessionId = messageSessionId; + mirrorClaudeSessionPointer(managed, runtime.sdkSessionId); persistChatState(managed); } // system:init — capture data silently (no UI emission) if (msg.type === "system" && (msg as any).subtype === "init") { const initMsg = msg as any; - runtime.sdkSessionId = initMsg.session_id ?? runtime.sdkSessionId; + const initSessionId = typeof initMsg.session_id === "string" + ? initMsg.session_id.trim() + : ""; + if (initSessionId.length > 0 && runtime.sdkSessionId !== initSessionId) { + runtime.sdkSessionId = initSessionId; + mirrorClaudeSessionPointer(managed, runtime.sdkSessionId); + persistChatState(managed); + } reportedInitModel = normalizeReportedModelName(initMsg.model) ?? reportedInitModel; if (Array.isArray(initMsg.slash_commands)) { applyClaudeSlashCommands(runtime, initMsg.slash_commands); @@ -10090,7 +10149,7 @@ export function createAgentChatService(args: { ? [ "", "## Project slash commands and skills", - "ADE walks up from the lane worktree to discover `.claude/commands/*.md` (slash commands) and `.claude/skills//SKILL.md` (skills) at every ancestor directory plus `~/.claude/`. Claude Code itself may discover some of these, but ADE also tells you about the full project-visible set here.", + "ADE walks up from the lane worktree to discover `.claude/commands/*.md` plus `.claude/skills//SKILL.md`, `.agents/skills//SKILL.md`, `.ade/skills//SKILL.md`, user skill roots, and ADE bundled skills. Claude Code itself may discover some of these, but ADE also tells you about the full project-visible set here.", "**User-invoked (`/`):** When the user sends a message that is exactly `/` or `/ `, ADE may pre-expand the file's body and substitute `$ARGUMENTS` before it reaches you. You'll see the expanded instructions, not necessarily the literal `/`.", "**Mid-sentence reference:** When the user mentions a command/skill mid-sentence, read the file at the path below and follow it.", "**Autonomous skill use:** If, while working on a task, you decide a discovered skill applies, read its SKILL.md file and follow it as if it had been invoked.", @@ -10337,8 +10396,15 @@ export function createAgentChatService(args: { emitChatEvent(managed, { type: "text", text: stateText, turnId }); persistChatState(managed); }; + const isManagedSessionStopped = (): boolean => + managed.closed + || managed.session.status !== "active" + || (managed.runtime?.kind === "claude" && managed.runtime.interrupted); while (Date.now() - start < maxDurationMs) { + if (isManagedSessionStopped()) { + return { status: "interrupted", assistantText, state: lastState }; + } lastState = readClaudeBackgroundJobState(short); if (lastState) { rememberClaudeBackgroundJob(managed, short, lastState); @@ -10519,7 +10585,8 @@ export function createAgentChatService(args: { }, ): Promise => { const runtimeKind = managed.runtime?.kind; - if (runtimeKind === "claude") { + if (runtimeKind === "claude" || managed.session.provider === "claude") { + ensureClaudeSessionRuntime(managed); return runClaudeTurn(managed, args); } if (runtimeKind !== "opencode") { @@ -13555,31 +13622,34 @@ export function createAgentChatService(args: { })(); const projectCommandFiles = projectSlashCommands.filter((cmd) => cmd.source === "command"); const projectSkillFiles = projectSlashCommands.filter((cmd) => cmd.source === "skill"); + const visibleProjectCommandFiles = projectCommandFiles.slice(0, MAX_INJECTED_PROJECT_COMMANDS); + const visibleProjectSkillFiles = projectSkillFiles.slice(0, MAX_INJECTED_PROJECT_SKILLS); + const hiddenProjectCommandCount = projectCommandFiles.length - visibleProjectCommandFiles.length; + const hiddenProjectSkillCount = projectSkillFiles.length - visibleProjectSkillFiles.length; + const formatDiscoveredCommand = (cmd: (typeof projectSlashCommands)[number]): string => { + const desc = cmd.description.trim(); + const head = desc.length ? `- ${cmd.name} — ${desc}` : `- ${cmd.name}`; + return `${head}\n file: ${cmd.filePath}`; + }; const slashCommandsSection = projectSlashCommands.length ? [ "", "## Project slash commands and skills", - "ADE walks up from the lane worktree to discover `.claude/commands/*.md` (slash commands) and `.claude/skills//SKILL.md` (skills) at every ancestor directory plus `~/.claude/`. The Claude Agent SDK only auto-discovers `/.claude/` and `~/.claude/`, so ADE injects the rest here.", + "ADE walks up from the lane worktree to discover `.claude/commands/*.md` plus `.claude/skills//SKILL.md`, `.agents/skills//SKILL.md`, `.ade/skills//SKILL.md`, user skill roots, and ADE bundled skills. The Claude Agent SDK only auto-discovers `/.claude/` and `~/.claude/`, so ADE injects the rest here.", "**User-invoked (`/`):** When the user sends a message that is exactly `/` or `/ `, ADE pre-expands the file's body (commands take precedence over same-named skills) and substitutes `$ARGUMENTS` before it reaches you. You'll see the expanded instructions, not the literal `/`.", "**Mid-sentence reference:** When the user mentions a command/skill mid-sentence (e.g. \"please /audit this\", \"can you do a /security-review\") the message is NOT auto-expanded. Read the file at the path below and follow it.", "**Autonomous skill use:** If, while working on a task, you decide a discovered skill applies (its description matches the situation), Read its SKILL.md file and follow it as if it had been invoked. Don't ask the user — just use the skill when warranted.", ...(projectCommandFiles.length ? [ "", "Commands (file-backed prompts):", - ...projectCommandFiles.map((cmd) => { - const desc = cmd.description.trim(); - const head = desc.length ? `- ${cmd.name} — ${desc}` : `- ${cmd.name}`; - return `${head}\n file: ${cmd.filePath}`; - }), + ...visibleProjectCommandFiles.map(formatDiscoveredCommand), + ...(hiddenProjectCommandCount > 0 ? [`- ${hiddenProjectCommandCount} more command(s) hidden to keep startup context lean. Use slash command search or inspect project command folders if needed.`] : []), ] : []), ...(projectSkillFiles.length ? [ "", "Skills (autonomously usable when relevant):", - ...projectSkillFiles.map((cmd) => { - const desc = cmd.description.trim(); - const head = desc.length ? `- ${cmd.name} — ${desc}` : `- ${cmd.name}`; - return `${head}\n file: ${cmd.filePath}`; - }), + ...visibleProjectSkillFiles.map(formatDiscoveredCommand), + ...(hiddenProjectSkillCount > 0 ? [`- ${hiddenProjectSkillCount} more skill(s) hidden to keep startup context lean. Use slash command search or inspect skill roots if needed.`] : []), ] : []), ] : []; @@ -14152,9 +14222,7 @@ export function createAgentChatService(args: { }); const claudePointer = getClaudeSessionPointerForChat(managed.session.id); const laneScopedClaudePointer = claudePointer?.laneId === managed.session.laneId ? claudePointer : null; - const sdkSessionId = currentLaneDirectiveKey != null && persisted?.lastLaneDirectiveKey === currentLaneDirectiveKey - ? persisted?.sdkSessionId ?? laneScopedClaudePointer?.sessionId ?? null - : laneScopedClaudePointer?.sessionId ?? null; + const sdkSessionId = persisted?.sdkSessionId ?? laneScopedClaudePointer?.sessionId ?? null; const forkFromSdkSessionId = currentLaneDirectiveKey != null && persisted?.lastLaneDirectiveKey === currentLaneDirectiveKey ? persisted?.forkFromSdkSessionId ?? null : null; @@ -14814,13 +14882,13 @@ export function createAgentChatService(args: { const createdManaged = ensureManagedSession(created.id); createdManaged.session.executionMode = managed.session.executionMode ?? sourceSession.executionMode ?? null; if (handoffMode === "fork") { - if (createdManaged.runtime?.kind !== "claude") { - throw new Error("Full-history fork can only target Claude chats."); + createdManaged.claudeBackgroundResumeSessionId = sourceSdkSessionId; + mirrorClaudeSessionPointer(createdManaged, sourceSdkSessionId); + if (createdManaged.runtime?.kind === "claude") { + resetClaudeQuerySession(createdManaged, createdManaged.runtime, "session_reset", { clearSdkSessionId: true }); + createdManaged.runtime.forkFromSdkSessionId = sourceSdkSessionId; + prewarmClaudeQuery(createdManaged); } - resetClaudeQuerySession(createdManaged, createdManaged.runtime, "session_reset", { clearSdkSessionId: true }); - createdManaged.runtime.sdkSessionId = randomUUID(); - createdManaged.runtime.forkFromSdkSessionId = sourceSdkSessionId ?? null; - prewarmClaudeQuery(createdManaged); } const inheritedGoal = trimLine(sourceSession.goal) ?? trimLine(sourceSession.summary) @@ -18284,7 +18352,7 @@ export function createAgentChatService(args: { // OpenCode runtime steer if (managed.runtime?.kind === "opencode") { const runtime = managed.runtime; - if (runtime.busy) { + if (runtime.busy || managed.session.status === "active") { const preparedSteer = prepareSendMessage({ sessionId, text: trimmed, @@ -18522,19 +18590,23 @@ export function createAgentChatService(args: { if (!preparedSteer) { return { steerId, queued: false }; } - const runtime = ensureClaudeSessionRuntime(managed); - if (runtime.busy) { - enqueueSteerOrDrop( - managed, - runtime, - sessionId, - steerId, - preparedSteer.visibleText, - preparedSteer.attachments, - preparedSteer.contextAttachments, - preparedSteer.resolvedAttachments, - ); - return { steerId, queued: true }; + if (managed.session.provider === "claude") { + const runtime = ensureClaudeSessionRuntime(managed); + if (runtime.busy || managed.session.status === "active") { + enqueueSteerOrDrop( + managed, + runtime, + sessionId, + steerId, + preparedSteer.visibleText, + preparedSteer.attachments, + preparedSteer.contextAttachments, + preparedSteer.resolvedAttachments, + ); + return { steerId, queued: true }; + } + await executePreparedSendMessage(preparedSteer); + return { steerId, queued: false }; } await executePreparedSendMessage(preparedSteer); return { steerId, queued: false }; @@ -18863,6 +18935,22 @@ export function createAgentChatService(args: { return; } + if (managed.session.provider === "claude" && managed.claudeBackgroundJobShort && managed.runtime?.kind !== "claude") { + const short = managed.claudeBackgroundJobShort; + if (short) { + await runClaudeBackgroundCliCommand(managed, ["stop", short], 15_000).catch((error) => { + logger.warn("agent_chat.claude_background_stop_failed", { + sessionId, + short, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + markSessionIdleWithFreshCache(managed); + persistChatState(managed); + return; + } + const runtime = ensureClaudeSessionRuntime(managed); // Idempotency guard: skip if already interrupted (e.g. rapid cancel clicks) if (runtime.interrupted) return; @@ -20564,12 +20652,14 @@ export function createAgentChatService(args: { return; } - // Only prewarm if the session is idle (not mid-turn) and not already warmed - if (managed.runtime?.kind === "claude" && (managed.runtime.query || managed.runtime.warmQuery || managed.runtime.warmupDone)) return; - - // Ensure a Claude runtime exists and kick off pre-warming - ensureClaudeSessionRuntime(managed); + if (managed.session.provider !== "claude") return; + if (managed.session.modelId !== descriptor.id) return; + if (managed.session.status === "active") return; + const runtime = ensureClaudeSessionRuntime(managed); + if (runtime.busy) return; + if (runtime.query || runtime.warmQuery || runtime.warmupDone) return; prewarmClaudeQuery(managed); + persistChatState(managed); }; const listSubagents = ({ sessionId }: AgentChatSubagentListArgs): AgentChatSubagentSnapshot[] => { diff --git a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts index 161e4c5de..901253ac2 100644 --- a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts +++ b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts @@ -33,13 +33,13 @@ describe("discoverClaudeSlashCommands", () => { "", ].join("\n")); - expect(discoverClaudeSlashCommands(tmpRoot)).toMatchObject([ - { + expect(discoverClaudeSlashCommands(tmpRoot)).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "/automate", description: "Generate comprehensive test suites", argumentHint: "[area]", - }, - ]); + }), + ])); }); it("uses nested project command paths for unambiguous discovery", () => { @@ -54,12 +54,12 @@ describe("discoverClaudeSlashCommands", () => { "", ].join("\n")); - expect(discoverClaudeSlashCommands(tmpRoot)).toMatchObject([ - { + expect(discoverClaudeSlashCommands(tmpRoot)).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "/frontend:test", description: "Run frontend tests", - }, - ]); + }), + ])); }); it("skips legacy command directories deeper than the traversal cap", () => { @@ -84,12 +84,12 @@ describe("discoverClaudeSlashCommands", () => { "", ].join("\n")); - expect(discoverClaudeSlashCommands(tmpRoot)).toMatchObject([ - { + expect(discoverClaudeSlashCommands(tmpRoot)).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "/level-0:level-1:level-2:level-3:level-4:level-5:level-6:level-7:level-8:level-9:visible", description: "Visible nested command", - }, - ]); + }), + ])); }); it("preserves command filename casing and dedupes case variants by project precedence", () => { @@ -147,12 +147,85 @@ describe("discoverClaudeSlashCommands", () => { "", ].join("\n")); - expect(discoverClaudeSlashCommands(tmpRoot)).toMatchObject([ - { + const commands = discoverClaudeSlashCommands(tmpRoot); + expect(commands).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "/fix-issue", description: "Fix a GitHub issue", - }, - ]); + }), + ])); + expect(commands).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "/background-context" }), + ])); + }); + + it("discovers cross-client .agents skills and ADE project skills", () => { + const agentsSkill = path.join(tmpRoot, ".agents", "skills", "ios-lab"); + const adeSkill = path.join(tmpRoot, ".ade", "skills", "pr-resolver"); + fs.mkdirSync(agentsSkill, { recursive: true }); + fs.mkdirSync(adeSkill, { recursive: true }); + fs.writeFileSync(path.join(agentsSkill, "SKILL.md"), [ + "---", + "name: ios-lab", + "description: Use this skill for simulator work", + "---", + "", + "Inspect the simulator.", + "", + ].join("\n")); + fs.writeFileSync(path.join(adeSkill, "SKILL.md"), [ + "---", + "name: pr-resolver", + "description: Use this skill for PR resolver work", + "---", + "", + "Resolve the PR.", + "", + ].join("\n")); + + const commands = discoverClaudeSlashCommands(tmpRoot); + expect(commands).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "/ios-lab", + description: "Use this skill for simulator work", + source: "skill", + }), + expect.objectContaining({ + name: "/pr-resolver", + description: "Use this skill for PR resolver work", + source: "skill", + }), + ])); + }); + + it("discovers bundled ADE agent skills from the desktop resources directory in dev", () => { + const cwdRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bundled-skills-cwd-")); + const skillDir = path.join(cwdRoot, "apps", "desktop", "resources", "agent-skills", "ade-cli-control-plane"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), [ + "---", + "name: ade-cli-control-plane", + "description: Operate ADE through the CLI", + "---", + "", + "Use ade doctor.", + "", + ].join("\n")); + vi.spyOn(process, "cwd").mockReturnValue(cwdRoot); + + try { + const commands = discoverClaudeSlashCommands(tmpRoot); + expect(commands).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "/ade-cli-control-plane", + description: "Operate ADE through the CLI", + source: "skill", + filePath: path.join(skillDir, "SKILL.md"), + }), + ])); + } finally { + fs.rmSync(cwdRoot, { recursive: true, force: true }); + } }); it("walks up parent directories to discover .claude/commands at workspace root from a lane subdir", () => { @@ -169,13 +242,13 @@ describe("discoverClaudeSlashCommands", () => { const laneWorktree = path.join(tmpRoot, "lanes", "feature-x", "worktree"); fs.mkdirSync(laneWorktree, { recursive: true }); - expect(discoverClaudeSlashCommands(laneWorktree)).toMatchObject([ - { + expect(discoverClaudeSlashCommands(laneWorktree)).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "/audit", description: "Workspace-root audit", source: "command", - }, - ]); + }), + ])); }); it("walks up parent directories for resolveClaudeSlashCommandInvocation as well", () => { @@ -227,12 +300,12 @@ describe("discoverClaudeSlashCommands", () => { "", ].join("\n")); - expect(discoverClaudeSlashCommands(tmpRoot)).toMatchObject([ - { + expect(discoverClaudeSlashCommands(tmpRoot)).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "/ship", description: "Project ship", - }, - ]); + }), + ])); }); it("does not let home commands override project commands when the project is under home", () => { @@ -242,12 +315,12 @@ describe("discoverClaudeSlashCommands", () => { fs.writeFileSync(path.join(homeRoot, ".claude", "commands", "audit.md"), "Personal audit.\n"); fs.writeFileSync(path.join(projectRoot, ".claude", "commands", "audit.md"), "Project audit.\n"); - expect(discoverClaudeSlashCommands(projectRoot)).toMatchObject([ - { + expect(discoverClaudeSlashCommands(projectRoot)).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "/audit", description: "Project audit.", - }, - ]); + }), + ])); }); it("keeps nested commands with the same basename distinct", () => { diff --git a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts index eb7f1cece..b3e806408 100644 --- a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts +++ b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts @@ -251,22 +251,83 @@ function claudeRootsByPrecedence(cwd: string): string[] { return roots; } +function ancestorSkillRoots(cwd: string, dirName: ".agents" | ".ade"): string[] { + const roots: string[] = []; + const seen = new Set(); + const home = path.resolve(os.homedir()); + let current = path.resolve(cwd); + let depth = 0; + while (depth < 25) { + const candidate = path.join(current, dirName, "skills"); + if (!seen.has(candidate)) { + seen.add(candidate); + roots.push(candidate); + } + const parent = path.dirname(current); + if (parent === current) break; + if (current === home) break; + current = parent; + depth += 1; + } + return roots; +} + +function bundledSkillRoots(): string[] { + const candidates: string[] = []; + if (process.resourcesPath) { + candidates.push(path.join(process.resourcesPath, "agent-skills")); + } + candidates.push(path.resolve(process.cwd(), "resources", "agent-skills")); + candidates.push(path.resolve(process.cwd(), "apps", "desktop", "resources", "agent-skills")); + for (let depth = 0; depth < 8; depth += 1) { + candidates.push(path.resolve(__dirname, ...Array(depth).fill(".."), "resources", "agent-skills")); + candidates.push(path.resolve(__dirname, ...Array(depth).fill(".."), "apps", "desktop", "resources", "agent-skills")); + } + return candidates; +} + +function skillRootsByPrecedence(cwd: string): string[] { + const roots: string[] = []; + const seen = new Set(); + const addRoot = (root: string): void => { + const resolved = path.resolve(root); + if (seen.has(resolved)) return; + seen.add(resolved); + roots.push(resolved); + }; + + for (const root of claudeRootsByPrecedence(cwd)) addRoot(path.join(root, "skills")); + for (const root of ancestorSkillRoots(cwd, ".agents")) addRoot(root); + for (const root of ancestorSkillRoots(cwd, ".ade")) addRoot(root); + addRoot(path.join(os.homedir(), ".agents", "skills")); + addRoot(path.join(os.homedir(), ".ade", "skills")); + for (const root of bundledSkillRoots()) addRoot(root); + return roots; +} + export function discoverClaudeSlashCommands(cwd: string): DiscoveredClaudeSlashCommand[] { - const roots = claudeRootsByPrecedence(cwd); + const claudeRoots = claudeRootsByPrecedence(cwd); const byName = new Map(); - for (const root of roots) { - const discovered = [ - ...discoverLegacyCommands(path.join(root, "commands")), - ...discoverSkills(path.join(root, "skills")), - ]; + for (const root of claudeRoots) { + const discovered = discoverLegacyCommands(path.join(root, "commands")); + for (const command of discovered) { + const key = slashCommandKey(command.name); + if (!byName.has(key)) byName.set(key, command); + } + } + for (const root of skillRootsByPrecedence(cwd)) { + const discovered = discoverSkills(root); for (const command of discovered) { const key = slashCommandKey(command.name); if (!byName.has(key)) byName.set(key, command); } } - return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); + return [...byName.values()].sort((a, b) => { + if (a.source !== b.source) return a.source === "command" ? -1 : 1; + return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); + }); } function resolveSkillFile(skillsDir: string, commandName: string): string | null { @@ -317,17 +378,17 @@ export function resolveClaudeSlashCommandInvocation( const name = match[1]; if (!name) return null; const argumentsText = match[2]?.trim() ?? ""; - const roots = claudeRootsByPrecedence(cwd); + const claudeRoots = claudeRootsByPrecedence(cwd); // Prefer command files; fall back to user-invocable skills (SKILL.md). let resolvedFile: string | null = null; - for (const root of roots) { + for (const root of claudeRoots) { resolvedFile = resolveLegacyCommandFile(path.join(root, "commands"), name); if (resolvedFile) break; } if (!resolvedFile) { - for (const root of roots) { - resolvedFile = resolveSkillFile(path.join(root, "skills"), name); + for (const root of skillRootsByPrecedence(cwd)) { + resolvedFile = resolveSkillFile(root, name); if (resolvedFile) break; } } diff --git a/apps/desktop/src/main/services/files/fileService.test.ts b/apps/desktop/src/main/services/files/fileService.test.ts index 8b0259452..442222080 100644 --- a/apps/desktop/src/main/services/files/fileService.test.ts +++ b/apps/desktop/src/main/services/files/fileService.test.ts @@ -386,6 +386,8 @@ describe("fileService", () => { const service = createFileService({ laneService }); try { + fs.mkdirSync(path.join(rootPath, "lane-1"), { recursive: true }); + fs.mkdirSync(path.join(rootPath, "lane-2"), { recursive: true }); const workspaces = service.listWorkspaces(); expect(workspaces.map((workspace) => workspace.id)).toEqual([ "primary", @@ -397,4 +399,49 @@ describe("fileService", () => { fs.rmSync(rootPath, { recursive: true, force: true }); } }); + + it("does not list missing non-primary workspaces", () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-file-service-missing-workspaces-")); + const laneRoot = path.join(rootPath, "lane-existing"); + const laneService = { + resolveWorkspaceById: vi.fn(), + getFilesWorkspaces: vi.fn(() => [ + { + id: "primary", + kind: "primary", + laneId: null, + name: "Repo", + branchRef: "refs/heads/main", + rootPath, + isReadOnlyByDefault: true, + }, + { + id: "lane-existing", + kind: "worktree", + laneId: "lane-existing", + name: "Existing lane", + branchRef: "refs/heads/existing", + rootPath: laneRoot, + isReadOnlyByDefault: false, + }, + { + id: "lane-missing", + kind: "worktree", + laneId: "lane-missing", + name: "Missing lane", + branchRef: "refs/heads/missing", + rootPath: path.join(rootPath, "missing"), + isReadOnlyByDefault: false, + }, + ]), + } as any; + const service = createFileService({ laneService }); + + try { + fs.mkdirSync(laneRoot, { recursive: true }); + expect(service.listWorkspaces().map((workspace) => workspace.id)).toEqual(["primary", "lane-existing"]); + } finally { + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); }); diff --git a/apps/desktop/src/main/services/files/fileService.ts b/apps/desktop/src/main/services/files/fileService.ts index 76d28376b..7e1a49357 100644 --- a/apps/desktop/src/main/services/files/fileService.ts +++ b/apps/desktop/src/main/services/files/fileService.ts @@ -440,6 +440,13 @@ export function createFileService({ (relPath: string, includeIgnored: boolean) => isIgnoredPath(rootPath, relPath, includeIgnored); const primeIgnoreCacheForRoot = (rootPath: string) => (relPaths: string[], includeIgnored: boolean) => primeIgnoreCache(rootPath, relPaths, includeIgnored); + const workspaceRootExists = (rootPath: string): boolean => { + try { + return fs.existsSync(rootPath) && fs.statSync(rootPath).isDirectory(); + } catch { + return false; + } + }; const emitLaneMutation = (workspaceId: string, reason: string) => { if (!onLaneWorktreeMutation) return; @@ -452,12 +459,14 @@ export function createFileService({ }; const listWorkspaces = (_args: FilesListWorkspacesArgs = {}): FilesWorkspace[] => { - const scopes = [...laneService.getFilesWorkspaces()].sort((a, b) => { - if (a.kind === b.kind) return 0; - if (a.kind === "primary") return -1; - if (b.kind === "primary") return 1; - return 0; - }); + const scopes = [...laneService.getFilesWorkspaces()] + .filter((scope) => scope.kind === "primary" || workspaceRootExists(scope.rootPath)) + .sort((a, b) => { + if (a.kind === b.kind) return 0; + if (a.kind === "primary") return -1; + if (b.kind === "primary") return 1; + return 0; + }); return scopes.map((scope) => ({ id: scope.id, kind: scope.kind, diff --git a/apps/desktop/src/main/services/memory/skillRegistryService.test.ts b/apps/desktop/src/main/services/memory/skillRegistryService.test.ts index 96b7dab1d..00031ee0b 100644 --- a/apps/desktop/src/main/services/memory/skillRegistryService.test.ts +++ b/apps/desktop/src/main/services/memory/skillRegistryService.test.ts @@ -125,6 +125,68 @@ describe("skillRegistryService", () => { expect(markExportedSkill).toHaveBeenCalledWith("proc-1", exported.path); }); + it("quotes exported skill descriptions so trigger punctuation cannot break frontmatter", async () => { + const fixture = await createFixture(); + const service = createSkillRegistryService({ + db: fixture.db, + projectId: fixture.projectId, + projectRoot: fixture.projectRoot, + memoryService: fixture.memoryService, + proceduralLearningService: { + getProcedureDetail: () => ({ + memory: { + id: "proc-yaml", + scope: "project", + scopeOwnerId: null, + tier: 2, + pinned: false, + category: "procedure", + content: "Trigger: fixing quoted branch names", + importance: "high", + createdAt: "2026-03-11T12:00:00.000Z", + updatedAt: "2026-03-11T12:00:00.000Z", + lastAccessedAt: "2026-03-11T12:00:00.000Z", + accessCount: 0, + observationCount: 1, + status: "promoted", + confidence: 0.8, + embedded: false, + sourceRunId: null, + sourceType: "system", + sourceId: null, + fileScopePattern: null, + }, + procedural: { + id: "proc-yaml", + trigger: "fixing \"critical\" bugs: branch main\nwithout breaking YAML", + procedure: "## Recommended Procedure\n1. Keep frontmatter valid.", + confidence: 0.8, + successCount: 1, + failureCount: 0, + sourceEpisodeIds: [], + lastUsed: null, + createdAt: "2026-03-11T12:00:00.000Z", + }, + exportedSkillPath: null, + exportedAt: null, + supersededByMemoryId: null, + sourceEpisodes: [], + confidenceHistory: [], + }), + markExportedSkill: vi.fn(), + markProcedureSuperseded: vi.fn(), + }, + }); + + const exported = await service.exportProcedureSkill({ id: "proc-yaml", name: "YAML Skill" }); + if (!exported) throw new Error("Expected exported skill"); + + const content = fs.readFileSync(exported.path, "utf8"); + expect(content).toContain( + 'description: "Use this skill when fixing \\"critical\\" bugs: branch main\\nwithout breaking YAML."', + ); + }); + it("supersedes near-duplicate system procedures when importing user skills", async () => { const fixture = await createFixture(); const skillPath = path.join(fixture.projectRoot, ".claude", "skills", "testing-guide.md"); @@ -174,4 +236,30 @@ describe("skillRegistryService", () => { expect(superseded?.supersededByMemoryId).toBe(imported.memoryId); expect(duplicateMemory?.status).toBe("archived"); }); + + it("indexes AGENTS.md as a root doc instead of importing it as a procedure", async () => { + const fixture = await createFixture(); + const rootDocPath = path.join(fixture.projectRoot, "AGENTS.md"); + fs.writeFileSync(rootDocPath, "# Project agent instructions\n\nKeep release checks honest.\n", "utf8"); + + const service = createSkillRegistryService({ + db: fixture.db, + projectId: fixture.projectId, + projectRoot: fixture.projectRoot, + memoryService: fixture.memoryService, + proceduralLearningService: fixture.proceduralLearningService, + }); + + const indexed = await service.reindexSkills({ paths: [rootDocPath] }); + const rootDoc = indexed.find((entry) => entry.path === rootDocPath); + + expect(rootDoc?.kind).toBe("root_doc"); + expect(rootDoc?.memoryId).toBeNull(); + expect(fixture.memoryService.listMemories({ + projectId: fixture.projectId, + scope: "project", + categories: ["procedure"], + limit: 10, + })).toHaveLength(0); + }); }); diff --git a/apps/desktop/src/main/services/memory/skillRegistryService.ts b/apps/desktop/src/main/services/memory/skillRegistryService.ts index 0c2b542e9..dc68e5d1a 100644 --- a/apps/desktop/src/main/services/memory/skillRegistryService.ts +++ b/apps/desktop/src/main/services/memory/skillRegistryService.ts @@ -24,9 +24,11 @@ type SkillIndexRow = { const WATCH_PATTERNS = [ ".ade/skills/**/*.md", + ".agents/skills/**/*.md", ".claude/skills/**/*.md", ".claude/commands/**/*.md", "CLAUDE.md", + "AGENTS.md", "agents.md", ]; @@ -49,7 +51,8 @@ function slugify(value: string): string { function inferKind(filePath: string): SkillIndexKind { const normalized = filePath.replace(/\\/g, "/"); - if (normalized.endsWith("/CLAUDE.md") || normalized.endsWith("/agents.md")) return "root_doc"; + const basename = path.basename(normalized); + if (basename === "CLAUDE.md" || basename.toLowerCase() === "agents.md") return "root_doc"; if (normalized.includes("/.claude/commands/")) return "command"; return "skill"; } @@ -139,6 +142,11 @@ function buildSkillMarkdown(input: { ]; const lines = [ + "---", + `name: ${slugify(input.title)}`, + `description: ${yamlQuotedScalar(`Use this skill when ${input.trigger.trim() || "the workflow applies"}.`)}`, + "---", + "", `# ${input.title}`, "", "## When to use", @@ -157,6 +165,10 @@ function buildSkillMarkdown(input: { return lines.join("\n").trim(); } +function yamlQuotedScalar(value: string): string { + return JSON.stringify(value.replace(/\r\n?/g, "\n")); +} + export function createSkillRegistryService(args: { db: AdeDb; projectId: string; @@ -295,7 +307,7 @@ export function createSkillRegistryService(args: { // Root docs (CLAUDE.md, agents.md) should be read directly from disk -- // do NOT import them as procedure memories. - if (kind !== "root_doc") { + if (kind !== "root_doc" && source !== "exported") { const existingMemory = existing?.memory_id ? memoryService.getMemory(existing.memory_id) : null; const importedProcedureContent = buildProcedureBody(title, content, absolutePath); @@ -349,7 +361,7 @@ export function createSkillRegistryService(args: { supersededByMemoryId: memoryId, }); } - } else { + } else if (kind === "root_doc") { // Root doc -- clear any previously-imported memory reference. memoryId = null; } @@ -384,9 +396,10 @@ export function createSkillRegistryService(args: { } }; crawl(path.join(projectRoot, ".ade", "skills")); + crawl(path.join(projectRoot, ".agents", "skills")); crawl(path.join(projectRoot, ".claude", "skills")); crawl(path.join(projectRoot, ".claude", "commands")); - for (const fileName of ["CLAUDE.md", "agents.md"]) { + for (const fileName of ["CLAUDE.md", "AGENTS.md", "agents.md"]) { const absolute = path.join(projectRoot, fileName); if (fs.existsSync(absolute)) discovered.add(path.resolve(absolute)); } @@ -415,7 +428,7 @@ export function createSkillRegistryService(args: { while (fs.existsSync(destinationPath)) { slug = `${slugBase}-${counter}`; counter += 1; - destinationDir = path.join(projectRoot, ".claude", "skills", slug); + destinationDir = path.join(projectRoot, ".ade", "skills", slug); destinationPath = path.join(destinationDir, "SKILL.md"); } fs.mkdirSync(destinationDir, { recursive: true }); diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index d1646819d..2efb3ed5d 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -1101,6 +1101,83 @@ describe("prService.refresh", () => { }); }); +describe("prService.getActionRuns", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("bounds action run history and only hydrates jobs for the newest runs", async () => { + const row = makePrRow({ id: "pr-actions", github_pr_number: 90 }); + const db = makeMockDb(); + db.get.mockImplementation((sql: string, params: unknown[]) => { + const text = String(sql); + if (text.includes("from pull_requests") && text.includes("where id = ?")) { + return params[0] === row.id ? row : null; + } + return null; + }); + const workflowRuns = Array.from({ length: 20 }, (_, index) => ({ + id: index + 1, + name: `run-${index + 1}`, + status: "completed", + conclusion: "success", + html_url: `https://github.com/test-owner/test-repo/actions/runs/${index + 1}`, + created_at: `2026-01-01T00:${String(index).padStart(2, "0")}:00Z`, + updated_at: `2026-01-01T00:${String(index).padStart(2, "0")}:30Z`, + })).reverse(); + const githubService = makeGithubService({ + apiRequest: vi.fn(async (args: { path: string }) => { + if (args.path === "/repos/test-owner/test-repo/pulls/90") { + return { data: makeGitHubPull({ number: 90, head: { ref: "my-feature", sha: "head-sha" } }) }; + } + if (args.path === "/repos/test-owner/test-repo/actions/runs") { + return { data: { workflow_runs: workflowRuns } }; + } + const jobMatch = args.path.match(/\/actions\/runs\/(\d+)\/jobs$/); + if (jobMatch) { + const runId = Number(jobMatch[1]); + return { + data: { + jobs: [{ + id: runId * 100, + name: `job-${runId}`, + status: "completed", + conclusion: "success", + steps: [], + }], + }, + }; + } + throw new Error(`Unexpected GitHub API path: ${args.path}`); + }), + }); + const { service } = buildService({ db, githubService }); + + const runs = await service.getActionRuns("pr-actions"); + + expect(runs).toHaveLength(12); + expect(runs[0]?.jobs).toHaveLength(1); + expect(runs[5]?.jobs).toHaveLength(1); + expect(runs[6]?.jobs).toHaveLength(0); + const calls: Array<{ path: string; query?: Record }> = + (githubService.apiRequest as ReturnType).mock.calls.map((call: unknown[]) => + call[0] as { path: string; query?: Record } + ); + expect(calls.find((call) => call.path === "/repos/test-owner/test-repo/actions/runs")?.query).toEqual({ + head_sha: "head-sha", + per_page: 12, + }); + expect(calls.filter((call) => /\/actions\/runs\/\d+\/jobs$/.test(call.path)).map((call) => call.path)).toEqual([ + "/repos/test-owner/test-repo/actions/runs/20/jobs", + "/repos/test-owner/test-repo/actions/runs/19/jobs", + "/repos/test-owner/test-repo/actions/runs/18/jobs", + "/repos/test-owner/test-repo/actions/runs/17/jobs", + "/repos/test-owner/test-repo/actions/runs/16/jobs", + "/repos/test-owner/test-repo/actions/runs/15/jobs", + ]); + }); +}); + describe("prService merge contexts", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 05b2d816e..c9ae1c696 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -179,6 +179,8 @@ type LanePrLookupRow = { }; const SQL_IN_CLAUSE_CHUNK_SIZE = 900; +const PR_ACTION_RUNS_LIMIT = 12; +const PR_ACTION_RUN_JOBS_LIMIT = 6; function chunkValues(values: readonly T[], size = SQL_IN_CLAUSE_CHUNK_SIZE): T[][] { const chunks: T[][] = []; @@ -6421,40 +6423,45 @@ export function createPrService({ const { data: runsData } = await githubService.apiRequest({ method: "GET", path: `/repos/${repo.owner}/${repo.name}/actions/runs`, - query: { head_sha: headSha, per_page: 100 } + query: { head_sha: headSha, per_page: PR_ACTION_RUNS_LIMIT } }); - const rawRuns: any[] = Array.isArray(runsData?.workflow_runs) ? runsData.workflow_runs : []; + const rawRuns: any[] = Array.isArray(runsData?.workflow_runs) + ? runsData.workflow_runs.slice(0, PR_ACTION_RUNS_LIMIT) + : []; const runs: PrActionRun[] = await Promise.all( - rawRuns.map(async (run: any): Promise => { + rawRuns.map(async (run: any, index): Promise => { const runId = Number(run?.id); let jobs: PrActionJob[] = []; - try { - const { data: jobsData } = await githubService.apiRequest({ - method: "GET", - path: `/repos/${repo.owner}/${repo.name}/actions/runs/${runId}/jobs` - }); - const rawJobs: any[] = Array.isArray(jobsData?.jobs) ? jobsData.jobs : []; - jobs = rawJobs.map((j: any): PrActionJob => ({ - id: Number(j?.id) || 0, - name: asString(j?.name) || "", - status: toJobStatus(j?.status), - conclusion: toJobConclusion(j?.conclusion), - startedAt: asString(j?.started_at) || null, - completedAt: asString(j?.completed_at) || null, - steps: Array.isArray(j?.steps) - ? j.steps.map((st: any): PrActionStep => ({ - name: asString(st?.name) || "", - status: toJobStatus(st?.status), - conclusion: toJobConclusion(st?.conclusion), - number: Number(st?.number) || 0, - startedAt: asString(st?.started_at) || null, - completedAt: asString(st?.completed_at) || null - })) - : [] - })); - } catch { - // Jobs fetch failed; return empty jobs array + if (runId > 0 && index < PR_ACTION_RUN_JOBS_LIMIT) { + try { + const { data: jobsData } = await githubService.apiRequest({ + method: "GET", + path: `/repos/${repo.owner}/${repo.name}/actions/runs/${runId}/jobs`, + query: { per_page: 100 } + }); + const rawJobs: any[] = Array.isArray(jobsData?.jobs) ? jobsData.jobs : []; + jobs = rawJobs.map((j: any): PrActionJob => ({ + id: Number(j?.id) || 0, + name: asString(j?.name) || "", + status: toJobStatus(j?.status), + conclusion: toJobConclusion(j?.conclusion), + startedAt: asString(j?.started_at) || null, + completedAt: asString(j?.completed_at) || null, + steps: Array.isArray(j?.steps) + ? j.steps.map((st: any): PrActionStep => ({ + name: asString(st?.name) || "", + status: toJobStatus(st?.status), + conclusion: toJobConclusion(st?.conclusion), + number: Number(st?.number) || 0, + startedAt: asString(st?.started_at) || null, + completedAt: asString(st?.completed_at) || null + })) + : [] + })); + } catch { + // Jobs fetch failed; return empty jobs array + } } return { id: runId, diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts index 672a59338..80ae3dab9 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts @@ -449,6 +449,50 @@ describe("RemoteConnectionPool", () => { }); }); + it("retries project-scoped sync reads once when the connection closes during the request", async () => { + const firstClient = createClient(); + const firstSsh = createSsh(); + firstClient.call.mockRejectedValueOnce( + new Error("Remote ADE service connection closed."), + ); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: firstClient, + ssh: firstSsh, + result: connectResult("1.0.0"), + }); + const secondClient = createClient(); + secondClient.call.mockResolvedValueOnce({ + pairingPin: "654321", + connectedPeers: [{ id: "phone-1" }], + }); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: secondClient, + ssh: createSsh(), + result: connectResult("1.0.1"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + await expect( + pool.callSyncForTarget(target, "project-1", "sync.getStatus", { + includeTransferReadiness: true, + }), + ).resolves.toEqual({ + pairingPin: "654321", + connectedPeers: [{ id: "phone-1" }], + }); + + expect(firstSsh.end).toHaveBeenCalledTimes(1); + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + expect(firstClient.call).toHaveBeenCalledWith("sync.getStatus", { + projectId: "project-1", + includeTransferReadiness: true, + }); + expect(secondClient.call).toHaveBeenCalledWith("sync.getStatus", { + projectId: "project-1", + includeTransferReadiness: true, + }); + }); + it("subscribes to runtime event notifications and unsubscribes on cleanup", async () => { const client = createClient(); client.call.mockImplementation(async (method: string) => { diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts index 76718bcac..c93faa4ec 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts @@ -142,11 +142,14 @@ export class RemoteConnectionPool { method: string, params: Record = {}, ): Promise { - const entry = await this.connectEntry(target); - return await entry.client.call(method, { - ...params, - projectId, - }); + return await this.withEntryForTarget( + target, + (entry) => entry.client.call(method, { + ...params, + projectId, + }), + { retryOnConnectionError: true }, + ); } private async addProjectWithEntry( diff --git a/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx b/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx index 31ee35ab0..a80142f68 100644 --- a/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx +++ b/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx @@ -15,6 +15,8 @@ import { LinearIssueBrowser, linearBrowserIssueToLaneIssue } from "./LinearIssue type PopoverPosition = { top: number; right: number } | null; +const INITIAL_VISIBILITY_CHECK_DELAY_MS = 8_000; + export function LinearQuickViewButton() { const project = useAppStore((s) => s.project); const refreshLanes = useAppStore((s) => s.refreshLanes); @@ -42,15 +44,18 @@ export function LinearQuickViewButton() { setVisible(false); setOpen(false); setQuickView(null); - void loadVisibility() + const timer = window.setTimeout(() => { + void loadVisibility() .then((nextVisible) => { if (!cancelled) setVisible(nextVisible); }) .catch(() => { if (!cancelled) setVisible(false); }); + }, INITIAL_VISIBILITY_CHECK_DELAY_MS); return () => { cancelled = true; + window.clearTimeout(timer); }; }, [loadVisibility, project?.rootPath]); diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index faf078181..bba09ebae 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -143,6 +143,14 @@ function fireProjectTabDragEnd( fireEvent(element, event); } +async function advancePhoneSyncStartupDelay() { + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + await Promise.resolve(); + }); +} + describe("TopBar", () => { const originalAde = globalThis.window.ade; @@ -360,24 +368,32 @@ describe("TopBar", () => { }); it("opens the phone sync drawer from the host status control", async () => { - render(); + vi.useFakeTimers(); + try { + render(); - expect(await screen.findByText("1 phone connected to ADE Desktop")).toBeTruthy(); + expect(screen.getByText("Phone sync")).toBeTruthy(); + expect(globalThis.window.ade.sync.getStatus).not.toHaveBeenCalled(); - fireEvent.click(screen.getByTitle("Connect a phone to this machine")); + await advancePhoneSyncStartupDelay(); + expect(screen.getByText("1 phone connected to ADE Desktop")).toBeTruthy(); - expect(screen.getByText("Connect to the ADE mobile app")).toBeTruthy(); - expect(screen.getByTestId("sync-devices-section")).toBeTruthy(); - expect(screen.getByTitle("Connect a phone to this machine").getAttribute("aria-expanded")).toBe("true"); + fireEvent.click(screen.getByTitle("Connect a phone to this machine")); - fireEvent.click(screen.getByTitle("Close phone sync")); + expect(screen.getByText("Connect to the ADE mobile app")).toBeTruthy(); + expect(screen.getByTestId("sync-devices-section")).toBeTruthy(); + expect(screen.getByTitle("Connect a phone to this machine").getAttribute("aria-expanded")).toBe("true"); + + fireEvent.click(screen.getByTitle("Close phone sync")); - await waitFor(() => { expect(screen.queryByTestId("sync-devices-section")).toBeNull(); - }); + } finally { + vi.useRealTimers(); + } }); it("refreshes the phone sync label from global sync events", async () => { + vi.useFakeTimers(); let syncEventHandler: ((event: any) => void) | null = null; const getStatus = vi.fn() .mockResolvedValueOnce(makeSyncSnapshot({ connectedPeers: [] })); @@ -389,25 +405,31 @@ describe("TopBar", () => { }; }) as any; - render(); + try { + render(); - expect(await screen.findByText("Phone sync ready")).toBeTruthy(); + await advancePhoneSyncStartupDelay(); + expect(screen.getByText("Phone sync ready")).toBeTruthy(); - await act(async () => { - syncEventHandler?.({ - type: "sync-status", - snapshot: makeSyncSnapshot({ - connectedPeers: [ - { deviceId: "phone-1", deviceName: "Arul iPhone", platform: "iOS", deviceType: "phone" }, - ], - }), + await act(async () => { + syncEventHandler?.({ + type: "sync-status", + snapshot: makeSyncSnapshot({ + connectedPeers: [ + { deviceId: "phone-1", deviceName: "Arul iPhone", platform: "iOS", deviceType: "phone" }, + ], + }), + }); }); - }); - expect(await screen.findByText("1 phone connected to ADE Desktop")).toBeTruthy(); + expect(screen.getByText("1 phone connected to ADE Desktop")).toBeTruthy(); + } finally { + vi.useRealTimers(); + } }); it("labels disabled local runtime sync as unavailable", async () => { + vi.useFakeTimers(); const snapshot = makeSyncSnapshot(); globalThis.window.ade.sync.getStatus = vi.fn(async () => makeSyncSnapshot({ connectedPeers: [], @@ -420,10 +442,15 @@ describe("TopBar", () => { }, })) as any; - render(); + try { + render(); - expect(await screen.findByText("Phone sync unavailable")).toBeTruthy(); - expect(screen.queryByText("Phone sync ready")).toBeNull(); + await advancePhoneSyncStartupDelay(); + expect(screen.getByText("Phone sync unavailable")).toBeTruthy(); + expect(screen.queryByText("Phone sync ready")).toBeNull(); + } finally { + vi.useRealTimers(); + } }); it("does not refresh phone sync status on an idle interval", async () => { @@ -438,7 +465,7 @@ describe("TopBar", () => { await Promise.resolve(); }); - expect(getStatus).toHaveBeenCalledTimes(1); + expect(getStatus).not.toHaveBeenCalled(); await act(async () => { vi.advanceTimersByTime(15_000); @@ -452,21 +479,34 @@ describe("TopBar", () => { }); it("refreshes phone sync status when the window regains focus", async () => { + vi.useFakeTimers(); const getStatus = vi.fn() .mockResolvedValueOnce(makeSyncSnapshot({ connectedPeers: [] })) .mockResolvedValueOnce(makeSyncSnapshot()); globalThis.window.ade.sync.getStatus = getStatus as any; - render(); + try { + render(); - expect(await screen.findByText("Phone sync ready")).toBeTruthy(); + await act(async () => { + window.dispatchEvent(new Event("focus")); + await Promise.resolve(); + await Promise.resolve(); + }); - await act(async () => { - window.dispatchEvent(new Event("focus")); - }); + expect(screen.getByText("Phone sync ready")).toBeTruthy(); - expect(await screen.findByText("1 phone connected to ADE Desktop")).toBeTruthy(); - expect(getStatus).toHaveBeenCalledTimes(2); + await act(async () => { + window.dispatchEvent(new Event("focus")); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(screen.getByText("1 phone connected to ADE Desktop")).toBeTruthy(); + expect(getStatus).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } }); it("opens Linear quick view and creates a linked lane from an issue", async () => { @@ -566,6 +606,11 @@ describe("TopBar", () => { render(); + await act(async () => { + window.dispatchEvent(new Event("focus")); + await Promise.resolve(); + }); + fireEvent.click(await screen.findByRole("button", { name: /linear quick view/i })); await waitFor(() => { @@ -617,6 +662,13 @@ describe("TopBar", () => { render(); + expect(getLinearConnectionStatus).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: /linear quick view/i })).toBeNull(); + + await act(async () => { + window.dispatchEvent(new Event("focus")); + await Promise.resolve(); + }); await waitFor(() => { expect(getLinearConnectionStatus).toHaveBeenCalledTimes(1); }); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index b5973da0f..7227922c7 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -66,6 +66,7 @@ const projectIconCache = new Map(); const PROJECT_ICON_ACCENT_CACHE_MAX = 48; const projectIconAccentCache = new Map(); const RECENT_PROJECTS_CACHE_TTL_MS = 2_500; +const PHONE_SYNC_STARTUP_DELAY_MS = 5_000; let recentProjectsCache: | { rows: RecentProjectSummary[]; fetchedAtMs: number } | null = null; @@ -690,6 +691,7 @@ export function TopBar() { const connectedRemoteCount = remoteSnapshot?.connectedCount ?? 0; const remoteButtonLabel = connectedRemoteCount > 0 ? `Remote ${connectedRemoteCount}` : "Remote"; + const showSyncControl = workspaceProjectOpen; const applyZoom = useCallback((pct: number) => { const clamped = Math.max(MIN_ZOOM_LEVEL, Math.min(MAX_ZOOM_LEVEL, pct)); @@ -852,6 +854,9 @@ export function TopBar() { useEffect(() => { let cancelled = false; let statusRequestVersion = 0; + let started = false; + let startupTimer: number | null = null; + let disposeSyncEvents: (() => void) | null = null; if (!project?.rootPath || remoteBinding) { setSyncSnapshot(null); setPhoneSyncOpen(false); @@ -873,24 +878,40 @@ export function TopBar() { }); }; setSyncSnapshot(null); - refreshSyncStatus(); - window.addEventListener("focus", refreshSyncStatus); - const dispose = window.ade.sync.onEvent((event) => { - if (!cancelled && event.type === "sync-status") { - statusRequestVersion += 1; - setSyncSnapshot(event.snapshot); + const startSyncStatus = () => { + if (cancelled || started) return; + started = true; + refreshSyncStatus(); + disposeSyncEvents = window.ade.sync.onEvent((event) => { + if (!cancelled && event.type === "sync-status") { + statusRequestVersion += 1; + setSyncSnapshot(event.snapshot); + } + }); + }; + const onFocus = () => { + if (started) { + refreshSyncStatus(); + } else { + startSyncStatus(); } - }); + }; + startupTimer = window.setTimeout( + startSyncStatus, + phoneSyncOpen ? 0 : PHONE_SYNC_STARTUP_DELAY_MS, + ); + window.addEventListener("focus", onFocus); return () => { cancelled = true; - window.removeEventListener("focus", refreshSyncStatus); - dispose(); + if (startupTimer != null) window.clearTimeout(startupTimer); + window.removeEventListener("focus", onFocus); + disposeSyncEvents?.(); }; // Background projects don't broadcast sync-status events (main.ts filters // them to the active project), so we re-run this effect on rootPath change - // to force an immediate refetch. Focus refresh covers state changes that - // happen while ADE is not active. - }, [project?.rootPath, remoteBinding]); + // and let the delayed startup check pick up the current state. Focus and + // explicit drawer opens still refresh immediately. + }, [phoneSyncOpen, project?.rootPath, remoteBinding]); const checkForActiveWorkloads = useCallback( async (projectRootPath: string): Promise => { @@ -1334,7 +1355,7 @@ export function TopBar() { [], ); - const syncLabel = deriveSyncLabel(syncSnapshot); + const syncLabel = deriveSyncLabel(syncSnapshot) ?? "Phone sync"; const transitionTargetName = projectTransition?.rootPath ? (projectTabs.find( (entry) => entry.rootPath === projectTransition.rootPath, @@ -1832,7 +1853,7 @@ export function TopBar() { {remoteButtonLabel} - {syncSnapshot && syncLabel ? ( + {showSyncControl ? (