From a3abe3f39dd3ea8003d84743a541486c69cd91da Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:59:11 -0400 Subject: [PATCH 1/5] feat: add projectless personal chats --- apps/ade-cli/README.md | 18 + apps/ade-cli/src/bootstrap.ts | 87 +-- apps/ade-cli/src/cli.test.ts | 183 ++++++ apps/ade-cli/src/cli.ts | 269 +++++++- .../ade-cli/src/multiProjectRpcServer.test.ts | 49 ++ apps/ade-cli/src/multiProjectRpcServer.ts | 16 + apps/ade-cli/src/services/imageAttachment.ts | 107 ++++ .../personalChats/personalChatScope.test.ts | 226 +++++++ .../personalChats/personalChatScope.ts | 392 ++++++++++++ .../src/services/projects/machineLayout.ts | 6 + .../sync/brainProjectActionsSyncHandler.ts | 158 ++++- .../src/services/sync/syncHostService.test.ts | 130 ++++ .../src/services/sync/syncHostService.ts | 38 +- .../sync/syncRemoteCommandService.test.ts | 48 ++ .../services/sync/syncRemoteCommandService.ts | 151 +---- apps/ade-cli/src/services/sync/syncService.ts | 4 + .../builtInBrowserService.test.ts | 31 + .../builtInBrowser/builtInBrowserService.ts | 29 + .../main/services/chat/agentChatService.ts | 137 ++-- .../src/main/services/ipc/registerIpc.ts | 8 + .../main/services/ipc/runtimeBridge.test.ts | 55 ++ .../src/main/services/ipc/runtimeBridge.ts | 65 ++ .../src/main/services/lanes/laneService.ts | 11 +- .../remoteRuntime/remoteConnectionService.ts | 10 + apps/desktop/src/preload/global.d.ts | 12 + apps/desktop/src/preload/preload.ts | 14 + apps/desktop/src/renderer/browserMock.ts | 54 ++ .../src/renderer/components/app/App.tsx | 18 +- .../components/app/App.workKeepAlive.test.tsx | 30 + .../src/renderer/components/app/AppShell.tsx | 6 +- .../renderer/components/app/TabNav.test.tsx | 17 +- .../src/renderer/components/app/TabNav.tsx | 25 + .../renderer/components/app/TopBar.test.tsx | 13 + .../src/renderer/components/app/TopBar.tsx | 35 +- .../chat/ChatBuiltInBrowserPanel.tsx | 21 +- .../personalChats/PersonalChatsPage.tsx | 564 +++++++++++++++++ .../personalChats/PersonalTerminalPanel.tsx | 209 +++++++ .../src/renderer/components/run/RunPage.tsx | 22 +- .../desktop/src/renderer/lib/webClientMode.ts | 2 +- .../adapter/__tests__/adapter.test.ts | 75 ++- .../renderer/webclient/adapter/agentChat.ts | 4 +- .../src/renderer/webclient/adapter/index.ts | 2 + .../webclient/adapter/personalChats.ts | 122 ++++ .../webclient/shell/ProjectPicker.tsx | 21 + .../webclient/shell/WebClientRoot.tsx | 30 +- .../src/renderer/webclient/shell/WebShell.tsx | 10 + apps/desktop/src/shared/ipc.ts | 2 + .../src/shared/types/builtInBrowser.ts | 2 + apps/desktop/src/shared/types/chat.ts | 2 +- apps/desktop/src/shared/types/index.ts | 1 + .../desktop/src/shared/types/personalChats.ts | 154 +++++ apps/desktop/src/shared/types/sync.ts | 6 + apps/ios/ADE.xcodeproj/project.pbxproj | 12 + apps/ios/ADE/Models/RemoteModels.swift | 3 + apps/ios/ADE/Services/SyncService.swift | 391 +++++++++++- apps/ios/ADE/Views/Hub/HubComponents.swift | 76 +++ apps/ios/ADE/Views/Hub/HubScreen.swift | 31 +- .../PersonalChats/PersonalChatsScreen.swift | 583 ++++++++++++++++++ .../Views/Work/WorkChatAttachmentTray.swift | 31 + .../Work/WorkChatHeaderAndMessageViews.swift | 33 +- .../ADE/Views/Work/WorkChatSessionView.swift | 25 +- .../ADE/Views/Work/WorkModelPickerSheet.swift | 63 +- .../WorkSessionDestinationView+Actions.swift | 42 +- .../Work/WorkSessionDestinationView.swift | 116 ++-- apps/ios/ADETests/ADETests.swift | 174 ++++++ docs/ARCHITECTURE.md | 71 ++- docs/PRD.md | 14 +- docs/README.md | 3 +- docs/features/agents/README.md | 33 +- docs/features/chat/README.md | 11 + docs/features/personal-chats/README.md | 168 +++++ docs/features/remote-runtime/README.md | 5 +- docs/features/sync-and-multi-device/README.md | 30 +- .../sync-and-multi-device/ios-companion.md | 29 +- .../sync-and-multi-device/remote-commands.md | 29 +- .../features/terminals-and-sessions/README.md | 7 + docs/features/web-client/README.md | 18 +- 77 files changed, 5273 insertions(+), 426 deletions(-) create mode 100644 apps/ade-cli/src/services/imageAttachment.ts create mode 100644 apps/ade-cli/src/services/personalChats/personalChatScope.test.ts create mode 100644 apps/ade-cli/src/services/personalChats/personalChatScope.ts create mode 100644 apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.tsx create mode 100644 apps/desktop/src/renderer/components/personalChats/PersonalTerminalPanel.tsx create mode 100644 apps/desktop/src/renderer/webclient/adapter/personalChats.ts create mode 100644 apps/desktop/src/shared/types/personalChats.ts create mode 100644 apps/ios/ADE/Views/PersonalChats/PersonalChatsScreen.swift create mode 100644 docs/features/personal-chats/README.md diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index c4deacafb..d90fafb59 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -23,6 +23,7 @@ Default routing for typed commands: prefer the machine brain endpoint if reachab | `$ADE_HOME/sock/ade.sock` | ADE brain local endpoint (POSIX). | | `\\.\pipe\ade-runtime` | ADE runtime named-pipe endpoint (Windows). | | `$ADE_HOME/projects.json` | Project catalog. | +| `$ADE_HOME/personal-chats/` | Machine-owned projectless chat runtime state, hidden workspace, transcripts, and attachments. | | `~/.ade/secrets/` | Machine credential store (`credentials.safe.enc` for desktop safeStorage, `credentials.json.enc` plus `.machine-key` for headless fallback storage, and per-store `*.lock` files). | | `~/.ade/bin/ade` | Bundled static runtime binary (release installs / remote uploads). | | `~/.ade/agent-skills/` | Bundled, version-locked ADE agent skills. Desktop remote bootstrap uploads this beside the remote runtime; CLI launch then re-seeds ADE-managed skills into runtime-native home skill directories. | @@ -182,6 +183,7 @@ The runtime exposes two layers of JSON-RPC methods (`src/multiProjectRpcServer.t ade/initialize ade/initialized ping shutdown exit runtime/info machineInfo.get projects.list projects.add projects.remove projects.touch +personalChats.call personalChats.streamEvents runtimeEvents.subscribe runtimeEvents.unsubscribe sync.getStatus sync.refreshDiscovery sync.listDevices sync.updateLocalDevice @@ -196,6 +198,17 @@ sync.getRequireDpop sync.setRequireDpop `runtimeEvents.subscribe` returns `eventEpoch`, `nextCursor`, `hasMore`, `gap`, and `oldestCursor`; when `gap` is true, the caller's cursor predates the retained buffer and it should refresh state before resuming from `oldestCursor` / `nextCursor`. +`personalChats.call` dispatches the machine action registry advertised as +`capabilities.personalChats` during initialization. It owns chats outside every +project and includes lifecycle, model, input/approval, attachment, and personal +terminal actions. Typed CLI commands use `ade chat … --personal`; use +`ade chat actions --personal` and `ade chat action --personal +--input-json '{...}'` for the complete low-level registry. These commands require +the machine brain (which can run headlessly without desktop UI) and also work +through the `ade rpc --stdio` transport used by remote desktops. The one-shot +global `--headless` mode is not supported because its in-process runtime exits +with the command. + **Project-scoped** — every other request must carry `params.projectId`. `ade/actions/call` (and the legacy ADE action / tool catalog underneath it) is dispatched into the per-project `ProjectScope` returned by `ProjectScopeRegistry.get(projectId)`. `ade/initialize` advertises `runtimeInfo.multiProject: true` and `capabilities.projects: true`. Clients use that to switch between sending `projectId` per request (multi-project runtime) and the legacy per-process binding (embedded runtime). Sync is owned by the sync service for the most-recently-opened registered project; `ProjectScopeRegistry.ensureSyncHost` refreshes the active sync project when projects are added or removed. @@ -266,6 +279,11 @@ ade lanes create-from-linear --issue-id ENG-431 --start-chat --provider codex -- ade lanes batch-create-from-linear --linear-issues-json '[{"id":"...","identifier":"ENG-431"},{"id":"...","identifier":"ENG-440"}]' ade chat attach-linear-issue --issue-id ENG-431 ade chat create --from-linear-issue ENG-431 +ade chat list --personal --text +ade chat create --personal --provider codex --model openai/gpt-5.5 --prompt "Plan a trip" +ade chat steer personal-session-id --personal --text "focus on the tradeoffs" +ade chat actions --personal --text +ade chat action --personal modelCatalog --input-json '{"mode":"cached"}' --json ade linear attach --this-session --issue-id ENG-431 # attach to the current CLI session ($ADE_CHAT_SESSION_ID) ade linear comment "Pushed a fix; CI running" # write back through the attached runtime ade linear set-state ENG-431 diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 137292e95..152e36ef1 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -158,6 +158,7 @@ export type AdeRuntimeSyncOptions = { projectCatalogProvider?: Parameters[0]["projectCatalogProvider"]; rosterProvider?: Parameters[0]["rosterProvider"]; foreignChatProvider?: Parameters[0]["foreignChatProvider"]; + personalChatScope?: Parameters[0]["personalChatScope"]; remoteCommandExecutor?: Parameters[0]["remoteCommandExecutor"]; /** * Brain-level websocket listener shared by every project scope's sync host @@ -420,8 +421,11 @@ function createHeadlessAdeCliAgentEnv(baseEnv: NodeJS.ProcessEnv = process.env): export async function createAdeRuntime(args: { projectRoot: string; workspaceRoot?: string; + primaryWorktreePath?: string; chatRuntime?: "headless-stub" | "agent"; runtimeProfile?: "full" | "chat"; + /** Disable project-oriented push/deep-link events for machine-scoped runtimes. */ + publishPushEvents?: boolean; syncRuntime?: AdeRuntimeSyncOptions; } | string): Promise { const resolvedArgs = typeof args === "string" @@ -429,13 +433,18 @@ export async function createAdeRuntime(args: { : args; const projectRoot = path.resolve(resolvedArgs.projectRoot); const workspaceRoot = path.resolve(resolvedArgs.workspaceRoot ?? resolvedArgs.projectRoot); + const primaryWorktreePath = path.resolve(resolvedArgs.primaryWorktreePath ?? resolvedArgs.projectRoot); const chatOnlyRuntime = resolvedArgs.runtimeProfile === "chat"; + const publishPushEvents = resolvedArgs.publishPushEvents !== false; if (!fs.existsSync(projectRoot) || !fs.statSync(projectRoot).isDirectory()) { throw new Error(`Project root does not exist: ${projectRoot}`); } if (!fs.existsSync(workspaceRoot) || !fs.statSync(workspaceRoot).isDirectory()) { throw new Error(`Workspace root does not exist: ${workspaceRoot}`); } + if (!fs.existsSync(primaryWorktreePath) || !fs.statSync(primaryWorktreePath).isDirectory()) { + throw new Error(`Primary worktree path does not exist: ${primaryWorktreePath}`); + } const hadAdeDb = fs.existsSync(path.join(projectRoot, ".ade", "ade.db")); const baseRef = await detectDefaultBaseRef(projectRoot); @@ -492,6 +501,7 @@ export async function createAdeRuntime(args: { const laneService = createLaneService({ db, projectRoot, + primaryWorktreePath, projectId, defaultBaseRef: baseRef, worktreesDir: paths.worktreesDir, @@ -1238,42 +1248,46 @@ export async function createAdeRuntime(args: { machineName: os.hostname(), }; }); - const detachPushSources = pushPublisherService.attachSources(projectId, { - agentChatService: agentChatService ?? null, - ptyService, - subscribePrNotifications: (cb) => { - pushPrNotificationSubscribers.add(cb); - return () => pushPrNotificationSubscribers.delete(cb); - }, - resolveLaneName: (laneId) => { - try { - const row = db.get<{ name: string }>( - "select name from lanes where id = ? and project_id = ? limit 1", - [laneId, projectId], - ); - return row?.name ?? null; - } catch { - return null; - } - }, - resolveCliSession: (sessionId) => { - try { - const session = sessionService.get(sessionId); - if (!session) return null; - return { - title: session.title ?? null, - toolType: session.toolType ?? null, - chatSessionId: session.chatSessionId ?? null, - }; - } catch { - return null; - } - }, - }); - pushPublisherForPtySignals = pushPublisherService; - void pushPublisherService.start().catch((error) => { - logger.warn("push.start_failed", { error: error instanceof Error ? error.message : String(error) }); - }); + const detachPushSources = publishPushEvents + ? pushPublisherService.attachSources(projectId, { + agentChatService: agentChatService ?? null, + ptyService, + subscribePrNotifications: (cb) => { + pushPrNotificationSubscribers.add(cb); + return () => pushPrNotificationSubscribers.delete(cb); + }, + resolveLaneName: (laneId) => { + try { + const row = db.get<{ name: string }>( + "select name from lanes where id = ? and project_id = ? limit 1", + [laneId, projectId], + ); + return row?.name ?? null; + } catch { + return null; + } + }, + resolveCliSession: (sessionId) => { + try { + const session = sessionService.get(sessionId); + if (!session) return null; + return { + title: session.title ?? null, + toolType: session.toolType ?? null, + chatSessionId: session.chatSessionId ?? null, + }; + } catch { + return null; + } + }, + }) + : () => {}; + if (publishPushEvents) { + pushPublisherForPtySignals = pushPublisherService; + void pushPublisherService.start().catch((error) => { + logger.warn("push.start_failed", { error: error instanceof Error ? error.message : String(error) }); + }); + } const usageTrackingService = createUsageTrackingService({ logger, @@ -1374,6 +1388,7 @@ export async function createAdeRuntime(args: { projectCatalogProvider: resolvedArgs.syncRuntime.projectCatalogProvider, rosterProvider: resolvedArgs.syncRuntime.rosterProvider, foreignChatProvider: resolvedArgs.syncRuntime.foreignChatProvider, + personalChatScope: resolvedArgs.syncRuntime.personalChatScope, remoteCommandExecutor: resolvedArgs.syncRuntime.remoteCommandExecutor, getModelPickerStore: () => getSharedModelPickerStore(db), cloudRelayStore, diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 778b66a15..78a95dc96 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -2205,6 +2205,189 @@ describe("ADE CLI", () => { ).toThrow(/Use either --include-archived or --no-archived/); }); + it("builds complete machine-only personal chat plans without project registration", async () => { + const list = expectExecutePlan(buildCliPlan([ + "chat", + "list", + "--personal", + "--no-archived", + ])); + expect(list).toMatchObject({ + machineOnly: true, + label: "personal chat list", + formatter: "chat-list", + steps: [{ + method: "personalChats.call", + params: { action: "list", args: { includeArchived: false } }, + }], + }); + expect(shouldAutoRegisterProjectForPlan(list)).toBe(false); + + const create = expectExecutePlan(buildCliPlan([ + "chat", + "create", + "--personal", + "--provider", + "codex", + "--model", + "openai/gpt-5.5", + "--prompt", + "Plan a trip", + ])); + expect(create.steps).toEqual([{ + key: "result", + method: "personalChats.call", + params: { + action: "create", + args: { + provider: "codex", + model: "openai/gpt-5.5", + modelId: "openai/gpt-5.5", + title: null, + reasoningEffort: null, + permissionMode: null, + kickoffText: "Plan a trip", + }, + }, + }]); + + const send = expectExecutePlan(buildCliPlan([ + "chat", + "send", + "personal-1", + "--personal", + "--text", + "hello", + ])); + expect(send.steps[0]).toMatchObject({ + method: "personalChats.call", + params: { + action: "send", + args: { sessionId: "personal-1", text: "hello" }, + }, + }); + + const actions = expectStaticPlan(buildCliPlan(["chat", "actions", "--personal"])); + expect(actions.value).toMatchObject({ + actions: expect.arrayContaining([ + expect.objectContaining({ name: "personalChats.create" }), + expect.objectContaining({ name: "personalChats.saveTempAttachment" }), + expect.objectContaining({ name: "personalChats.terminalDispose" }), + ]), + }); + expect(formatOutput(actions.value, { + ...baseResolveOpts(), + projectRoot: null, + workspaceRoot: null, + text: true, + }, actions.formatter)).toContain( + "ade chat action --personal ", + ); + + const rawTerminalDispose = expectExecutePlan(buildCliPlan([ + "chat", + "action", + "--personal", + "terminalDispose", + "--input-json", + '{"ptyId":"pty-1","sessionId":"terminal-1"}', + ])); + expect(rawTerminalDispose.steps[0]).toEqual({ + key: "result", + method: "personalChats.call", + params: { + action: "terminalDispose", + args: { ptyId: "pty-1", sessionId: "terminal-1" }, + }, + }); + + const steer = expectExecutePlan(buildCliPlan([ + "chat", + "steer", + "personal-1", + "--personal", + "--text", + "focus", + "--image-url", + "https://example.test/image.png", + ])); + expect(steer.steps[0]).toMatchObject({ + params: { + action: "steer", + args: { + sessionId: "personal-1", + text: "focus", + attachments: [{ + type: "image-url", + url: "https://example.test/image.png", + path: "https://example.test/image.png", + }], + }, + }, + }); + + const models = expectExecutePlan(buildCliPlan([ + "chat", + "models", + "--personal", + "--provider", + "codex", + ])); + expect(models.steps[0]).toMatchObject({ + params: { action: "models", args: { provider: "codex" } }, + }); + + const update = expectExecutePlan(buildCliPlan([ + "chat", + "update", + "personal-1", + "--personal", + "--title", + "Trip planning", + "--reasoning-effort", + "high", + "--fast", + ])); + expect(update.steps[0]).toMatchObject({ + params: { + action: "updateSession", + args: { + sessionId: "personal-1", + title: "Trip planning", + reasoningEffort: "high", + fastMode: true, + }, + }, + }); + + expect(() => buildCliPlan([ + "chat", + "list", + "--personal", + "--lane", + "lane-1", + ])).toThrow(/cannot be combined with --lane/); + expect(() => buildCliPlan([ + "chat", + "create", + "--personal", + "--provider", + "codex", + ])).toThrow(/model is required/); + expect(() => buildCliPlan([ + "chat", + "action", + "--personal", + "not-real", + ])).toThrow(/Unknown personal chat action/); + await expect(runCli([ + "--headless", + "chat", + "list", + "--personal", + ])).rejects.toThrow(/require the machine-owned ADE brain/); + }); + it("requires a chat session id for chat show", () => { expect(() => buildCliPlan(["chat", "show"])).toThrow( /sessionId is required/, diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 669b0e56a..259238d37 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -27,6 +27,7 @@ import { buildDeeplink, type DeeplinkEnvelope } from "../../desktop/src/shared/d import { buildPairingQrPayload } from "../../desktop/src/shared/pairingQr"; import { buildWebClientPairUrl } from "../../desktop/src/shared/webClientUrl"; import { SEARCH_DOC_KINDS } from "../../desktop/src/shared/types/search"; +import { PERSONAL_CHAT_ACTIONS } from "../../desktop/src/shared/types/personalChats"; import { deriveDeterministicLaneNameFromPrompt } from "../../desktop/src/shared/laneNameFallback"; import { AUTOMATIONS_COMING_SOON_MESSAGE, @@ -215,6 +216,7 @@ type CliPlan = summary?: "status" | "doctor" | "auth"; formatter?: FormatterId; preferHeadless?: boolean; + machineOnly?: boolean; historyOperationId?: string; historyStatusFilter?: string; historyListFilters?: { @@ -1508,11 +1510,18 @@ const HELP_BY_COMMAND: Record = { requires an attached runtime because it owns provider/session state. $ ade chat list --lane --text List chat sessions + $ ade chat list --personal --text List machine personal chats (no project required) + $ ade chat actions --personal --text List machine personal-chat actions + $ ade chat action --personal models --input-json '{"provider":"codex"}' $ ade chat list --include-automation --no-archived --text $ ade chat create --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --no-fast --permissions full-auto + $ ade chat create --personal --provider codex --model openai/gpt-5.5 --prompt "Plan my trip" $ ade chat create --lane --provider claude --model anthropic/claude-opus-4-8 --prompt "fix the tests" $ ade chat create --from-linear-issue ENG-431 Start a chat with an attached issue + kickoff (alias: --linear-issue-json) $ ade chat send --text "next step" Send a message; steers automatically if the turn is active + $ ade chat steer --personal --text "focus on the tradeoffs" + $ ade chat models --personal --provider codex + $ ade chat update --personal --title "Trip planning" $ ade chat message --kind auto --text "status" Deliver via auto | queue | wake | interrupt-replace $ ade chat steer --text "context" Steer/queue context into an active turn @@ -1561,6 +1570,12 @@ const HELP_BY_COMMAND: Record = { config-toml is only meaningful for Codex/OpenCode provider-native config. Use ade actions run chat.modelCatalog --json to inspect model-specific reasoning tiers and fast-mode support. + + Personal chats attach to the machine-owned ADE brain and never register a + project. They work with a desktopless brain and through the same + 'ade rpc --stdio' transport used by remote desktops. One-shot '--headless' + is intentionally unsupported because it would dispose the agent runtime when + the command exits. `, "chat create": `${ADE_BANNER} Chat create @@ -1574,6 +1589,7 @@ const HELP_BY_COMMAND: Record = { $ ade chat create --from-linear-issue ENG-431 --provider codex --model openai/gpt-5.5 --prompt "Work this issue" Flags: + --personal Use machine-owned chats instead of a project/lane chat. --lane Lane/worktree for the chat. --provider claude | codex | cursor | droid | opencode. --model Model id, also sent as modelId for runtime parity. @@ -6302,6 +6318,9 @@ function buildTerminalPlan(args: string[]): CliPlan { function buildChatPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "list"; + if (readFlag(args, ["--personal"])) { + return buildPersonalChatPlan(sub, args); + } if (sub === "actions") return { kind: "execute", @@ -6971,6 +6990,215 @@ function buildChatPlan(args: string[]): CliPlan { }; } +function personalChatStep(action: string, args: JsonObject = {}): InvocationStep { + return { + key: "result", + method: "personalChats.call", + params: { action, args }, + }; +} + +function buildPersonalChatPlan(sub: string, args: string[]): CliPlan { + const laneId = readLaneId(args); + if (laneId) { + throw new CliUsageError("--personal cannot be combined with --lane."); + } + if ( + args.some((token) => + token === "--from-linear-issue" || + token.startsWith("--from-linear-issue=") || + token === "--linear-issue-json" || + token.startsWith("--linear-issue-json="), + ) + ) { + throw new CliUsageError("Personal chats cannot attach project Linear issues."); + } + + const base = { + kind: "execute" as const, + machineOnly: true, + }; + if (sub === "actions") { + return { + kind: "static", + formatter: "actions-list", + value: { + actions: PERSONAL_CHAT_ACTIONS.map((action) => ({ + name: `personalChats.${action}`, + description: "Machine-scoped personal chat action.", + example: `ade chat action --personal ${action} --input-json '{...}'`, + })), + }, + }; + } + if (sub === "action" || sub === "call") { + const action = requireValue( + readValue(args, ["--action"]) ?? firstStandalonePositional(args), + "personal chat action", + ); + if (!(PERSONAL_CHAT_ACTIONS as readonly string[]).includes(action)) { + throw new CliUsageError( + `Unknown personal chat action '${action}'. Use \`ade chat actions --personal --text\` to list actions.`, + ); + } + return { + ...base, + label: `personal chat ${action}`, + steps: [personalChatStep(action, collectGenericObjectArgs(args))], + }; + } + if (sub === "list" || sub === "ls") { + const includeArchived = readFlag(args, ["--archived", "--include-archived"]); + const excludeArchived = readFlag(args, ["--active", "--no-archived", "--exclude-archived"]); + if (includeArchived && excludeArchived) { + throw new CliUsageError("Use either --include-archived or --no-archived, not both."); + } + return { + ...base, + label: "personal chat list", + formatter: "chat-list", + steps: [personalChatStep("list", { + ...(includeArchived ? { includeArchived: true } : {}), + ...(excludeArchived ? { includeArchived: false } : {}), + })], + }; + } + + if (sub === "create" || sub === "spawn") { + const model = readValue(args, ["--model", "--model-id"]); + const provider = readValue(args, ["--provider"]); + const prompt = readValue(args, ["--prompt", "--kickoff", "--kickoff-prompt"]); + const fastMode = readFastModeFlag(args); + const createArgs = collectGenericObjectArgs(args, { + provider, + model, + modelId: model, + title: readValue(args, ["--title"]), + reasoningEffort: readValue(args, ["--reasoning-effort", "--effort"]), + permissionMode: readValue(args, ["--permission-mode", "--permissions"]), + ...(fastMode !== undefined ? { fastMode, codexFastMode: fastMode } : {}), + ...(prompt ? { kickoffText: prompt } : {}), + }); + requireValue(asString(createArgs.provider), "provider"); + requireValue(asString(createArgs.model), "model"); + return { + ...base, + label: "personal chat create", + steps: [personalChatStep("create", createArgs)], + }; + } + + if (sub === "models") { + const provider = readValue(args, ["--provider"]); + return { + ...base, + label: "personal chat models", + steps: [personalChatStep("models", collectGenericObjectArgs(args, { + ...(provider ? { provider } : {}), + }))], + }; + } + if (sub === "model-catalog" || sub === "catalog") { + const mode = readValue(args, ["--mode"]); + return { + ...base, + label: "personal chat model catalog", + steps: [personalChatStep("modelCatalog", collectGenericObjectArgs(args, { + ...(mode ? { mode } : {}), + }))], + }; + } + + const sessionId = requireValue( + readValue(args, ["--session", "--session-id"]) ?? firstStandalonePositional(args), + "sessionId", + ); + if (sub === "read" || sub === "messages" || sub === "transcript") { + const limit = readIntOption(args, ["--limit"], 50); + const since = readValue(args, ["--since"]); + return { + ...base, + label: "personal chat read", + formatter: "chat-read", + steps: [personalChatStep("read", collectGenericObjectArgs(args, { + sessionId, + ...(limit !== undefined ? { limit } : {}), + ...(since ? { since } : {}), + }))], + }; + } + if (sub === "send") { + const text = requireValue(readValue(args, ["--text", "--message"]) ?? args.join(" "), "message text"); + const imageUrl = readValue(args, ["--image-url"]); + return { + ...base, + label: "personal chat send", + steps: [personalChatStep("send", collectGenericObjectArgs(args, { + sessionId, + text, + ...(imageUrl ? { attachments: [{ type: "image-url", url: imageUrl, path: imageUrl }] } : {}), + }))], + }; + } + if (sub === "steer") { + const text = requireValue(readValue(args, ["--text", "--message"]) ?? args.join(" "), "message text"); + const imageUrl = readValue(args, ["--image-url"]); + return { + ...base, + label: "personal chat steer", + steps: [personalChatStep("steer", collectGenericObjectArgs(args, { + sessionId, + text, + ...(imageUrl ? { attachments: [{ type: "image-url", url: imageUrl, path: imageUrl }] } : {}), + }))], + }; + } + if (sub === "update" || sub === "configure") { + const model = readValue(args, ["--model", "--model-id"]); + const title = readValue(args, ["--title"]); + const provider = readValue(args, ["--provider"]); + const reasoningEffort = readValue(args, ["--reasoning-effort", "--effort"]); + const permissionMode = readValue(args, ["--permission-mode", "--permissions"]); + const fastMode = readFastModeFlag(args); + return { + ...base, + label: "personal chat update", + steps: [personalChatStep("updateSession", collectGenericObjectArgs(args, { + sessionId, + ...(title !== null ? { title } : {}), + ...(provider ? { provider } : {}), + ...(model ? { model, modelId: model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(permissionMode ? { permissionMode } : {}), + ...(fastMode !== undefined ? { fastMode } : {}), + }))], + }; + } + if (sub === "interrupt" || sub === "stop") { + return { + ...base, + label: "personal chat interrupt", + steps: [personalChatStep("interrupt", collectGenericObjectArgs(args, { sessionId }))], + }; + } + if (sub === "archive" || sub === "unarchive" || sub === "delete" || sub === "rm") { + const action = sub === "rm" ? "delete" : sub; + return { + ...base, + label: `personal chat ${action}`, + steps: [personalChatStep(action, collectGenericObjectArgs(args, { sessionId }))], + }; + } + if (sub === "show" || sub === "status") { + return { + ...base, + label: "personal chat status", + steps: [personalChatStep("getSummary", { sessionId })], + }; + } + throw new CliUsageError(`Personal chats support actions, action, list, create, show, read, send, steer, update, models, model-catalog, interrupt, archive, unarchive, or delete; got '${sub}'.`); +} + function buildTestsPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "list"; if (sub === "actions") @@ -12192,7 +12420,8 @@ function isMachineRuntimeScopedMethod(method: string): boolean { method === "runtime/info" || method === "machineInfo.get" || method.startsWith("sync.") || - method.startsWith("projects.") + method.startsWith("projects.") || + method.startsWith("personalChats.") ); } @@ -13643,6 +13872,7 @@ async function runServe( { resolveMachineAdeLayout }, { ProjectRegistry }, { ProjectScopeRegistry }, + { PersonalChatScope }, { createMultiProjectRpcRequestHandler }, { createSharedSyncListener }, { resolveMobileProjectIconDataUrl }, @@ -13653,6 +13883,7 @@ async function runServe( import("./services/projects/machineLayout"), import("./services/projects/projectRegistry"), import("./services/projects/projectScope"), + import("./services/personalChats/personalChatScope"), import("./multiProjectRpcServer"), import("./services/sync/sharedSyncListener"), import("../../desktop/src/main/services/projects/projectIconThumbnail"), @@ -13672,6 +13903,7 @@ async function runServe( const port = parseOptionalPort(readValue(args, ["--port"]), "--port"); const syncEnabled = !readFlag(args, ["--no-sync"]); const projectRegistry = new ProjectRegistry(layout); + const personalChatScope = new PersonalChatScope(); let preferredSyncProjectId: string | null = null; const preferredSyncProjectRoot = process.env.ADE_PROJECT_ROOT?.trim(); if (preferredSyncProjectRoot) { @@ -14009,6 +14241,7 @@ async function runServe( localSiteIdPath: path.join(layout.secretsDir, "sync-site-id"), getCloudRelayWssUrl: () => machineCloudRelayStore.isEnabled() ? machineCloudRelayStore.getRelayWssUrl() : null, + personalChatScope, }), ); scopeRegistry = new ProjectScopeRegistry(projectRegistry, { @@ -14041,6 +14274,7 @@ async function runServe( // straight off that project's `.ade` transcripts dir (registry-validated, // no runtime boot) — the counterpart to the roster feed above. foreignChatProvider: createForeignChatTranscriptResolver({ projectRegistry }), + personalChatScope, }, }); const previousRole = process.env.ADE_DEFAULT_ROLE; @@ -14062,6 +14296,7 @@ async function runServe( serverVersion: VERSION, projectRegistry, scopeRegistry, + personalChatScope, disposeScopesOnDispose: false, onShutdown: finish, }); @@ -14097,6 +14332,7 @@ async function runServe( // Best-effort tunnel teardown; the process exit closes sockets anyway. } await scopeRegistry.disposeAll(); + await personalChatScope.dispose(); if (sharedSyncListener) { await sharedSyncListener.close().catch(() => {}); } @@ -14889,11 +15125,17 @@ function formatActionsList(value: unknown): string { list.push(action); byDomain.set(domain, list); } - const lines = [ - "ADE actions", - 'Use: ade actions run --input-json \'{"key":"value"}\'', - 'For multi-parameter methods: --args-list-json \'["first",{"second":true}]\'', - ]; + const personalOnly = actions.every((action) => asString(action.name)?.startsWith("personalChats.")); + const lines = personalOnly + ? [ + "ADE personal chat actions", + 'Use: ade chat action --personal --input-json \'{"key":"value"}\'', + ] + : [ + "ADE actions", + 'Use: ade actions run --input-json \'{"key":"value"}\'', + 'For multi-parameter methods: --args-list-json \'["first",{"second":true}]\'', + ]; for (const [domain, list] of [...byDomain.entries()].sort(([left], [right]) => left.localeCompare(right), )) { @@ -16524,6 +16766,10 @@ function summarizeExecution(args: { return buildSyncWebPairingOutput(values.result); } + if (plan.label.startsWith("personal chat ") && isRecord(values.result)) { + return values.result.result; + } + const result = values.result ?? values; if ( isRecord(result) && @@ -16781,7 +17027,9 @@ async function executePlan( ): Promise { let connection: CliConnection; const connectionOptions = - plan.preferHeadless && !options.requireSocket + plan.machineOnly + ? { ...options, headless: false, requireSocket: true } + : plan.preferHeadless && !options.requireSocket ? { ...options, headless: true } : options; try { @@ -17013,6 +17261,11 @@ async function runCli( output: formatOutput(plan.value, parsed.options, plan.formatter), exitCode: 0, }; + if (plan.kind === "execute" && plan.machineOnly && parsed.options.headless) { + throw new CliUsageError( + "Personal chats require the machine-owned ADE brain; remove --headless and run `ade brain start` if the brain is not already available.", + ); + } // Ensure ADE's bundled skills are seeded into the home-level dirs every runtime // discovers, but only on the paths that actually launch an agent/runtime/skill — // cheap commands like `ade help` and `ade --version` must not pay the scan/hash @@ -17024,7 +17277,7 @@ async function runCli( plan.kind === "runtime" || plan.kind === "serve" || (plan.kind === "execute" && - /^(agent spawn|chat create|new chat|shell start cli)\b/.test(plan.label)) + /^(agent spawn|chat create|personal chat create|new chat|shell start cli)\b/.test(plan.label)) ) { reseedBundledAdeSkillsForCli(); } diff --git a/apps/ade-cli/src/multiProjectRpcServer.test.ts b/apps/ade-cli/src/multiProjectRpcServer.test.ts index 69ac031c3..50f13ac2d 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.test.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.test.ts @@ -88,6 +88,55 @@ describe("multi-project RPC server", () => { } }); + it("exposes machine personal chats without a project id", async () => { + const { registry } = createRegistry(); + const personalChatScope = { + capabilities: vi.fn(() => ({ version: 1 as const, actions: ["list" as const, "send" as const] })), + call: vi.fn(async (action: unknown, args: unknown) => ({ action: action as "list", result: args })), + streamEvents: vi.fn(async () => ({ + events: [], + nextCursor: 0, + hasMore: false, + eventEpoch: "epoch", + gap: false, + oldestCursor: null, + })), + transcriptPath: vi.fn(async () => null), + isTurnActive: vi.fn(async () => false), + dispose: vi.fn(async () => undefined), + }; + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + projectRegistry: registry, + personalChatScope, + }); + + const initialized = await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: {}, + }); + expect(initialized).toMatchObject({ + capabilities: { personalChats: { version: 1, actions: ["list", "send"] } }, + }); + await expect(handler({ + jsonrpc: "2.0", + id: 2, + method: "personalChats.call", + params: { action: "send", args: { sessionId: "personal-1", text: "hello" } }, + })).resolves.toEqual({ + action: "send", + result: { sessionId: "personal-1", text: "hello" }, + }); + expect(personalChatScope.call).toHaveBeenCalledWith("send", { + sessionId: "personal-1", + text: "hello", + }); + handler.dispose(); + expect(personalChatScope.dispose).not.toHaveBeenCalled(); + }); + it("exposes runtime-scoped project registry methods", async () => { const { projectRoot, expectedProjectRoot, registry } = createRegistry(); const handler = createMultiProjectRpcRequestHandler({ diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index 5258959ef..fee733ccb 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -30,6 +30,7 @@ import { type ProjectId, } from "./services/projects/projectRegistry"; import { ProjectScopeRegistry } from "./services/projects/projectScope"; +import { PersonalChatScope } from "./services/personalChats/personalChatScope"; import { createHeadlessGitHubService } from "./headlessLinearServices"; import { normalizeAdeRuntimeRole } from "./runtimeRoles"; import type { SyncPeerDeviceType } from "../../desktop/src/shared/types"; @@ -52,6 +53,7 @@ export type MultiProjectRpcHandlerOptions = { scopeRegistry?: ProjectScopeRegistry; disposeScopesOnDispose?: boolean; onShutdown?: (() => void) | null; + personalChatScope?: Pick; }; const RUNTIME_METHODS = new Set([ @@ -61,6 +63,8 @@ const RUNTIME_METHODS = new Set([ "shutdown", "exit", "runtime/info", + "personalChats.call", + "personalChats.streamEvents", "machineInfo.get", "projects.list", "projects.add", @@ -300,6 +304,8 @@ export function createMultiProjectRpcRequestHandler( setNotifier: (notify: JsonRpcNotifier | null) => void; } { const projectRegistry = options.projectRegistry ?? new ProjectRegistry(); + const ownsPersonalChatScope = options.personalChatScope == null; + const personalChatScope = options.personalChatScope ?? new PersonalChatScope(); const handlers = new Map>(); const eventSubscriptions = new Map(); const disposeProjectRuntimeCaches = (projectId: ProjectId): void => { @@ -532,6 +538,7 @@ export function createMultiProjectRpcRequestHandler( clone: true, listMyGitHubRepos: true, }, + personalChats: personalChatScope.capabilities(), }, }; } @@ -572,6 +579,14 @@ export function createMultiProjectRpcRequestHandler( }; } + if (method === "personalChats.call") { + return await personalChatScope.call(params.action, params.args); + } + + if (method === "personalChats.streamEvents") { + return await personalChatScope.streamEvents(params); + } + if (method === "projects.list") { return decorateProjectListWithIcons(projectRegistry.list()); } @@ -833,6 +848,7 @@ export function createMultiProjectRpcRequestHandler( if (options.disposeScopesOnDispose ?? !options.scopeRegistry) { void scopeRegistry.disposeAll(); } + if (ownsPersonalChatScope) void personalChatScope.dispose(); }; handler.setNotifier = (notify: JsonRpcNotifier | null) => { diff --git a/apps/ade-cli/src/services/imageAttachment.ts b/apps/ade-cli/src/services/imageAttachment.ts new file mode 100644 index 000000000..cf3e988f4 --- /dev/null +++ b/apps/ade-cli/src/services/imageAttachment.ts @@ -0,0 +1,107 @@ +import fs from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +const MAX_IMAGE_BYTES = 10 * 1024 * 1024; +const IMAGE_MIME_BY_EXTENSION: Record = { + ".bmp": "image/bmp", + ".gif": "image/gif", + ".ico": "image/x-icon", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +}; + +function sniffImageMimeType(buffer: Buffer): string | null { + if (buffer.length >= 8 + && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47 + && buffer[4] === 0x0D && buffer[5] === 0x0A && buffer[6] === 0x1A && buffer[7] === 0x0A) return "image/png"; + if (buffer.length >= 3 && buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) return "image/jpeg"; + if (buffer.length >= 6 + && buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38 + && (buffer[4] === 0x37 || buffer[4] === 0x39) && buffer[5] === 0x61) return "image/gif"; + if (buffer.length >= 12 + && buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 + && buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) return "image/webp"; + if (buffer.length >= 2 && buffer[0] === 0x42 && buffer[1] === 0x4D) return "image/bmp"; + if (buffer.length >= 4 + && buffer[0] === 0x00 && buffer[1] === 0x00 && buffer[2] === 0x01 && buffer[3] === 0x00) return "image/x-icon"; + const head = buffer.slice(0, Math.min(buffer.length, 1024)).toString("utf8"); + const stripped = head.replace(/^/, "").trimStart(); + if ((/^<\?xml\b/i.test(stripped) && / { + const stat = await fs.promises.stat(filePath); + if (!stat.isFile()) throw new Error("Path is not a file."); + if (stat.size > MAX_IMAGE_BYTES) throw new Error("Image must be 10 MB or smaller."); + const data = await fs.promises.readFile(filePath); + const mimeType = sniffImageMimeType(data); + if (!mimeType) throw new Error("Path is not an image."); + return { data, mimeType }; +} + +function normalizeImageMime(mime: unknown, filename: string): string { + const raw = typeof mime === "string" ? mime.trim().toLowerCase() : ""; + const fromExtension = IMAGE_MIME_BY_EXTENSION[path.extname(filename).toLowerCase()]; + const normalized = raw || fromExtension || ""; + if (!Object.values(IMAGE_MIME_BY_EXTENSION).includes(normalized)) { + throw new Error("Temporary attachment mime must be a supported image type."); + } + return normalized; +} + +function decodeBase64ImagePayload(value: string): Buffer { + const compact = value.replace(/\s+/g, ""); + if (!compact || !/^[A-Za-z0-9+/]+={0,2}$/.test(compact) || compact.length % 4 === 1) { + throw new Error("Temporary attachment base64 is invalid."); + } + const maxEncodedLength = Math.ceil(MAX_IMAGE_BYTES / 3) * 4; + if (compact.length > maxEncodedLength) throw new Error("Temporary attachments must be 10 MB or smaller."); + const content = Buffer.from(compact, "base64"); + if (content.byteLength > MAX_IMAGE_BYTES) throw new Error("Temporary attachments must be 10 MB or smaller."); + return content; +} + +function parseTempAttachmentPayload(payload: Record) { + const dataUrl = typeof payload.dataUrl === "string" ? payload.dataUrl.trim() : ""; + const filename = typeof payload.filename === "string" && payload.filename.trim() + ? payload.filename.trim() + : "attachment.png"; + if (dataUrl) { + const match = /^data:([^;,]+)(;base64)?,(.*)$/s.exec(dataUrl); + if (!match) throw new Error("Temporary attachment dataUrl is invalid."); + const mimeType = normalizeImageMime(match[1], filename); + const content = match[2] === ";base64" + ? decodeBase64ImagePayload(match[3] ?? "") + : Buffer.from(decodeURIComponent(match[3] ?? ""), "utf8"); + if (content.byteLength > MAX_IMAGE_BYTES) throw new Error("Temporary attachments must be 10 MB or smaller."); + if (sniffImageMimeType(content) !== mimeType) throw new Error("Temporary attachment MIME type does not match payload."); + return { content, filename, mimeType }; + } + const base64 = typeof payload.base64 === "string" + ? payload.base64 + : typeof payload.data === "string" ? payload.data : ""; + const mimeType = normalizeImageMime(payload.mime ?? payload.mimeType, filename); + const content = decodeBase64ImagePayload(base64); + if (sniffImageMimeType(content) !== mimeType) throw new Error("Temporary attachment MIME type does not match payload."); + return { content, filename, mimeType }; +} + +export async function saveImageTempAttachment( + baseDir: string, + payload: Record, +): Promise<{ path: string; mimeType: string; previewDataUrl: string | null }> { + const { content, filename, mimeType } = parseTempAttachmentPayload(payload); + await fs.promises.mkdir(baseDir, { recursive: true }); + const ext = path.extname(filename) || Object.entries(IMAGE_MIME_BY_EXTENSION) + .find(([, entryMime]) => entryMime === mimeType)?.[0] || ".png"; + const destPath = path.join(baseDir, `${randomUUID()}${ext}`); + await fs.promises.writeFile(destPath, content); + return { path: destPath, mimeType, previewDataUrl: null }; +} diff --git a/apps/ade-cli/src/services/personalChats/personalChatScope.test.ts b/apps/ade-cli/src/services/personalChats/personalChatScope.test.ts new file mode 100644 index 000000000..45b2275ee --- /dev/null +++ b/apps/ade-cli/src/services/personalChats/personalChatScope.test.ts @@ -0,0 +1,226 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AdeRuntime } from "../../bootstrap"; +import { PersonalChatScope } from "./personalChatScope"; + +describe("PersonalChatScope", () => { + let adeHome: string; + let previousAdeHome: string | undefined; + + beforeEach(() => { + previousAdeHome = process.env.ADE_HOME; + adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-personal-chat-scope-")); + process.env.ADE_HOME = adeHome; + }); + + afterEach(() => { + if (previousAdeHome == null) delete process.env.ADE_HOME; + else process.env.ADE_HOME = previousAdeHome; + fs.rmSync(adeHome, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + function fixture(surface: "personal" | "work" = "personal") { + const summary = { + sessionId: "chat-1", + laneId: "internal-lane", + provider: "codex", + model: "gpt-5", + surface, + status: "idle", + startedAt: "2026-01-01T00:00:00.000Z", + endedAt: null, + lastActivityAt: "2026-01-01T00:00:00.000Z", + lastOutputPreview: null, + summary: null, + } as const; + const service = { + listSessions: vi.fn(async () => [summary]), + createSession: vi.fn(async (args) => ({ + id: "chat-1", + ...args, + status: "idle", + createdAt: "2026-01-01T00:00:00.000Z", + lastActivityAt: "2026-01-01T00:00:00.000Z", + })), + getSessionSummary: vi.fn(async () => summary), + sendMessage: vi.fn(async () => undefined), + readTranscript: vi.fn(async () => []), + steer: vi.fn(async () => undefined), + interrupt: vi.fn(async () => undefined), + respondToInput: vi.fn(async () => undefined), + approveToolUse: vi.fn(async () => undefined), + updateSession: vi.fn(async () => summary), + archiveSession: vi.fn(async () => undefined), + unarchiveSession: vi.fn(async () => undefined), + deleteSession: vi.fn(async () => undefined), + getAvailableModels: vi.fn(async () => []), + getModelCatalog: vi.fn(async () => ({ groups: [], fetchedAt: "now" })), + getChatEventHistory: vi.fn(() => ({ sessionId: "chat-1", events: [], truncated: false, sessionFound: true })), + getChatEventHistoryPage: vi.fn(() => ({ sessionId: "chat-1", events: [], startOffset: 0, hasMore: false, sessionFound: true })), + }; + const runtime = { + agentChatService: service, + projectRoot: path.join(adeHome, "personal-chats", "state"), + workspaceRoot: path.join(adeHome, "personal-chats", "workspaces"), + laneService: { + list: vi.fn(async () => [{ id: "internal-lane", laneType: "primary" }]), + }, + ptyService: { + create: vi.fn(async () => ({ ptyId: "pty-personal", sessionId: "terminal-personal", pid: 42 })), + writeTerminal: vi.fn(async () => ({ ok: true as const })), + resizeTerminal: vi.fn(() => ({ ok: true as const, cols: 100, rows: 30 })), + dispose: vi.fn(() => ({ disposed: true, reason: "disposed" as const })), + }, + sessionService: { get: vi.fn(() => ({ transcriptPath: "/tmp/chat-1.jsonl" })) }, + eventBuffer: { drain: vi.fn(() => ({ events: [], nextCursor: 0, hasMore: false, eventEpoch: "epoch", gap: false, oldestCursor: null })) }, + dispose: vi.fn(), + } as unknown as AdeRuntime; + const createRuntime = vi.fn(async ( + _args: Parameters[0], + ): Promise => runtime); + return { summary, service, runtime, createRuntime }; + } + + it("creates a hidden personal session and dispatches an optional kickoff", async () => { + const { service, createRuntime } = fixture(); + const scope = new PersonalChatScope({ createRuntime }); + + const response = await scope.call("create", { + provider: "codex", + model: "gpt-5", + laneId: "attacker-lane", + requestedCwd: "/tmp/outside", + surface: "work", + sessionProfile: "workflow", + kickoffText: "hello", + }); + + expect(response.action).toBe("create"); + expect(service.createSession).toHaveBeenCalledWith(expect.objectContaining({ + laneId: "internal-lane", + surface: "personal", + sessionProfile: "light", + permissionMode: "default", + })); + expect(service.createSession).not.toHaveBeenCalledWith(expect.objectContaining({ requestedCwd: expect.anything() })); + expect(service.sendMessage).toHaveBeenCalledWith( + { sessionId: "chat-1", text: "hello" }, + { awaitDispatch: false }, + ); + const runtimeArgs = createRuntime.mock.calls[0]?.[0]; + if (!runtimeArgs || typeof runtimeArgs === "string") throw new Error("Expected structured runtime args"); + expect(runtimeArgs?.projectRoot).toBe(path.join(adeHome, "personal-chats", "state")); + expect(runtimeArgs?.primaryWorktreePath).toBe(path.join(adeHome, "personal-chats", "workspaces")); + expect(runtimeArgs?.projectRoot).not.toBe(runtimeArgs?.primaryWorktreePath); + expect(runtimeArgs.runtimeProfile).toBe("chat"); + expect(runtimeArgs.publishPushEvents).toBe(false); + }); + + it("filters the hidden scope list to personal sessions", async () => { + const personal = fixture("personal"); + const scope = new PersonalChatScope({ createRuntime: personal.createRuntime }); + await expect(scope.call("list", { includeArchived: false })).resolves.toMatchObject({ + action: "list", + result: [{ sessionId: "chat-1", surface: "personal" }], + }); + + const work = fixture("work"); + const workScope = new PersonalChatScope({ createRuntime: work.createRuntime }); + await expect(workScope.call("list", {})).resolves.toMatchObject({ result: [] }); + expect(personal.service.listSessions).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ includeArchived: false }), + ); + }); + + it("rejects session-scoped calls when the session is not personal", async () => { + const { createRuntime, service } = fixture("work"); + const scope = new PersonalChatScope({ createRuntime }); + await expect(scope.call("send", { sessionId: "chat-1", text: "nope" })) + .rejects.toThrow("Personal chat session 'chat-1' was not found"); + expect(service.getSessionSummary).toHaveBeenCalledWith("chat-1"); + expect(service.sendMessage).not.toHaveBeenCalled(); + }); + + it("exposes only the hard allowlisted personal-chat actions", () => { + const scope = new PersonalChatScope(); + const capabilities = scope.capabilities(); + expect(capabilities.version).toBe(1); + expect(capabilities.actions).toEqual(expect.arrayContaining(["list", "create", "send", "updateSession", "delete"])); + expect(capabilities.actions).not.toContain("projects.list"); + }); + + it("owns terminal lifecycle inside the hidden workspace", async () => { + const { createRuntime, runtime } = fixture(); + const scope = new PersonalChatScope({ createRuntime }); + + await expect(scope.call("terminalCreate", { + chatSessionId: "chat-1", + cols: 100, + rows: 30, + })).resolves.toMatchObject({ + action: "terminalCreate", + result: { ptyId: "pty-personal", sessionId: "terminal-personal", pid: 42 }, + }); + expect(runtime.ptyService.create).toHaveBeenCalledWith({ + laneId: "internal-lane", + cwd: path.join(adeHome, "personal-chats", "workspaces"), + chatSessionId: "chat-1", + cols: 100, + rows: 30, + title: "Personal terminal", + tracked: true, + toolType: "shell", + }); + + await expect(scope.call("terminalWrite", { ptyId: "pty-personal", data: "pwd\n" })) + .resolves.toMatchObject({ result: { ok: true } }); + expect(runtime.ptyService.writeTerminal).toHaveBeenCalledWith({ + ptyId: "pty-personal", + data: "pwd\n", + }); + await expect(scope.call("terminalResize", { ptyId: "pty-personal", cols: 100, rows: 30 })) + .resolves.toMatchObject({ result: { ok: true, cols: 100, rows: 30 } }); + await expect(scope.call("terminalDispose", { + ptyId: "pty-personal", + sessionId: "terminal-personal", + })).resolves.toMatchObject({ result: { disposed: true, reason: "disposed" } }); + + await expect(scope.call("terminalWrite", { ptyId: "pty-personal", data: "nope" })) + .rejects.toThrow("Personal terminal 'pty-personal' was not found"); + }); + + it("saves validated image attachments only inside hidden state", async () => { + const { createRuntime } = fixture(); + const scope = new PersonalChatScope({ createRuntime }); + const response = await scope.call("saveTempAttachment", { + filename: "photo.png", + mimeType: "image/png", + base64: "iVBORw0KGgo=", + }); + expect(response).toMatchObject({ + action: "saveTempAttachment", + result: { mimeType: "image/png", previewDataUrl: null }, + }); + const savedPath = (response.result as { path: string }).path; + expect(savedPath.startsWith(path.join(adeHome, "personal-chats", "state", ".ade", "attachments"))) + .toBe(true); + expect(fs.existsSync(savedPath)).toBe(true); + + await expect(scope.call("getImageDataUrl", { path: savedPath })).resolves.toMatchObject({ + action: "getImageDataUrl", + result: { dataUrl: expect.stringMatching(/^data:image\/png;base64,/) }, + }); + await expect(scope.call("getImageDataUrl", { path: __filename })) + .rejects.toThrow(/outside the attachment store/); + + await expect(scope.call("saveTempAttachment", { + filename: "fake.png", + mimeType: "image/png", + base64: Buffer.from("not an image").toString("base64"), + })).rejects.toThrow(/MIME type does not match/); + }); +}); diff --git a/apps/ade-cli/src/services/personalChats/personalChatScope.ts b/apps/ade-cli/src/services/personalChats/personalChatScope.ts new file mode 100644 index 000000000..ab6010f6b --- /dev/null +++ b/apps/ade-cli/src/services/personalChats/personalChatScope.ts @@ -0,0 +1,392 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { + AgentChatCreateArgs, + AgentChatSessionSummary, + PersonalChatAction, + PersonalChatCallResponse, + PersonalChatCapabilities, +} from "../../../../desktop/src/shared/types"; +import { PERSONAL_CHAT_ACTIONS } from "../../../../desktop/src/shared/types"; +import { resolveAdeLayout } from "../../../../desktop/src/shared/adeLayout"; +import type { AdeRuntime } from "../../bootstrap"; +import { resolveMachineAdeLayout } from "../projects/machineLayout"; +import { readImageFileAndSniffMime, saveImageTempAttachment } from "../imageAttachment"; + +type PersonalChatScopeOptions = { + createRuntime?: typeof import("../../bootstrap").createAdeRuntime; +}; + +type ObjectArgs = Record; + +function asObject(value: unknown): ObjectArgs { + return value && typeof value === "object" && !Array.isArray(value) + ? value as ObjectArgs + : {}; +} + +function requiredString(value: unknown, label: string): string { + const normalized = typeof value === "string" ? value.trim() : ""; + if (!normalized) throw new Error(`${label} is required.`); + return normalized; +} + +function readSessionId(args: ObjectArgs): string { + return requiredString(args.sessionId, "sessionId"); +} + +function readLimit(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + return Math.max(1, Math.min(500, Math.floor(value))); +} + +function readDimension(value: unknown, label: string, fallback?: number): number { + if (value == null && fallback != null) return fallback; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${label} must be a finite number.`); + } + return Math.floor(value); +} + +function isPersonalChatAction(value: unknown): value is PersonalChatAction { + return typeof value === "string" && (PERSONAL_CHAT_ACTIONS as readonly string[]).includes(value); +} + +/** + * Machine-owned chat scope. It deliberately stays out of ProjectRegistry, so + * the synthetic project/lane required by the existing chat + PTY services can + * never leak into project pickers, recents, or mobile project catalogs. + */ +export class PersonalChatScope { + private runtimePromise: Promise | null = null; + private readonly personalTerminalSessions = new Map(); + + constructor(private readonly options: PersonalChatScopeOptions = {}) {} + + capabilities(): PersonalChatCapabilities { + return { version: 1, actions: [...PERSONAL_CHAT_ACTIONS] }; + } + + async call(actionValue: unknown, argsValue: unknown): Promise { + if (!isPersonalChatAction(actionValue)) { + throw new Error(`Unsupported personal chat action: ${String(actionValue ?? "")}.`); + } + const action = actionValue; + const args = asObject(argsValue); + const runtime = await this.getRuntime(); + const service = runtime.agentChatService; + if (!service) throw new Error("Personal chat service is not available."); + + let result: unknown; + switch (action) { + case "list": + result = (await service.listSessions(undefined, { + includeIdentity: false, + includeAutomation: true, + includeArchived: args.includeArchived === true, + })).filter((session) => session.surface === "personal"); + break; + case "create": { + const provider = requiredString(args.provider, "provider") as AgentChatCreateArgs["provider"]; + const model = requiredString(args.model, "model"); + const laneId = await this.getInternalLaneId(runtime); + const kickoffText = typeof args.kickoffText === "string" ? args.kickoffText.trim() : ""; + const { + laneId: _laneId, + requestedCwd: _requestedCwd, + surface: _surface, + sessionProfile: _sessionProfile, + identityKey: _identityKey, + automationId: _automationId, + automationRunId: _automationRunId, + orchestrationRunId: _orchestrationRunId, + orchestrationRole: _orchestrationRole, + orchestrationParentSessionId: _orchestrationParentSessionId, + orchestrationTag: _orchestrationTag, + orchestrationStepId: _orchestrationStepId, + orchestrationBundlePath: _orchestrationBundlePath, + kickoffText: _kickoffText, + ...forwarded + } = args; + const created = await service.createSession({ + ...forwarded, + laneId, + provider, + model, + surface: "personal", + sessionProfile: "light", + permissionMode: typeof args.permissionMode === "string" + ? args.permissionMode as AgentChatCreateArgs["permissionMode"] + : "default", + } as AgentChatCreateArgs); + if (kickoffText) { + await service.sendMessage({ sessionId: created.id, text: kickoffText }, { awaitDispatch: false }); + } + result = await service.getSessionSummary(created.id); + break; + } + case "getSummary": + result = await this.requirePersonalSession(service, readSessionId(args)); + break; + case "read": { + const sessionId = readSessionId(args); + await this.requirePersonalSession(service, sessionId); + result = await service.readTranscript( + sessionId, + readLimit(args.limit), + typeof args.since === "string" ? args.since : undefined, + ); + break; + } + case "send": + await this.requirePersonalSession(service, readSessionId(args)); + result = await service.sendMessage(args as never); + break; + case "steer": + await this.requirePersonalSession(service, readSessionId(args)); + result = await service.steer(args as never); + break; + case "cancelSteer": + await this.requirePersonalSession(service, readSessionId(args)); + result = await service.cancelSteer(args as never); + break; + case "editSteer": + await this.requirePersonalSession(service, readSessionId(args)); + result = await service.editSteer(args as never); + break; + case "dispatchSteer": + await this.requirePersonalSession(service, readSessionId(args)); + result = await service.dispatchSteer(args as never); + break; + case "cancelDispatchedSteer": + await this.requirePersonalSession(service, readSessionId(args)); + result = await service.cancelDispatchedSteer(args as never); + break; + case "interrupt": + await this.requirePersonalSession(service, readSessionId(args)); + result = await service.interrupt(args as never); + break; + case "respondToInput": + await this.requirePersonalSession(service, readSessionId(args)); + result = await service.respondToInput(args as never); + break; + case "approve": + await this.requirePersonalSession(service, readSessionId(args)); + result = await service.approveToolUse(args as never); + break; + case "updateSession": { + const sessionId = readSessionId(args); + await this.requirePersonalSession(service, sessionId); + result = await service.updateSession(args as never); + break; + } + case "archive": + case "unarchive": + case "delete": { + const sessionId = readSessionId(args); + await this.requirePersonalSession(service, sessionId); + const method = action === "archive" + ? service.archiveSession + : action === "unarchive" + ? service.unarchiveSession + : service.deleteSession; + await method({ sessionId }); + result = { ok: true }; + break; + } + case "models": + if (typeof args.provider === "string" && args.provider.trim()) { + result = await service.getAvailableModels({ provider: args.provider.trim() as never }); + } else { + const catalog = await service.getModelCatalog({}); + result = catalog.groups.flatMap((group) => + group.providers.flatMap((provider) => provider.subsections.flatMap((subsection) => subsection.models)), + ); + } + break; + case "modelCatalog": + result = await service.getModelCatalog(args as never); + break; + case "getEventHistory": { + const sessionId = readSessionId(args); + await this.requirePersonalSession(service, sessionId); + result = service.getChatEventHistory(sessionId, { + ...(typeof args.maxEvents === "number" ? { maxEvents: args.maxEvents } : {}), + ...(typeof args.maxBytes === "number" ? { maxBytes: args.maxBytes } : {}), + }); + break; + } + case "getEventHistoryPage": { + const sessionId = readSessionId(args); + await this.requirePersonalSession(service, sessionId); + result = service.getChatEventHistoryPage(sessionId, { + beforeOffset: Number(args.beforeOffset), + ...(typeof args.maxBytes === "number" ? { maxBytes: args.maxBytes } : {}), + }); + break; + } + case "terminalCreate": { + const chatSessionId = typeof args.chatSessionId === "string" && args.chatSessionId.trim() + ? args.chatSessionId.trim() + : null; + if (chatSessionId) await this.requirePersonalSession(service, chatSessionId); + const laneId = await this.getInternalLaneId(runtime); + const created = await runtime.ptyService.create({ + laneId, + cwd: runtime.workspaceRoot, + ...(chatSessionId ? { chatSessionId } : {}), + cols: readDimension(args.cols, "cols", 120), + rows: readDimension(args.rows, "rows", 36), + title: "Personal terminal", + tracked: true, + toolType: "shell", + }); + this.personalTerminalSessions.set(created.ptyId, created.sessionId); + result = created; + break; + } + case "terminalWrite": { + const ptyId = requiredString(args.ptyId, "ptyId"); + this.requirePersonalTerminal(ptyId); + if (typeof args.data !== "string") throw new Error("data must be a string."); + result = await runtime.ptyService.writeTerminal({ ptyId, data: args.data }); + break; + } + case "terminalResize": { + const ptyId = requiredString(args.ptyId, "ptyId"); + this.requirePersonalTerminal(ptyId); + result = runtime.ptyService.resizeTerminal({ + ptyId, + cols: readDimension(args.cols, "cols"), + rows: readDimension(args.rows, "rows"), + }); + break; + } + case "terminalDispose": { + const ptyId = requiredString(args.ptyId, "ptyId"); + const sessionId = requiredString(args.sessionId, "sessionId"); + this.requirePersonalTerminal(ptyId, sessionId); + result = runtime.ptyService.dispose({ ptyId, sessionId }); + this.personalTerminalSessions.delete(ptyId); + break; + } + case "saveTempAttachment": + result = await saveImageTempAttachment( + path.join(runtime.projectRoot, ".ade", "attachments"), + args, + ); + break; + case "getImageDataUrl": { + const attachmentsRoot = await fs.promises.realpath( + path.join(runtime.projectRoot, ".ade", "attachments"), + ); + const requestedPath = await fs.promises.realpath(requiredString(args.path, "path")); + if (requestedPath !== attachmentsRoot && !requestedPath.startsWith(`${attachmentsRoot}${path.sep}`)) { + throw new Error("Personal chat attachment path is outside the attachment store."); + } + const image = await readImageFileAndSniffMime(requestedPath); + result = { + dataUrl: `data:${image.mimeType};base64,${image.data.toString("base64")}`, + mimeType: image.mimeType, + }; + break; + } + } + return { action, result }; + } + + async streamEvents(argsValue: unknown) { + const args = asObject(argsValue); + const runtime = await this.getRuntime(); + const cursor = typeof args.cursor === "number" && Number.isFinite(args.cursor) + ? Math.max(0, Math.floor(args.cursor)) + : 0; + const limit = typeof args.limit === "number" && Number.isFinite(args.limit) + ? Math.max(1, Math.min(1000, Math.floor(args.limit))) + : 100; + return runtime.eventBuffer.drain(cursor, limit); + } + + async transcriptPath(sessionIdValue: unknown): Promise { + const sessionId = requiredString(sessionIdValue, "sessionId"); + const runtime = await this.getRuntime(); + const summary = await runtime.agentChatService?.getSessionSummary(sessionId); + if (!summary || summary.surface !== "personal") return null; + // The session transcript is byte-capped. Remote clients must tail the + // dedicated durable chat transcript or long conversations stop updating. + const durablePath = path.join(resolveAdeLayout(runtime.projectRoot).chatTranscriptsDir, `${sessionId}.jsonl`); + if (fs.existsSync(durablePath)) return durablePath; + return runtime.sessionService.get(sessionId)?.transcriptPath ?? null; + } + + async isTurnActive(sessionIdValue: unknown): Promise { + const sessionId = requiredString(sessionIdValue, "sessionId"); + const runtime = await this.getRuntime(); + const summary = await runtime.agentChatService?.getSessionSummary(sessionId); + return summary?.surface === "personal" && summary.status === "active"; + } + + async dispose(): Promise { + const pending = this.runtimePromise; + this.runtimePromise = null; + const runtime = await pending?.catch(() => null); + this.personalTerminalSessions.clear(); + runtime?.dispose(); + } + + private async getRuntime(): Promise { + if (this.runtimePromise) return await this.runtimePromise; + this.runtimePromise = this.createRuntime(); + try { + return await this.runtimePromise; + } catch (error) { + this.runtimePromise = null; + throw error; + } + } + + private async createRuntime(): Promise { + const layout = resolveMachineAdeLayout(); + const stateRoot = layout.personalChatsStateRoot ?? path.join(layout.adeDir, "personal-chats", "state"); + const workspaceRoot = layout.personalChatsWorkspaceRoot ?? path.join(layout.adeDir, "personal-chats", "workspaces"); + fs.mkdirSync(stateRoot, { recursive: true, mode: 0o700 }); + fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 }); + const createRuntime = this.options.createRuntime + ?? (await import("../../bootstrap")).createAdeRuntime; + return await createRuntime({ + projectRoot: stateRoot, + workspaceRoot, + primaryWorktreePath: workspaceRoot, + chatRuntime: "agent", + runtimeProfile: "chat", + publishPushEvents: false, + syncRuntime: { enabled: false }, + }); + } + + private async getInternalLaneId(runtime: AdeRuntime): Promise { + const lanes = await runtime.laneService.list({ includeArchived: false, includeStatus: false }); + const primary = lanes.find((lane) => lane.laneType === "primary") ?? lanes[0]; + if (!primary) throw new Error("Personal chat workspace is unavailable."); + return primary.id; + } + + private async requirePersonalSession( + service: NonNullable, + sessionId: string, + ): Promise { + const summary = await service.getSessionSummary(sessionId); + if (!summary || summary.surface !== "personal") { + throw new Error(`Personal chat session '${sessionId}' was not found.`); + } + return summary; + } + + private requirePersonalTerminal(ptyId: string, sessionId?: string): string { + const ownedSessionId = this.personalTerminalSessions.get(ptyId); + if (!ownedSessionId || (sessionId && ownedSessionId !== sessionId)) { + throw new Error(`Personal terminal '${ptyId}' was not found.`); + } + return ownedSessionId; + } +} diff --git a/apps/ade-cli/src/services/projects/machineLayout.ts b/apps/ade-cli/src/services/projects/machineLayout.ts index 22583a61d..071b57495 100644 --- a/apps/ade-cli/src/services/projects/machineLayout.ts +++ b/apps/ade-cli/src/services/projects/machineLayout.ts @@ -17,6 +17,9 @@ export type MachineAdeLayout = { desktopBridgeSocketPath: string; binDir: string; runtimeDir: string; + personalChatsDir?: string; + personalChatsStateRoot?: string; + personalChatsWorkspaceRoot?: string; }; export function resolveMachineAdeDir(env: NodeJS.ProcessEnv = process.env): string { @@ -59,5 +62,8 @@ export function resolveMachineAdeLayout( desktopBridgeSocketPath, binDir: path.join(adeDir, "bin"), runtimeDir: path.join(adeDir, "runtime"), + personalChatsDir: path.join(adeDir, "personal-chats"), + personalChatsStateRoot: path.join(adeDir, "personal-chats", "state"), + personalChatsWorkspaceRoot: path.join(adeDir, "personal-chats", "workspaces"), }; } diff --git a/apps/ade-cli/src/services/sync/brainProjectActionsSyncHandler.ts b/apps/ade-cli/src/services/sync/brainProjectActionsSyncHandler.ts index b603fbf24..73e501768 100644 --- a/apps/ade-cli/src/services/sync/brainProjectActionsSyncHandler.ts +++ b/apps/ade-cli/src/services/sync/brainProjectActionsSyncHandler.ts @@ -3,10 +3,17 @@ import path from "node:path"; import { randomBytes, timingSafeEqual } from "node:crypto"; import type { RawData, WebSocket } from "ws"; import type { + AgentChatEventEnvelope, CloneProjectInput, CreateProjectInput, ListMyGitHubReposInput, ProjectBrowseInput, + PersonalChatScopeContract, + SyncChatSubscribePayload, + SyncChatSubscribeSnapshotPayload, + SyncChatUnsubscribePayload, + SyncCommandPayload, + SyncRemoteCommandDescriptor, SyncEnvelope, SyncHelloPayload, SyncMobileProjectSummary, @@ -18,6 +25,8 @@ import type { SyncProjectOpenRequestPayload, SyncProjectSwitchRequestPayload, } from "../../../../desktop/src/shared/types"; +import { parseAgentChatTranscript } from "../../../../desktop/src/shared/chatTranscript"; +import { isPersonalChatActionQueueable } from "../../../../desktop/src/shared/types/personalChats"; import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; import { nowIso } from "../../../../desktop/src/main/services/shared/utils"; import type { SharedSyncListenerConnectionHandler } from "./sharedSyncListener"; @@ -56,6 +65,7 @@ type BrainProjectActionsSyncHandlerArgs = { authTimeoutMs?: number; /** Mirrors SyncHostServiceArgs.getCloudRelayWssUrl for the fallback hello_ok. */ getCloudRelayWssUrl?: () => string | null; + personalChatScope?: PersonalChatScopeContract; }; type BrainPeerState = { @@ -63,6 +73,7 @@ type BrainPeerState = { authenticated: boolean; authTimeout: ReturnType | null; metadata: SyncPeerMetadata | null; + personalChatSubscriptions: Map; }; const WS_OPEN = 1; @@ -332,6 +343,52 @@ async function projectCatalog(provider: SyncProjectCatalogProvider, logger: Logg } } +function personalChatCommandDescriptors( + scope: BrainProjectActionsSyncHandlerArgs["personalChatScope"], +): SyncRemoteCommandDescriptor[] { + if (!scope) return []; + const descriptors = scope.capabilities().actions.map((action) => ({ + action: `personalChats.${action}`, + scope: "runtime" as const, + policy: { + viewerAllowed: true, + queueable: isPersonalChatActionQueueable(action), + }, + })); + descriptors.push({ + action: "personalChats.streamEvents", + scope: "runtime", + policy: { viewerAllowed: true, queueable: false }, + }); + return descriptors; +} + +async function readPersonalChatEventsSince( + transcriptPath: string, + offset: number, +): Promise<{ events: AgentChatEventEnvelope[]; nextOffset: number }> { + let file: fs.promises.FileHandle | null = null; + try { + file = await fs.promises.open(transcriptPath, "r"); + const size = (await file.stat()).size; + const start = Math.max(0, Math.min(offset, size)); + if (start >= size) return { events: [], nextOffset: size }; + const bytes = Buffer.alloc(size - start); + await file.read(bytes, 0, bytes.length, start); + const lastNewline = bytes.lastIndexOf(0x0a); + if (lastNewline < 0) return { events: [], nextOffset: start }; + const complete = bytes.subarray(0, lastNewline + 1); + return { + events: parseAgentChatTranscript(complete.toString("utf8")), + nextOffset: start + complete.length, + }; + } catch { + return { events: [], nextOffset: Math.max(0, offset) }; + } finally { + await file?.close().catch(() => {}); + } +} + export function createBrainProjectActionsSyncHandler( args: BrainProjectActionsSyncHandlerArgs, ): SharedSyncListenerConnectionHandler { @@ -540,6 +597,50 @@ export function createBrainProjectActionsSyncHandler( } break; } + case "chat_subscribe": { + const payload = envelope.payload as SyncChatSubscribePayload | null; + const sessionId = optionalString(payload?.sessionId); + if (!sessionId || payload?.chatScope !== "personal" || !args.personalChatScope) break; + const transcriptPath = await args.personalChatScope.transcriptPath(sessionId); + if (!transcriptPath) { + send(peer.ws, "chat_subscribe", { + sessionId, + capturedAt: nowIso(), + truncated: false, + events: [], + turnActive: false, + } satisfies SyncChatSubscribeSnapshotPayload, envelope.requestId); + break; + } + const maxBytes = typeof payload.maxBytes === "number" && Number.isFinite(payload.maxBytes) + ? Math.max(1_024, Math.min(2_000_000, Math.floor(payload.maxBytes))) + : 2_000_000; + // Capture the tail point before reading history. Events committed + // during the snapshot can then be replayed (and client-deduped), but + // can never fall into the gap between history collection and offset. + const offset = fs.existsSync(transcriptPath) ? fs.statSync(transcriptPath).size : 0; + const history = (await args.personalChatScope.call("getEventHistory", { + sessionId, + maxBytes, + })).result as { events?: AgentChatEventEnvelope[]; truncated?: boolean }; + peer.personalChatSubscriptions.set(sessionId, { transcriptPath, offset }); + send(peer.ws, "chat_subscribe", { + sessionId, + capturedAt: nowIso(), + truncated: history.truncated === true, + events: history.events ?? [], + turnActive: await args.personalChatScope.isTurnActive(sessionId), + } satisfies SyncChatSubscribeSnapshotPayload, envelope.requestId); + break; + } + case "chat_unsubscribe": { + const payload = envelope.payload as SyncChatUnsubscribePayload | null; + if (payload?.chatScope === "personal") { + const sessionId = optionalString(payload.sessionId); + if (sessionId) peer.personalChatSubscriptions.delete(sessionId); + } + break; + } case "heartbeat": { const payload = envelope.payload as { kind?: string; sentAt?: string } | null; if (payload?.kind === "ping") { @@ -558,10 +659,41 @@ export function createBrainProjectActionsSyncHandler( // staring at a 30s timeout and a vague "took too long" banner for // EVERY surface; answer immediately with a self-describing failure so // the app can show what actually happened and retry. - const payload = envelope.payload as { commandId?: string; action?: string } | null; + const payload = envelope.payload as SyncCommandPayload | null; const commandId = typeof payload?.commandId === "string" && payload.commandId ? payload.commandId : envelope.requestId ?? ""; + const action = typeof payload?.action === "string" ? payload.action : ""; + const commandArgs = payload?.args ?? {}; + const descriptors = personalChatCommandDescriptors(args.personalChatScope); + const descriptor = descriptors.find((entry) => entry.action === action); + if (action.startsWith("personalChats.") && args.personalChatScope && descriptor) { + send(peer.ws, "command_ack", { + commandId, + accepted: true, + status: "accepted", + message: `Executing ${action}.`, + }, envelope.requestId); + try { + const result = action === "personalChats.streamEvents" + ? await args.personalChatScope.streamEvents(commandArgs) + : (await args.personalChatScope.call( + action.slice("personalChats.".length), + commandArgs, + )).result; + send(peer.ws, "command_result", { commandId, ok: true, result }, envelope.requestId); + } catch (error) { + send(peer.ws, "command_result", { + commandId, + ok: false, + error: { + code: "command_failed", + message: error instanceof Error ? error.message : String(error), + }, + }, envelope.requestId); + } + break; + } args.logger.warn("sync_brain.command_without_project_host", { action: typeof payload?.action === "string" ? payload.action : null, peerDeviceId: peer.metadata?.deviceId ?? null, @@ -591,7 +723,24 @@ export function createBrainProjectActionsSyncHandler( authenticated: false, authTimeout: null, metadata: null, + personalChatSubscriptions: new Map(), }; + let personalChatPumpRunning = false; + const personalChatPump = setInterval(() => { + if (!peer.authenticated || peer.ws.readyState !== WS_OPEN || personalChatPumpRunning) return; + personalChatPumpRunning = true; + void (async () => { + for (const [sessionId, subscription] of peer.personalChatSubscriptions) { + const next = await readPersonalChatEventsSince(subscription.transcriptPath, subscription.offset); + for (const event of next.events) send(peer.ws, "chat_event", event); + subscription.offset = next.nextOffset; + peer.personalChatSubscriptions.set(sessionId, subscription); + } + })().finally(() => { + personalChatPumpRunning = false; + }); + }, pollIntervalMs); + personalChatPump.unref?.(); const clearAuthTimeout = (): void => { if (!peer.authTimeout) return; clearTimeout(peer.authTimeout); @@ -772,6 +921,7 @@ export function createBrainProjectActionsSyncHandler( peer.metadata = hello.peer; const catalog = await projectCatalog(args.projectCatalogProvider, args.logger); const brain = brainMetadata(); + const personalDescriptors = personalChatCommandDescriptors(args.personalChatScope); send(ws, "hello_ok", buildSyncHostHelloOkPayload({ peer: hello.peer, brain, @@ -782,8 +932,8 @@ export function createBrainProjectActionsSyncHandler( projectCatalogEnabled: true, crossProjectChatEnabled: false, projectActionsEnabled: projectActionsEnabled(args.projectCatalogProvider), - remoteCommandSupportedActions: [], - remoteCommandDescriptors: [], + remoteCommandSupportedActions: personalDescriptors.map((entry) => entry.action), + remoteCommandDescriptors: personalDescriptors, localCommandDescriptors: [], compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, cloudRelayWssUrl: args.getCloudRelayWssUrl?.() ?? null, @@ -807,6 +957,8 @@ export function createBrainProjectActionsSyncHandler( }); ws.on("close", () => { clearAuthTimeout(); + clearInterval(personalChatPump); + peer.personalChatSubscriptions.clear(); }); }; } diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index 70c35e6ea..065124c30 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -6,6 +6,8 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vites import type { AgentChatEventEnvelope, CrsqlChangeRow, + PersonalChatAction, + PersonalChatScopeContract, SyncMobileProjectSummary, SyncPeerMetadata, SyncProjectCatalogPayload, @@ -453,6 +455,134 @@ describe("buildChangesetBatchPayload", () => { }); describe("brain project actions fallback handler", () => { + it("serves personal commands and chat streams with zero projects", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const secretsDir = path.join(projectRoot, "secrets"); + fs.mkdirSync(secretsDir, { recursive: true }); + const transcriptPath = path.join(projectRoot, "personal-chat.jsonl"); + fs.writeFileSync(transcriptPath, ""); + const credentialStore = new EncryptedFileCredentialStore({ + secretsDir, + keyMaterialProvider: () => null, + }); + credentialStore.setSync("test.bootstrap", "bootstrap-token"); + const personalChatScope: PersonalChatScopeContract = { + capabilities: vi.fn(() => ({ + version: 1 as const, + actions: ["list", "terminalCreate", "saveTempAttachment"], + })), + call: vi.fn(async (action: unknown) => ({ + action: action as PersonalChatAction, + result: action === "getEventHistory" + ? { events: [], truncated: false } + : [{ sessionId: "personal-1", surface: "personal" }], + })), + streamEvents: vi.fn(async () => ({ events: [], nextCursor: 0, hasMore: false })), + transcriptPath: vi.fn(async () => transcriptPath), + isTurnActive: vi.fn(async () => true), + }; + const handler = createBrainProjectActionsSyncHandler({ + logger: createDiscoveryLogger(), + projectCatalogProvider: { + listProjects: vi.fn(async () => ({ projects: [] })), + prepareProjectConnection: vi.fn(async () => ({ ok: false, message: "No projects." })), + }, + bootstrapCredentialStore: credentialStore, + bootstrapTokenKey: "test.bootstrap", + pairingSecretsPath: path.join(secretsDir, "sync-paired-devices.json"), + pinPath: path.join(secretsDir, "sync-pin.json"), + localDeviceIdPath: path.join(secretsDir, "sync-device-id"), + localSiteIdPath: path.join(secretsDir, "sync-site-id"), + pollIntervalMs: 100, + personalChatScope, + }); + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + server.on("connection", (ws, request) => handler({ + ws, + remoteAddress: request.socket.remoteAddress ?? null, + remotePort: request.socket.remotePort ?? null, + })); + let client: WebSocket | null = null; + try { + await new Promise((resolve, reject) => { + server.once("listening", () => resolve()); + server.once("error", reject); + }); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + client = new WebSocket(`ws://127.0.0.1:${port}`); + const { envelopes } = trackClientEnvelopes(client); + await new Promise((resolve, reject) => { + client!.once("open", () => resolve()); + client!.once("error", reject); + }); + sendHello(client, "bootstrap-token"); + const hello = await waitForValue( + () => envelopes.find((envelope) => envelope.type === "hello_ok"), + "personal fallback hello", + ); + expect(hello.payload).toMatchObject({ + projects: [], + features: { + commandRouting: { + actions: expect.arrayContaining([ + expect.objectContaining({ action: "personalChats.list", scope: "runtime" }), + expect.objectContaining({ action: "personalChats.terminalCreate", scope: "runtime" }), + expect.objectContaining({ action: "personalChats.streamEvents", scope: "runtime" }), + ]), + }, + }, + }); + + client.send(encodeSyncEnvelope({ + type: "command", + requestId: "personal-list-request", + payload: { + commandId: "personal-list", + action: "personalChats.list", + args: {}, + }, + })); + const result = await waitForEnvelope(envelopes, "command_result", "personal-list-request"); + expect(result.payload).toMatchObject({ + commandId: "personal-list", + ok: true, + result: [{ sessionId: "personal-1", surface: "personal" }], + }); + + client.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "personal-subscribe", + payload: { sessionId: "personal-1", chatScope: "personal" }, + })); + const snapshot = await waitForEnvelope(envelopes, "chat_subscribe", "personal-subscribe"); + expect(snapshot.payload).toMatchObject({ + sessionId: "personal-1", + events: [], + turnActive: true, + }); + const event: AgentChatEventEnvelope = { + sessionId: "personal-1", + timestamp: "2026-07-09T12:00:00.000Z", + sequence: 1, + event: { type: "text", text: "hello from fallback" }, + }; + fs.appendFileSync(transcriptPath, `${JSON.stringify(event)}\n`); + const streamed = await waitForValue( + () => envelopes.find((envelope) => + envelope.type === "chat_event" + && (envelope.payload as AgentChatEventEnvelope).event.type === "text" + ), + "personal fallback chat event", + ); + expect(streamed.payload).toMatchObject({ sessionId: "personal-1", sequence: 1 }); + } finally { + try { client?.close(); } catch {} + await new Promise((resolve) => server.close(() => resolve())); + cleanup(); + } + }); + it("does not send a second switch result if post-response project completion fails", async () => { const { projectRoot, cleanup } = createTempProjectRoot(); const secretsDir = path.join(projectRoot, "secrets"); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 25b7c02c5..c1e542b7f 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -36,6 +36,7 @@ import type { LaneSummary, PtyDataEvent, PtyExitEvent, + PersonalChatScopeContract, SyncBrainStatusPayload, SyncChangesetAckPayload, SyncChangesetBatchPayload, @@ -603,6 +604,10 @@ type SyncHostServiceArgs = { ptyService: ReturnType; processService?: ReturnType; agentChatService?: ReturnType; + personalChatScope?: Pick< + PersonalChatScopeContract, + "call" | "streamEvents" | "transcriptPath" | "isTurnActive" + >; aiIntegrationService?: ReturnType | null; orchestrationService?: ReturnType | null; /** Brain→push-relay publisher; forwarded to the default remote-command service. */ @@ -1268,6 +1273,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { conflictService: args.conflictService, operationService: args.operationService, agentChatService: args.agentChatService, + personalChatScope: args.personalChatScope, aiIntegrationService: args.aiIntegrationService, orchestrationService: args.orchestrationService, pushPublisherService: args.pushPublisherService, @@ -4402,12 +4408,18 @@ export function createSyncHostService(args: SyncHostServiceArgs) { return; } - const projectScope = resolveSyncHostInboundProjectScope( - envelope.type, - envelope.projectId, - args.projectId, - hostProjectIdAliases, - ); + const envelopePayload = safeObjectValue(envelope.payload); + const personalChatEnvelope = + (envelope.type === "chat_subscribe" || envelope.type === "chat_unsubscribe") + && envelopePayload?.chatScope === "personal"; + const projectScope: SyncHostProjectScopeResolution = personalChatEnvelope + ? { ok: true, projectId: null, usedSingleProjectFallback: false } + : resolveSyncHostInboundProjectScope( + envelope.type, + envelope.projectId, + args.projectId, + hostProjectIdAliases, + ); if (!projectScope.ok) { rejectProjectScopedEnvelope(peer, envelope.type, envelope.requestId, envelope.payload, projectScope); return; @@ -4727,6 +4739,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const payload = envelope.payload as SyncChatSubscribePayload | null; const sessionId = toOptionalString(payload?.sessionId); if (!sessionId) break; + const personalChatRequested = payload?.chatScope === "personal"; // Cross-project "quick look": a payload targeting a registered FOREIGN // project is served read-only from that project's `.ade` transcript @@ -4738,7 +4751,14 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // including in the pump: a rejected scope must not register a live // subscription at all, or the periodic pump would stream the ACTIVE // project's transcript for the same session id after the empty ack. - const foreignScope = resolveForeignChatScope(payload, sessionId); + const personalTranscriptPath = personalChatRequested + ? await args.personalChatScope?.transcriptPath?.(sessionId).catch(() => null) ?? null + : null; + const foreignScope = personalChatRequested + ? personalTranscriptPath + ? { kind: "foreign" as const, transcriptPath: personalTranscriptPath } + : { kind: "rejected" as const } + : resolveForeignChatScope(payload, sessionId); const foreignTranscriptPath = foreignScope.kind === "foreign" ? foreignScope.transcriptPath : null; if (foreignScope.kind === "rejected") { peer.subscribedChatSessionIds.delete(sessionId); @@ -4764,6 +4784,10 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // Foreign quick-looks have no live agent chat service here, so they // derive turn state from the streamed status events instead. const resolveLiveStatusFields = async (): Promise<{ turnActive?: boolean }> => { + if (personalChatRequested) { + const turnActive = await args.personalChatScope?.isTurnActive?.(sessionId).catch(() => false); + return typeof turnActive === "boolean" ? { turnActive } : {}; + } if (foreignScope.kind !== "local") return {}; const liveSummary = await args.agentChatService?.getSessionSummary(sessionId).catch(() => null); return liveSummary ? { turnActive: liveSummary.status === "active" } : {}; diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index 25a0f1f08..20ec9b9ab 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -34,6 +34,10 @@ function createService(options?: { getPairingConnectInfo?: () => SyncPairingConnectInfo | null; isCloudRelayEnabled?: () => boolean; usageTrackingService?: Record; + personalChatScope?: { + call: ReturnType; + streamEvents?: ReturnType; + }; }) { const ptyService = { resumeSession: vi.fn().mockResolvedValue({ @@ -78,6 +82,7 @@ function createService(options?: { ...(options?.getPairingConnectInfo ? { getPairingConnectInfo: options.getPairingConnectInfo } : {}), ...(options?.isCloudRelayEnabled ? { isCloudRelayEnabled: options.isCloudRelayEnabled } : {}), ...(options?.usageTrackingService ? { usageTrackingService: options.usageTrackingService } : {}), + ...(options?.personalChatScope ? { personalChatScope: options.personalChatScope } : {}), logger, } as any); return { service, ptyService, sessionService, externalSessionsService: options?.externalSessionsService, logger }; @@ -119,6 +124,49 @@ describe("createSyncRemoteCommandService", () => { ); }); + it("advertises and executes machine personal chats as runtime commands", async () => { + const personalChatScope = { + call: vi.fn(async (action: string, args: unknown) => ({ + action, + result: { action, args }, + })), + streamEvents: vi.fn(async () => ({ events: [], nextCursor: 0 })), + }; + const { service } = createService({ personalChatScope }); + + expect(service.getDescriptor("personalChats.create")).toEqual({ + action: "personalChats.create", + scope: "runtime", + policy: { viewerAllowed: true, queueable: false }, + }); + expect(service.getDescriptor("personalChats.cancelDispatchedSteer")).toEqual({ + action: "personalChats.cancelDispatchedSteer", + scope: "runtime", + policy: { viewerAllowed: true, queueable: false }, + }); + expect(service.getDescriptor("personalChats.streamEvents")).toEqual({ + action: "personalChats.streamEvents", + scope: "runtime", + policy: { viewerAllowed: true, queueable: false }, + }); + expect(service.getDescriptor("personalChats.terminalCreate")).toEqual({ + action: "personalChats.terminalCreate", + scope: "runtime", + policy: { viewerAllowed: true, queueable: false }, + }); + await expect(service.execute(makePayload("personalChats.send", { + sessionId: "personal-1", + text: "hello", + }))).resolves.toEqual({ + action: "send", + args: { sessionId: "personal-1", text: "hello" }, + }); + expect(personalChatScope.call).toHaveBeenCalledWith("send", { + sessionId: "personal-1", + text: "hello", + }); + }); + it("registers sync.getWebPairingInfo and returns the configured browser pairing info", async () => { const syncPinStore = { getPin: vi.fn(() => "428193"), diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index e05ac52c4..41e99a238 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -102,6 +102,7 @@ import type { LandPrArgs, LandQueueNextArgs, PauseQueueAutomationArgs, + PersonalChatScopeContract, PrGithubCoords, PublishProjectInput, PublishProjectResult, @@ -175,6 +176,7 @@ import type { } from "../../../../desktop/src/shared/types"; import { isAdeUsageRangePreset } from "../../../../desktop/src/shared/types"; import type { OrchestrationRunCreateRequest } from "../../../../desktop/src/shared/types/orchestration"; +import { PERSONAL_CHAT_ACTIONS, isPersonalChatActionQueueable } from "../../../../desktop/src/shared/types/personalChats"; import { buildTrackedCliLaunchCommand, deriveTrackedCliInitialInputSessionMeta, @@ -207,6 +209,7 @@ import type { PushPublisherService } from "../push/pushPublisherService"; import { deriveDeterministicLaneNameFromPrompt } from "../../../../desktop/src/shared/laneNameFallback"; import { resolveLaneCreateRemoteBase } from "../laneCreateRemoteBase"; import { normalizePrCreationStrategy } from "../../../../desktop/src/shared/prStrategy"; +import { readImageFileAndSniffMime, saveImageTempAttachment } from "../imageAttachment"; import { buildAiSettingsStatus, getUnavailableAiStatus, isDatabaseClosedError } from "../../../../desktop/src/main/services/ai/aiSettingsStatus"; import type { createAiIntegrationService } from "../../../../desktop/src/main/services/ai/aiIntegrationService"; import type { createAgentChatService } from "../../../../desktop/src/main/services/chat/agentChatService"; @@ -286,6 +289,7 @@ type SyncRemoteCommandServiceArgs = { operationService?: ReturnType | null; aiIntegrationService?: ReturnType | null; agentChatService?: ReturnType; + personalChatScope?: Pick; orchestrationService?: ReturnType | null; ctoStateService?: ReturnType | null; ctoMemoryService?: CtoMemoryService | null; @@ -534,19 +538,6 @@ function parsePublishCurrentProjectArgs(value: Record): Publish }; } -const MAX_IMAGE_BYTES = 10 * 1024 * 1024; -const MAX_TEMP_ATTACHMENT_BYTES = 10 * 1024 * 1024; -const IMAGE_MIME_BY_EXTENSION: Record = { - ".bmp": "image/bmp", - ".gif": "image/gif", - ".ico": "image/x-icon", - ".jpeg": "image/jpeg", - ".jpg": "image/jpeg", - ".png": "image/png", - ".svg": "image/svg+xml", - ".webp": "image/webp", -}; - function requireProjectRoot(args: SyncRemoteCommandServiceArgs, action: string): string { return requireString(args.projectRoot, `${action} requires a project root.`); } @@ -1311,124 +1302,12 @@ function resolveAllowedProjectPath(args: SyncRemoteCommandServiceArgs, rawPath: return resolvePathWithinRoot(projectRoot, normalized); } -function sniffImageMimeType(buffer: Buffer): string | null { - if (buffer.length >= 8 - && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47 - && buffer[4] === 0x0D && buffer[5] === 0x0A && buffer[6] === 0x1A && buffer[7] === 0x0A) { - return "image/png"; - } - if (buffer.length >= 3 && buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) { - return "image/jpeg"; - } - if (buffer.length >= 6 - && buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38 - && (buffer[4] === 0x37 || buffer[4] === 0x39) && buffer[5] === 0x61) { - return "image/gif"; - } - if (buffer.length >= 12 - && buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 - && buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) { - return "image/webp"; - } - if (buffer.length >= 2 && buffer[0] === 0x42 && buffer[1] === 0x4D) { - return "image/bmp"; - } - if (buffer.length >= 4 - && buffer[0] === 0x00 && buffer[1] === 0x00 - && buffer[2] === 0x01 && buffer[3] === 0x00) { - return "image/x-icon"; - } - const head = buffer.slice(0, Math.min(buffer.length, 1024)).toString("utf8"); - const stripped = head.replace(/^/, "").trimStart(); - if (/^<\?xml\b/i.test(stripped) && / { - const stat = await fs.promises.stat(filePath); - if (!stat.isFile()) throw new Error("Path is not a file."); - if (stat.size > MAX_IMAGE_BYTES) throw new Error("Image must be 10 MB or smaller."); - const data = await fs.promises.readFile(filePath); - const mimeType = sniffImageMimeType(data); - if (!mimeType) throw new Error("Path is not an image."); - return { data, mimeType }; -} - -function normalizeImageMime(mime: unknown, filename: string): string { - const raw = typeof mime === "string" ? mime.trim().toLowerCase() : ""; - const fromExtension = IMAGE_MIME_BY_EXTENSION[path.extname(filename).toLowerCase()]; - const normalized = raw || fromExtension || ""; - if (!Object.values(IMAGE_MIME_BY_EXTENSION).includes(normalized)) { - throw new Error("Temporary attachment mime must be a supported image type."); - } - return normalized; -} - -function decodeBase64ImagePayload(value: string): Buffer { - const compact = value.replace(/\s+/g, ""); - if (!compact || !/^[A-Za-z0-9+/]+={0,2}$/.test(compact) || compact.length % 4 === 1) { - throw new Error("Temporary attachment base64 is invalid."); - } - const maxEncodedLength = Math.ceil(MAX_TEMP_ATTACHMENT_BYTES / 3) * 4; - if (compact.length > maxEncodedLength) { - throw new Error("Temporary attachments must be 10 MB or smaller."); - } - const content = Buffer.from(compact, "base64"); - if (content.byteLength > MAX_TEMP_ATTACHMENT_BYTES) { - throw new Error("Temporary attachments must be 10 MB or smaller."); - } - return content; -} - -function parseTempAttachmentPayload(payload: Record): { content: Buffer; filename: string; mimeType: string } { - const dataUrl = typeof payload.dataUrl === "string" ? payload.dataUrl.trim() : ""; - const filename = asTrimmedString(payload.filename) ?? "attachment.png"; - if (dataUrl) { - const match = /^data:([^;,]+)(;base64)?,(.*)$/s.exec(dataUrl); - if (!match) throw new Error("Temporary attachment dataUrl is invalid."); - const mimeType = normalizeImageMime(match[1], filename); - const isBase64 = match[2] === ";base64"; - const content = isBase64 - ? decodeBase64ImagePayload(match[3] ?? "") - : Buffer.from(decodeURIComponent(match[3] ?? ""), "utf8"); - if (content.byteLength > MAX_TEMP_ATTACHMENT_BYTES) { - throw new Error("Temporary attachments must be 10 MB or smaller."); - } - const sniffed = sniffImageMimeType(content); - if (sniffed !== mimeType) throw new Error("Temporary attachment MIME type does not match payload."); - return { content, filename, mimeType }; - } - - const base64 = typeof payload.base64 === "string" - ? payload.base64 - : typeof payload.data === "string" - ? payload.data - : ""; - const mimeType = normalizeImageMime(payload.mime ?? payload.mimeType, filename); - const content = decodeBase64ImagePayload(base64); - const sniffed = sniffImageMimeType(content); - if (sniffed !== mimeType) throw new Error("Temporary attachment MIME type does not match payload."); - return { content, filename, mimeType }; -} - async function saveAgentChatTempAttachment( args: SyncRemoteCommandServiceArgs, payload: Record, ): Promise<{ path: string; mimeType: string; previewDataUrl: string | null }> { - const { content, filename, mimeType } = parseTempAttachmentPayload(payload); const projectRoot = requireProjectRoot(args, "chat.saveTempAttachment"); - const baseDir = path.join(projectRoot, ".ade", "attachments"); - await fs.promises.mkdir(baseDir, { recursive: true }); - const ext = path.extname(filename) || Object.entries(IMAGE_MIME_BY_EXTENSION) - .find(([, entryMime]) => entryMime === mimeType)?.[0] || ".png"; - const destPath = path.join(baseDir, `${randomUUID()}${ext}`); - await fs.promises.writeFile(destPath, content); - return { path: destPath, mimeType, previewDataUrl: null }; + return await saveImageTempAttachment(path.join(projectRoot, ".ade", "attachments"), payload); } function inferPrAiProvider(modelId: string): "codex" | "claude" { @@ -3833,6 +3712,25 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio } +function registerPersonalChatRemoteCommands({ args, register }: RemoteCommandRegistrationDeps): void { + const scope = args.personalChatScope; + if (!scope) return; + for (const action of PERSONAL_CHAT_ACTIONS) { + register( + `personalChats.${action}`, + { viewerAllowed: true, queueable: isPersonalChatActionQueueable(action) }, + async (payload) => (await scope.call(action, payload)).result, + "runtime", + ); + } + register( + "personalChats.streamEvents", + { viewerAllowed: true, queueable: false }, + async (payload) => await scope.streamEvents(payload), + "runtime", + ); +} + function registerPushRemoteCommands({ args, register }: RemoteCommandRegistrationDeps): void { const publisher = args.pushPublisherService ?? null; const unavailable = (action: string) => async (): Promise => { @@ -4570,6 +4468,7 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg registerWorkRemoteCommands({ args, register }); registerProcessRemoteCommands({ args, register }); registerChatRemoteCommands({ args, register }); + registerPersonalChatRemoteCommands({ args, register }); registerModelPickerRemoteCommands({ args, register }); registerPushRemoteCommands({ args, register }); registerSyncRemoteCommands({ args, register }); diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index 94b8cdec6..83007e924 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -8,6 +8,7 @@ import type { SyncDeviceRuntimeState, SyncGetStatusArgs, SyncPairingConnectInfo, + PersonalChatScopeContract, SyncRoleSnapshot, SyncTailnetDiscoveryStatus, SyncTransferBlocker, @@ -112,6 +113,7 @@ type SyncServiceArgs = { typeof createComputerUseArtifactBrokerService >; agentChatService: ReturnType; + personalChatScope?: PersonalChatScopeContract; /** Brain→push-relay publisher; threaded to the runtime remote-command service. */ pushPublisherService?: PushPublisherService | null; ctoStateService?: ReturnType | null; @@ -602,6 +604,7 @@ export function createSyncService(args: SyncServiceArgs) { operationService: args.operationService, aiIntegrationService: args.aiIntegrationService, agentChatService: args.agentChatService, + personalChatScope: args.personalChatScope, orchestrationService: args.orchestrationService, pushPublisherService: args.pushPublisherService, ctoStateService: args.ctoStateService, @@ -715,6 +718,7 @@ export function createSyncService(args: SyncServiceArgs) { projectCatalogProvider: args.projectCatalogProvider, rosterProvider: args.rosterProvider, foreignChatProvider: args.foreignChatProvider, + personalChatScope: args.personalChatScope, remoteCommandService, remoteCommandExecutor: args.remoteCommandExecutor, requireDpop: () => securityStore.getRequireDpop(), diff --git a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts index dd95bf0c6..0143ff88a 100644 --- a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts +++ b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts @@ -639,6 +639,37 @@ describe("createBuiltInBrowserService — bounds and status dedupe", () => { expect(fakes.partitionCalls).toEqual([partitionA, partitionA, partitionC]); }); + it("keeps an explicitly global browser profile isolated from the sender window project", async () => { + const projectRootByWindow = new Map(); + const service = createBuiltInBrowserService({ + onEvent: collector.onEvent, + getProjectRootForWindow: (win) => projectRootByWindow.get(win.id) ?? null, + }); + const win = fakeBrowserWindow(); + projectRootByWindow.set(win.id, "/Users/ade/project-alpha"); + const browserWin = win as unknown as Parameters[0]; + + service.attachToWindow(browserWin); + await service.createTab({ url: "https://project.example.test", activate: true }, browserWin); + await service.createTab({ + profileScope: "global", + url: "https://personal.example.test", + activate: true, + }, browserWin); + + expect(service.getStatus({ projectRoot: "/Users/ade/project-alpha" }, browserWin)).toMatchObject({ + profileProjectRoot: "/Users/ade/project-alpha", + url: "https://project.example.test/", + }); + expect(service.getStatus({ profileScope: "global" }, browserWin)).toMatchObject({ + partition: "persist:ade-browser", + profileProjectRoot: null, + url: "https://personal.example.test/", + }); + expect(service.getStatus({ profileScope: "global" }, browserWin).partition) + .not.toBe(service.getStatus({ projectRoot: "/Users/ade/project-alpha" }, browserWin).partition); + }); + it("rejects attached webviews from another project browser profile", async () => { const projectRootByWindow = new Map(); const service = createBuiltInBrowserService({ diff --git a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts index 231987f25..a0a10d50f 100644 --- a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts +++ b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts @@ -281,6 +281,9 @@ export function createBuiltInBrowserService(args: { return typeof projectRoot === "string" ? normalizedProjectRoot(projectRoot) : null; }; + const requestsGlobalProfile = (input: unknown): boolean => + isRecord(input) && input.profileScope === "global"; + const liveWindowForProjectRoot = (projectRoot: string): BrowserWindow | null => { const normalized = normalizedProjectRoot(projectRoot); if (!normalized) return null; @@ -388,10 +391,36 @@ export function createBuiltInBrowserService(args: { return service; }; + const serviceForGlobalProfile = (sourceWindow?: BrowserWindow | null): WindowBrowserService => { + const activeWindow = activeWindowId == null + ? null + : windowClosedListeners.get(activeWindowId)?.win ?? null; + const win = isLiveWindow(sourceWindow) + ? sourceWindow + : isLiveWindow(activeWindow) + ? activeWindow + : null; + if (!win) { + if (!fallbackService) { + fallbackService = createBuiltInBrowserWindowService({ + getLogger: args.getLogger, + onEvent: (payload) => args.onEvent?.(payload, null), + profile: profileForProjectRoot(null), + }); + } + return fallbackService; + } + activeWindowId = win.id; + const service = serviceForWindowProfile(win, profileForProjectRoot(null), { markActive: true }); + service.attachToWindow(win); + return service; + }; + const serviceForInput = ( input?: BuiltInBrowserProjectScopeArgs | null, sourceWindow?: BrowserWindow | null, ): WindowBrowserService => { + if (requestsGlobalProfile(input)) return serviceForGlobalProfile(sourceWindow); const projectRoot = projectRootFromInput(input); if (projectRoot) return serviceForProjectRoot(projectRoot); if (isLiveWindow(sourceWindow)) return serviceForWindow(sourceWindow); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index ad386d0f7..348d16181 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -4914,11 +4914,13 @@ function buildCodexDeveloperInstructions(args: { | "orchestrationTag" | "orchestrationParentSessionId" | "orchestrationStepId" + | "surface" >; collaborationMode: "default" | "plan"; /** Optional Linear-tracked-work directive appended to the base instructions. */ linearDirective?: string | null; }): string { + if (args.session.surface === "personal") return PERSONAL_CHAT_SYSTEM_PROMPT; const promptMode = args.collaborationMode === "plan" || args.session.interactionMode === "plan" ? "planning" : "coding"; @@ -4948,7 +4950,7 @@ function resolveCodexInstructionCollaborationMode( function buildCodexCollaborationMode( session: Pick< AgentChatSession, - "provider" | "permissionMode" | "interactionMode" | "model" | "reasoningEffort" | "codexConfigSource" + "provider" | "permissionMode" | "interactionMode" | "model" | "reasoningEffort" | "codexConfigSource" | "surface" >, supportedModes: Set | null, laneWorktreePath: string, @@ -4969,7 +4971,7 @@ function buildCodexCollaborationMode( settings: { model: session.model, reasoning_effort: session.reasoningEffort ?? DEFAULT_REASONING_EFFORT, - developer_instructions: mode === "plan" + developer_instructions: mode === "plan" && session.surface !== "personal" ? null : buildCodexDeveloperInstructions({ laneWorktreePath, @@ -5429,6 +5431,17 @@ function isLightweightSession(session: Pick) return session.sessionProfile === "light"; } +const PERSONAL_CHAT_SYSTEM_PROMPT = [ + "You are a general-purpose AI assistant in an ADE personal chat.", + "This conversation is not attached to a software project, repository, branch, lane, or pull request.", + "Answer the user's request directly. Do not assume they want coding work or inspect files unless they explicitly ask.", + "If filesystem or shell work is explicitly requested, keep it inside the scratch working directory provided by the runtime.", +].join(" "); + +function isPersonalSession(session: Pick): boolean { + return session.surface === "personal"; +} + export function createAgentChatService(args: { projectRoot: string; adeDir?: string; @@ -5599,10 +5612,25 @@ export function createAgentChatService(args: { const env: NodeJS.ProcessEnv = { ...(getAdeCliAgentEnv?.(process.env) ?? process.env), ADE_CHAT_SESSION_ID: managed.session.id, - ADE_LANE_ID: managed.session.laneId, - ADE_PROJECT_ROOT: projectRoot, - ADE_WORKSPACE_ROOT: managed.laneWorktreePath, + ...(isPersonalSession(managed.session) + ? { ADE_CHAT_SCOPE: "personal" } + : { + ADE_LANE_ID: managed.session.laneId, + ADE_PROJECT_ROOT: projectRoot, + ADE_WORKSPACE_ROOT: managed.laneWorktreePath, + }), }; + if (isPersonalSession(managed.session)) { + delete env.ADE_LANE_ID; + delete env.ADE_PROJECT_ROOT; + delete env.ADE_WORKSPACE_ROOT; + delete env.ADE_REPO_ROOT; + delete env.INIT_CWD; + delete env.OLDPWD; + // Keep shell-visible PWD aligned with the actual scratch cwd rather + // than leaking whichever project happened to launch the ADE brain. + env.PWD = managed.laneWorktreePath; + } const linearContext = writeSessionLinearIssueContext(managed.session.id); if (linearContext) { env.ADE_LINEAR_ISSUE_IDS = linearContext.identifiers; @@ -9217,7 +9245,9 @@ export function createAgentChatService(args: { : undefined; const cursorConfigValues = normalizeCursorConfigValueRecord(record.cursorConfigValues); const identityKey = normalizeIdentityKey(record.identityKey); - const surface = record.surface === "automation" ? record.surface : "work"; + const surface = record.surface === "automation" || record.surface === "personal" + ? record.surface + : "work"; const capabilityMode = normalizeCapabilityMode(record.capabilityMode); const completion = normalizePersistedCompletion(record.completion); const codexGoal = normalizeCodexGoalPayload(record.codexGoal); @@ -20601,10 +20631,11 @@ export function createAgentChatService(args: { managed.session.claudePermissionMode = claudePermissionMode; managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; const lightweight = isLightweightSession(managed.session); + const personalSession = isPersonalSession(managed.session); const claudeEnv = buildAgentRuntimeEnv(managed); const claudeExecutable = resolveClaudeCodeExecutable({ env: claudeEnv }); const outputStyle = resolveManagedClaudeOutputStyle(managed); - const pluginPaths = discoverClaudePluginPaths(managed.laneWorktreePath); + const pluginPaths = personalSession ? [] : discoverClaudePluginPaths(managed.laneWorktreePath); const claudeDescriptor = resolveSessionModelDescriptor(managed.session); const opts: ClaudeSDKOptions = { cwd: managed.laneWorktreePath, @@ -20681,7 +20712,9 @@ export function createAgentChatService(args: { source: claudeExecutable.source, path: claudeExecutable.path, }); - if (!lightweight) { + if (personalSession) { + opts.systemPrompt = PERSONAL_CHAT_SYSTEM_PROMPT; + } else if (!lightweight) { opts.toolConfig = { askUserQuestion: { previewFormat: "markdown", @@ -21047,7 +21080,8 @@ export function createAgentChatService(args: { const executionContext = resolveManagedExecutionContext(managed, { purpose: "deliver queued steer", }); - const laneDirectiveKey = executionContext.laneDirectiveKey; + const personalSession = isPersonalSession(managed.session); + const laneDirectiveKey = personalSession ? null : executionContext.laneDirectiveKey; const shouldInjectLaneDirective = laneDirectiveKey != null && managed.lastLaneDirectiveKey !== laneDirectiveKey; const promptText = composeLaunchDirectives(trimmed, [ @@ -21057,6 +21091,7 @@ export function createAgentChatService(args: { laneWorktreePath: executionContext.laneWorktreePath, }) : null, + personalSession ? PERSONAL_CHAT_SYSTEM_PROMPT : null, buildChatContextAttachmentPrompt(nextSteer.contextAttachments) || null, ]); @@ -22803,7 +22838,8 @@ export function createAgentChatService(args: { managed.session.interactionMode = interactionMode ?? managed.session.interactionMode ?? "default"; managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; } - const laneDirectiveKey = executionContext.laneDirectiveKey; + const personalSession = isPersonalSession(managed.session); + const laneDirectiveKey = personalSession ? null : executionContext.laneDirectiveKey; const shouldInjectLaneDirective = laneDirectiveKey != null && managed.lastLaneDirectiveKey !== laneDirectiveKey; // Guidance injection is capability-based, not session-state-based: // Claude sessions receive lane-scoped ADE guidance in their persistent system @@ -22813,14 +22849,14 @@ export function createAgentChatService(args: { // including on resumed sessions where `shouldInjectLaneDirective` is false. const providerHasPersistentGuidance = managed.session.provider === "claude" || managed.session.provider === "codex"; - const shouldInjectGuidance = !providerHasPersistentGuidance; + const shouldInjectGuidance = !personalSession && !providerHasPersistentGuidance; const claudeRuntimeSlashCommandNames = managed.runtime?.kind === "claude" ? new Set(managed.runtime.slashCommands.map((command) => slashCommandKey(command.name))) : new Set(); const codexRuntimeSlashCommandNames = managed.runtime?.kind === "codex" ? new Set((managed.runtime as { slashCommands?: Array<{ name: string }> }).slashCommands?.map((command) => slashCommandKey(command.name)) ?? []) : new Set(); - const expandedSlashCommandPrompt = providerSlashCommand + const expandedSlashCommandPrompt = providerSlashCommand && !personalSession ? resolveProviderSlashCommandPrompt({ provider: managed.session.provider, cwd: managed.laneWorktreePath, @@ -22832,10 +22868,10 @@ export function createAgentChatService(args: { codexRuntimeSlashCommandNames, }) : null; - const contextAttachmentPrompt = providerSlashCommand + const contextAttachmentPrompt = providerSlashCommand && !personalSession ? "" : buildChatContextAttachmentPrompt(publicContextAttachments); - const promptText = providerSlashCommand + const promptText = providerSlashCommand && !personalSession ? expandedSlashCommandPrompt ?? trimmed : composeLaunchDirectives(trimmed, [ shouldInjectLaneDirective @@ -22844,12 +22880,15 @@ export function createAgentChatService(args: { laneWorktreePath: executionContext.laneWorktreePath, }) : null, - buildExecutionModeDirective(executionMode, managed.session.provider), - buildClaudeInteractionModeDirective(managed.session.interactionMode, managed.session.provider), + personalSession ? PERSONAL_CHAT_SYSTEM_PROMPT : null, + personalSession ? null : buildExecutionModeDirective(executionMode, managed.session.provider), + personalSession ? null : buildClaudeInteractionModeDirective(managed.session.interactionMode, managed.session.provider), shouldInjectGuidance ? buildAdeGuidanceForLane(managed.laneWorktreePath) : null, - buildComputerUseDirective( - computerUseArtifactBrokerRef?.getBackendStatus() ?? null, - ), + personalSession + ? null + : buildComputerUseDirective( + computerUseArtifactBrokerRef?.getBackendStatus() ?? null, + ), contextAttachmentPrompt || null, ]); const codexGoalTitleSeed = managed.session.provider === "codex" && isCodexGoalSlashInput(trimmed) @@ -22858,7 +22897,7 @@ export function createAgentChatService(args: { return parsed.kind === "objective" ? parsed.objective : null; })() : null; - const autoTitleSeed = codexGoalTitleSeed ?? (providerSlashCommand + const autoTitleSeed = codexGoalTitleSeed ?? (providerSlashCommand && !personalSession ? expandedSlashCommandPrompt ?? null : visibleText); if (!managed.autoTitleSeed && autoTitleSeed) { @@ -22886,9 +22925,9 @@ export function createAgentChatService(args: { ...(metadata ? { metadata } : {}), reasoningEffort, interactionMode: managed.session.provider === "claude" ? managed.session.interactionMode ?? "default" : null, - laneDirectiveKey: providerSlashCommand ? null : shouldInjectLaneDirective ? laneDirectiveKey : null, - providerSlashCommand, - forceClaudeUserMessage: managed.session.provider === "claude" && !providerSlashCommand && slashCommand != null, + laneDirectiveKey: providerSlashCommand && !personalSession ? null : shouldInjectLaneDirective ? laneDirectiveKey : null, + providerSlashCommand: personalSession ? false : providerSlashCommand === true, + forceClaudeUserMessage: managed.session.provider === "claude" && (providerSlashCommand == null || personalSession) && slashCommand != null, ...(runtime ? { runtime } : {}), ...(cloudOverrides ? { cloudOverrides } : {}), }; @@ -24316,10 +24355,12 @@ export function createAgentChatService(args: { } const isFirstSendForLane = managed.lastLaneDirectiveKey !== args.laneDirectiveKey; if (isFirstSendForLane) { - const injected = await buildCursorSdkInjectedSystemPrompt({ - runtime: "local", - laneWorktreePath: managed.laneWorktreePath, - }); + const injected = isPersonalSession(managed.session) + ? PERSONAL_CHAT_SYSTEM_PROMPT + : await buildCursorSdkInjectedSystemPrompt({ + runtime: "local", + laneWorktreePath: managed.laneWorktreePath, + }); if (injected.length) { composed = `${injected}\n\n${composed}`; } @@ -24672,10 +24713,12 @@ export function createAgentChatService(args: { const isFollowUp = Boolean(managed.session.cursorCloudAgentId); let cloudComposed = args.promptText; if (!isFollowUp) { - const injected = await buildCursorSdkInjectedSystemPrompt({ - runtime: "cloud", - laneWorktreePath: managed.laneWorktreePath, - }); + const injected = isPersonalSession(managed.session) + ? PERSONAL_CHAT_SYSTEM_PROMPT + : await buildCursorSdkInjectedSystemPrompt({ + runtime: "cloud", + laneWorktreePath: managed.laneWorktreePath, + }); if (injected.length) { cloudComposed = `${injected}\n\n${cloudComposed}`; } @@ -25477,20 +25520,22 @@ export function createAgentChatService(args: { } runtime.eventMapperState = createDroidSdkEventMapperState(); - const droidHarnessPrompt = buildCodingAgentSystemPrompt({ - cwd: managed.laneWorktreePath, - mode: resolveDroidSdkInteractionMode(managed.session) === "spec" ? "planning" : "coding", - permissionMode: toHarnessPermissionMode(managed.session.permissionMode), - interactive: true, - runtime: "droid-sdk", - adeSkillRoots: getAdeAgentSkillRootsForPrompt({ cwd: managed.laneWorktreePath }), - orchestrationRole: managed.session.orchestrationRole, - orchestrationRunId: managed.session.orchestrationRunId, - orchestrationBundlePath: managed.session.orchestrationBundlePath, - orchestrationTag: managed.session.orchestrationTag, - orchestrationParentSessionId: managed.session.orchestrationParentSessionId, - orchestrationStepId: managed.session.orchestrationStepId, - }); + const droidHarnessPrompt = isPersonalSession(managed.session) + ? PERSONAL_CHAT_SYSTEM_PROMPT + : buildCodingAgentSystemPrompt({ + cwd: managed.laneWorktreePath, + mode: resolveDroidSdkInteractionMode(managed.session) === "spec" ? "planning" : "coding", + permissionMode: toHarnessPermissionMode(managed.session.permissionMode), + interactive: true, + runtime: "droid-sdk", + adeSkillRoots: getAdeAgentSkillRootsForPrompt({ cwd: managed.laneWorktreePath }), + orchestrationRole: managed.session.orchestrationRole, + orchestrationRunId: managed.session.orchestrationRunId, + orchestrationBundlePath: managed.session.orchestrationBundlePath, + orchestrationTag: managed.session.orchestrationTag, + orchestrationParentSessionId: managed.session.orchestrationParentSessionId, + orchestrationStepId: managed.session.orchestrationStepId, + }); const sdkInput = [ droidHarnessPrompt, "", @@ -27426,7 +27471,7 @@ export function createAgentChatService(args: { return chatRows .map((row) => summarizeSessionRow(row)) .filter((summary) => includeIdentity || !summary.identityKey) - .filter((summary) => includeAutomation || (summary.surface ?? "work") === "work") + .filter((summary) => includeAutomation || (summary.surface ?? "work") !== "automation") .filter((summary) => includeArchived || summary.archivedAt == null); }; diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 7dfb35b00..a1210e428 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -2449,8 +2449,16 @@ export function registerIpc({ channel: string, ): BuiltInBrowserProjectScopeArgs => { const projectRoot = optionalBuiltInBrowserString(record, "projectRoot", channel, 4096); + const profileScope = optionalBuiltInBrowserString(record, "profileScope", channel, 16); + if (profileScope && profileScope !== "global") { + return invalidBuiltInBrowserArg(channel, "profileScope is invalid"); + } + if (profileScope === "global" && projectRoot) { + return invalidBuiltInBrowserArg(channel, "profileScope and projectRoot cannot both be set"); + } return { ...(projectRoot ? { projectRoot } : {}), + ...(profileScope === "global" ? { profileScope } : {}), }; }; diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index b4e83a849..a48683c3c 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -1122,6 +1122,61 @@ describe("registerRuntimeBridge", () => { { retryOnConnectionError: false }, ); }); + + it("routes personal chat calls through the local machine runtime without a project", async () => { + const callSync = vi.fn().mockResolvedValue({ action: "list", result: [] }); + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + localRuntimeConnectionPool: { callSync } as any, + getWindowSession: () => ({ windowId: 7, project: null, binding: null }), + }); + + await expect( + ipcHandlers.get(IPC.personalChatsCall)?.(eventForSender(), { + action: "list", + args: { includeArchived: false }, + }), + ).resolves.toEqual({ action: "list", result: [] }); + expect(callSync).toHaveBeenCalledWith("personalChats.call", { + action: "list", + args: { includeArchived: false }, + }); + expect(remoteCallMachineForTargetMock).not.toHaveBeenCalled(); + }); + + it("routes personal chat event polling to the bound remote machine", async () => { + remoteRegistryGetMock.mockReturnValue(target); + remoteCallMachineForTargetMock.mockResolvedValue({ events: [], nextCursor: 12, hasMore: false }); + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + getWindowSession: () => ({ + windowId: 7, + project: { rootPath: "/remote", displayName: "Remote" } as any, + binding: { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + projectId: "project-1", + rootPath: "/remote", + displayName: "Remote", + runtimeName: "Remote", + }, + }), + }); + + await expect( + ipcHandlers.get(IPC.personalChatsStreamEvents)?.(eventForSender(), { cursor: 12, limit: 50 }), + ).resolves.toEqual({ events: [], nextCursor: 12, hasMore: false }); + expect(remoteCallMachineForTargetMock).toHaveBeenCalledWith( + target, + "personalChats.streamEvents", + { cursor: 12, limit: 50 }, + {}, + ); + expect(remoteRegistryGetMock).toHaveBeenCalledWith("target-1"); + }); }); describe("registerIpc sync bridge", () => { diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts index ed0b9988e..3ff0b296c 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -9,6 +9,10 @@ import type { ListMyGitHubReposInput, ListMyGitHubReposResult, OpenProjectBinding, + PersonalChatCallArgs, + PersonalChatCallResponse, + PersonalChatStreamEventsArgs, + PersonalChatStreamEventsResult, ProjectInfo, ProjectBrowseInput, ProjectBrowseResult, @@ -463,6 +467,67 @@ export function registerRuntimeBridge({ } }; + const personalChatBindingForSender = (sender: WebContents): OpenProjectBinding | null => { + const windowId = BrowserWindow.fromWebContents(sender)?.id ?? null; + return getWindowSession?.(windowId)?.binding ?? null; + }; + + ipcMain.handle( + IPC.personalChatsCall, + async (event, request: PersonalChatCallArgs): Promise => { + if (!request || typeof request !== "object" || typeof request.action !== "string") { + throw new Error("A personal chat action is required."); + } + const binding = personalChatBindingForSender(event.sender); + if (binding?.kind === "remote") { + return await remoteConnectionService.callMachineMethod( + binding.targetId, + "personalChats.call", + request as unknown as Record, + ); + } + if (!localRuntimeConnectionPool) { + throw new Error("The local ADE brain is unavailable for personal chats."); + } + return await localRuntimeConnectionPool.callSync( + "personalChats.call", + request as unknown as Record, + ); + }, + ); + + ipcMain.handle( + IPC.personalChatsStreamEvents, + async ( + event, + request: PersonalChatStreamEventsArgs = {}, + ): Promise => { + const normalized: PersonalChatStreamEventsArgs = { + ...(typeof request?.cursor === "number" && Number.isFinite(request.cursor) + ? { cursor: Math.max(0, Math.floor(request.cursor)) } + : {}), + ...(typeof request?.limit === "number" && Number.isFinite(request.limit) + ? { limit: Math.max(1, Math.min(500, Math.floor(request.limit))) } + : {}), + }; + const binding = personalChatBindingForSender(event.sender); + if (binding?.kind === "remote") { + return await remoteConnectionService.callMachineMethod( + binding.targetId, + "personalChats.streamEvents", + normalized as Record, + ); + } + if (!localRuntimeConnectionPool) { + throw new Error("The local ADE brain is unavailable for personal chats."); + } + return await localRuntimeConnectionPool.callSync( + "personalChats.streamEvents", + normalized as Record, + ); + }, + ); + ipcMain.handle( IPC.remoteRuntimeListTargets, async (): Promise => { diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 957557e40..8f7889fca 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -925,6 +925,7 @@ export type LaneDeleteTeardownDeps = { export function createLaneService({ db, projectRoot, + primaryWorktreePath = projectRoot, projectId, defaultBaseRef, worktreesDir, @@ -940,6 +941,12 @@ export function createLaneService({ }: { db: AdeDb; projectRoot: string; + /** + * Filesystem boundary for the synthetic primary lane. Ordinary projects use + * `projectRoot`; machine-owned personal chats keep runtime state and agent + * scratch space in separate directories. + */ + primaryWorktreePath?: string; projectId: string; defaultBaseRef: string; worktreesDir: string; @@ -2120,7 +2127,7 @@ export function createLaneService({ const laneId = randomUUID(); const now = new Date().toISOString(); - const branchRef = await detectBranchRef(projectRoot, defaultBaseRef); + const branchRef = await detectBranchRef(primaryWorktreePath, defaultBaseRef); db.run( ` insert into lanes( @@ -2129,7 +2136,7 @@ export function createLaneService({ ) values(?, ?, ?, ?, 'primary', ?, ?, ?, null, 1, null, null, null, null, 'active', ?, null) `, - [laneId, projectId, "Primary", "Main repository workspace", defaultBaseRef, branchRef, projectRoot, now] + [laneId, projectId, "Primary", "Main repository workspace", defaultBaseRef, branchRef, primaryWorktreePath, now] ); invalidateLaneListCache(); }; diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts index 87993d3da..ba5d75caf 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts @@ -581,6 +581,16 @@ export class RemoteConnectionService { } } + /** Call a machine-scoped runtime RPC that does not require a project. */ + async callMachineMethod( + targetId: string, + method: string, + params: Record = {}, + ): Promise { + const target = this.requireTargetForImplicitUse(targetId); + return (await this.callMachine(target, method, params)) as T; + } + dispose(): void { this.stopAutoconnect(); this.pool.dispose(); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index ffc143382..45b09d307 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -581,6 +581,10 @@ import type { BuiltInBrowserStatus, BuiltInBrowserTabArgs, BuiltInBrowserTabTargetArgs, + PersonalChatCallArgs, + PersonalChatCallResponse, + PersonalChatStreamEventsArgs, + PersonalChatStreamEventsResult, RemoteRuntimeActionRequest, RemoteRuntimeActionResult, RemoteRuntimeConnectionSnapshot, @@ -792,6 +796,14 @@ declare global { options?: { manual?: boolean }, ) => Promise<{ disconnected: boolean }>; }; + personalChats: { + call: ( + request: PersonalChatCallArgs, + ) => Promise; + streamEvents: ( + request?: PersonalChatStreamEventsArgs, + ) => Promise; + }; keybindings: { get: () => Promise; set: (overrides: KeybindingOverride[]) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 05081bf01..fa290a081 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -594,6 +594,10 @@ import type { BuiltInBrowserStatus, BuiltInBrowserTabArgs, BuiltInBrowserTabTargetArgs, + PersonalChatCallArgs, + PersonalChatCallResponse, + PersonalChatStreamEventsArgs, + PersonalChatStreamEventsResult, RemoteRuntimeActionRequest, RemoteRuntimeActionResult, RemoteRuntimeBufferedEvent, @@ -3594,6 +3598,16 @@ contextBridge.exposeInMainWorld("ade", { return result; }, }, + personalChats: { + call: async ( + request: PersonalChatCallArgs, + ): Promise => + ipcRenderer.invoke(IPC.personalChatsCall, request), + streamEvents: async ( + request: PersonalChatStreamEventsArgs = {}, + ): Promise => + ipcRenderer.invoke(IPC.personalChatsStreamEvents, request), + }, keybindings: { get: async (): Promise => callProjectRuntimeActionOr("keybindings", "get", {}, () => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 9a136a439..c2b15f374 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -3133,6 +3133,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { platform: "darwin", }; + const browserMockPersonalChats: any[] = []; + (window as any).ade = { app: { ping: resolved("pong" as const), @@ -4455,6 +4457,58 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { getDelta: resolvedArg(null), onChanged: noop, }, + personalChats: { + call: async ({ action, args = {} }: any) => { + let result: any = null; + if (action === "list") result = [...browserMockPersonalChats]; + if (action === "modelCatalog") result = { groups: [], fetchedAt: new Date().toISOString() }; + if (action === "models") result = []; + if (action === "create") { + const now = new Date().toISOString(); + result = { + sessionId: `personal-browser-${Date.now()}`, + title: args.title ?? null, + goal: null, + summary: null, + lastOutputPreview: null, + provider: args.provider ?? "codex", + model: args.model ?? DEFAULT_BROWSER_MOCK_CODEX_MODEL, + modelId: args.modelId ?? DEFAULT_BROWSER_MOCK_CODEX_MODEL, + status: "idle", + surface: "personal", + permissionMode: args.permissionMode ?? "default", + reasoningEffort: args.reasoningEffort ?? null, + fastMode: args.fastMode === true, + createdAt: now, + updatedAt: now, + lastActivityAt: now, + }; + browserMockPersonalChats.unshift(result); + } + if (action === "getSummary") result = browserMockPersonalChats.find((chat) => chat.sessionId === args.sessionId) ?? null; + if (action === "getEventHistory") result = { sessionId: args.sessionId ?? "", events: [], truncated: false, sessionFound: true }; + if (action === "getEventHistoryPage") result = { sessionId: args.sessionId ?? "", events: [], nextBeforeOffset: null, hasMore: false }; + if (action === "updateSession") { + const chat = browserMockPersonalChats.find((entry) => entry.sessionId === args.sessionId); + if (chat) Object.assign(chat, args, { updatedAt: new Date().toISOString() }); + result = chat ?? null; + } + if (action === "terminalCreate") { + const id = `personal-terminal-${Date.now()}`; + result = { ptyId: id, sessionId: id, pid: null }; + } + if (action === "terminalWrite") result = { ok: true }; + if (action === "terminalResize") result = { ok: true, cols: args.cols, rows: args.rows }; + if (action === "terminalDispose") result = { disposed: true, reason: "disposed" }; + if (action === "archive" || action === "delete") { + const index = browserMockPersonalChats.findIndex((chat) => chat.sessionId === args.sessionId); + if (index >= 0) browserMockPersonalChats.splice(index, 1); + result = { ok: true }; + } + return { action, result }; + }, + streamEvents: async ({ cursor = 0 }: any = {}) => ({ events: [], nextCursor: cursor, hasMore: false }), + }, agentChat: { list: async (args: any = {}) => listMockAgentChatSummaries(args), getSummary: async (args: any = {}) => { diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index a0ebab493..17cec2d03 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -86,6 +86,9 @@ const SettingsPage = React.lazy(() => const WorkspaceGraphPage = React.lazy(() => import("../graph/WorkspaceGraphPage").then((m) => ({ default: m.WorkspaceGraphPage })) ); +const PersonalChatsPage = React.lazy(() => + import("../personalChats/PersonalChatsPage").then((m) => ({ default: m.PersonalChatsPage })) +); const ctoRoute = createPreloadableRoute<{ active?: boolean }>(() => import("../cto/CtoPage").then((m) => ({ default: m.CtoPage })) ); @@ -704,6 +707,7 @@ function ProjectTabHost() { const storesRef = React.useRef(new Map()); const lruRef = React.useRef([]); const [routesBySurfaceKey, setRoutesBySurfaceKey] = React.useState>({}); + const isPersonalChatsRoute = location.pathname === "/chats" || location.pathname.startsWith("/chats/"); const isExternalFilesRoute = location.pathname === "/files" && new URLSearchParams(location.search).has("externalPath"); const activeBinding = !showWelcome && activeProject?.rootPath ? bindingForProject(activeProject, activeProjectBinding) @@ -747,6 +751,7 @@ function ProjectTabHost() { }, [activeSurfaceKey]); React.useEffect(() => { + if (isPersonalChatsRoute) return; const previousSurfaceKey = previousActiveSurfaceKeyRef.current; if (previousSurfaceKey === activeSurfaceKey) return; const currentRoute = serializeStoredProjectRoute(location); @@ -770,7 +775,7 @@ function ProjectTabHost() { if (currentRoute !== nextRoute) { navigate(nextRoute, { replace: true }); } - }, [activeSurfaceKey, location, navigate, routesBySurfaceKey]); + }, [activeSurfaceKey, isPersonalChatsRoute, location, navigate, routesBySurfaceKey]); React.useEffect(() => { if (!activeSurfaceKey) return; @@ -860,7 +865,7 @@ function ProjectTabHost() { return GuardLoadingFallback; } - if (!activeProject || showWelcome || mountedProjects.length === 0) { + if (!isPersonalChatsRoute && (!activeProject || showWelcome || mountedProjects.length === 0)) { return ( @@ -896,7 +901,7 @@ function ProjectTabHost() { return ( ); })} + {isPersonalChatsRoute ? ( + + + + + + ) : null} {transitionLabel ? : null} ); diff --git a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx index 1669fecf3..3adb005bd 100644 --- a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx +++ b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx @@ -187,6 +187,12 @@ vi.mock("../lanes/LanesPage", async () => { }; }); +vi.mock("../personalChats/PersonalChatsPage", () => ({ + PersonalChatsPage: ({ standalone = false }: { standalone?: boolean }) => ( +
+ ), +})); + describe("App Work route keep-alive", () => { beforeEach(() => { cleanup(); @@ -264,6 +270,30 @@ describe("App Work route keep-alive", () => { expect(workLifecycle.unmounts).toBe(0); }, ROUTE_INTEGRATION_TIMEOUT_MS); + it("parks the project surface while projectless chats are open inside a project", async () => { + window.history.replaceState({}, "", "/chats"); + const { App } = await import("./App"); + + render(); + + expect((await screen.findByTestId("personal-chats-page")).getAttribute("data-standalone")).toBe("false"); + expect(screen.getByTestId("work-page").getAttribute("data-active")).toBe("false"); + expect(screen.getByTestId("work-page").closest("[aria-hidden='true']")).not.toBeNull(); + }, ROUTE_INTEGRATION_TIMEOUT_MS); + + it("opens projectless chats when no project is selected", async () => { + appStoreState.project = { rootPath: "" }; + appStoreState.showWelcome = true; + window.history.replaceState({}, "", "/chats"); + const { App } = await import("./App"); + + render(); + + expect((await screen.findByTestId("personal-chats-page")).getAttribute("data-standalone")).toBe("true"); + expect(screen.queryByTestId("work-page")).toBeNull(); + expect(screen.queryByTestId("project-page")).toBeNull(); + }, ROUTE_INTEGRATION_TIMEOUT_MS); + it("parks the native Work browser view when the Work route is backgrounded", async () => { const { App } = await import("./App"); diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index fa0a465e7..bfa70af17 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -1141,6 +1141,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { "/automations": "tab-tint-automations", "/cto": "tab-tint-cto", "/settings": "tab-tint-settings", + "/chats": "tab-tint-work", }; return tintMap[primaryTabPath(location.pathname)] ?? ""; }, [location.pathname]); @@ -1233,7 +1234,10 @@ export function AppShell({ children }: { children: React.ReactNode }) { )} >
- + navigate("/work", { replace: true })} + />
{!hideSidebar && projectMissing && project?.rootPath ? ( diff --git a/apps/desktop/src/renderer/components/app/TabNav.test.tsx b/apps/desktop/src/renderer/components/app/TabNav.test.tsx index c5dca2d0a..4b32c2e9b 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.test.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { TabNav } from "./TabNav"; import { useAppStore } from "../../state/appStore"; @@ -47,6 +47,7 @@ describe("TabNav", () => { }); afterEach(() => { + cleanup(); Object.defineProperty(globalThis.window, "ade", { configurable: true, writable: true, @@ -78,4 +79,18 @@ describe("TabNav", () => { expect(globalThis.window.ade.app.openExternal).toHaveBeenCalledWith("https://github.com/arul28"); }); + + it("keeps Chats available and active without a project", () => { + useAppStore.setState({ project: null, showWelcome: true } as any); + + render( + + + , + ); + + expect(screen.getByRole("link", { name: "Chats" }).getAttribute("data-active")).toBe("true"); + expect(screen.getByRole("link", { name: "Work" }).getAttribute("aria-disabled")).toBe("true"); + expect(screen.getByRole("link", { name: "Review" }).getAttribute("aria-disabled")).toBe("true"); + }); }); diff --git a/apps/desktop/src/renderer/components/app/TabNav.tsx b/apps/desktop/src/renderer/components/app/TabNav.tsx index eacc0ccda..e99960914 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.tsx @@ -11,6 +11,7 @@ import { ClockCounterClockwise, Robot, Brain, + ChatCircleDots, GearSix, } from "@phosphor-icons/react"; import { cn } from "../ui/cn"; @@ -89,6 +90,7 @@ const TAB_TOOLTIP_BY_PATH: Record> = }; function primaryTabPath(pathname: string): string { + if (pathname === "/chats" || pathname.startsWith("/chats/")) return "/chats"; const match = mainItems.find((item) => pathname === item.to || pathname.startsWith(`${item.to}/`)); if (match) return match.to; return pathname === settingsItem.to || pathname.startsWith(`${settingsItem.to}/`) ? settingsItem.to : pathname; @@ -309,6 +311,29 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) {/* Spacer pushes settings to bottom */}
+ + + {primaryTabPath(location.pathname) === "/chats" ?
: null} + + + + Chats + + + {/* GitHub profile avatar — only shows when token is stored, a login is known, and the image loads */} {githubLogin && !avatarBroken ? (
); })} - {isNewTabOpen && ( + {standaloneChatsActive ? ( +
+ + Chats + +
+ ) : null} + {isNewTabOpen && !standaloneChatsActive && (
void; onAddAttachment?: (attachment: AgentChatFileRef) => void; onInsertDraft?: (text: string) => void; @@ -720,11 +723,15 @@ async function cropBrowserScreenshot( export function ChatBuiltInBrowserPanel({ sessionId, + projectRootOverride, onAddContext, onAddAttachment, onInsertDraft, }: ChatBuiltInBrowserPanelProps) { - const projectRoot = useAppStore(selectActiveProjectRoot); + const activeProjectRoot = useAppStore(selectActiveProjectRoot); + const projectRoot = projectRootOverride === undefined + ? activeProjectRoot + : projectRootOverride; const browserSurfaceRef = useRef(null); const browserWebviewsRef = useRef>(new Map()); const browserWebviewAttachCleanupRef = useRef void>>(new Map()); @@ -755,12 +762,16 @@ export function ChatBuiltInBrowserPanel({ const [browserInputSuppressed, setBrowserInputSuppressed] = useState(false); const [webviewNavigationNonce, setWebviewNavigationNonce] = useState(0); const browserScope = useMemo( - () => (projectRoot ? { projectRoot } : {}), - [projectRoot], + () => (projectRootOverride === null + ? { profileScope: "global" } + : projectRoot + ? { projectRoot } + : {}), + [projectRoot, projectRootOverride], ); const withBrowserScope = useCallback(>(args: T): T & BrowserProjectScopeArgs => ( - (projectRoot ? { ...args, projectRoot } : args) as T & BrowserProjectScopeArgs - ), [projectRoot]); + ({ ...args, ...browserScope }) as T & BrowserProjectScopeArgs + ), [browserScope]); const syncBrowserInputSuppressedState = useCallback(() => { setBrowserInputSuppressed(browserInputSuppressedRef.current || browserOverlayOccludedRef.current); }, []); diff --git a/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.tsx b/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.tsx new file mode 100644 index 000000000..fd1178b57 --- /dev/null +++ b/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.tsx @@ -0,0 +1,564 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Archive, + ArrowLeft, + ChatCircleDots, + DotsThree, + Globe, + House, + MagnifyingGlass, + PaperPlaneTilt, + Plus, + SpinnerGap, + Stop, + TerminalWindow, + Trash, + X, +} from "@phosphor-icons/react"; +import { useNavigate } from "react-router-dom"; +import type { + AgentChatEventEnvelope, + AgentChatEventHistorySnapshot, + AgentChatModelCatalog, + AgentChatPermissionMode, + AgentChatSessionSummary, + PersonalChatAction, + PersonalChatCallArgs, + PersonalChatCallResponse, +} from "../../../shared/types"; +import { + MODEL_REGISTRY, + getModelById, + getRuntimeModelRefForDescriptor, + modelSupportsFastMode, + resolveProviderGroupForModel, + type ModelDescriptor, +} from "../../../shared/modelRegistry"; +import { cn } from "../ui/cn"; +import { AgentChatMessageList } from "../chat/AgentChatMessageList"; +import { ChatBuiltInBrowserPanel } from "../chat/ChatBuiltInBrowserPanel"; +import { ChatSurfaceShell } from "../chat/ChatSurfaceShell"; +import { PersonalTerminalPanel } from "./PersonalTerminalPanel"; +import { buildChatAppearanceRootStyle } from "../chat/chatAppearance"; +import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; +import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; +import { descriptorsFromAgentChatModelCatalog } from "../shared/ModelPicker/modelCatalog"; +import { ToolLogo } from "../terminals/ToolLogos"; +import { isWebClientMode } from "../../lib/webClientMode"; +import { useAppStore } from "../../state/appStore"; + +type ToolPanel = "browser" | "terminal" | null; + +type PersonalChatsBridge = { + call(request: PersonalChatCallArgs): Promise; + streamEvents(request?: { cursor?: number; limit?: number }): Promise<{ + events: Array<{ id: number; payload: Record }>; + nextCursor: number; + hasMore: boolean; + }>; +}; + +const EMPTY_EVENTS: AgentChatEventEnvelope[] = []; +const DEFAULT_MODEL_ID = "openai/gpt-5.4"; + +function bridge(): PersonalChatsBridge { + const candidate = (window.ade as typeof window.ade & { personalChats?: PersonalChatsBridge }).personalChats; + if (!candidate) throw new Error("Personal chats are not available in this ADE runtime."); + return candidate; +} + +function resultOf(response: PersonalChatCallResponse | T): T { + if (response && typeof response === "object" && "result" in response) { + return (response as PersonalChatCallResponse).result as T; + } + return response as T; +} + +async function callPersonal(action: PersonalChatAction, args?: Record): Promise { + const request = (args === undefined ? { action } : { action, args }) as PersonalChatCallArgs; + return resultOf(await bridge().call(request)); +} + +function sessionTitle(session: AgentChatSessionSummary): string { + const title = session.title?.trim() || session.goal?.trim() || session.summary?.trim(); + if (title) return title; + return getModelById(session.modelId ?? "")?.displayName ?? "New chat"; +} + +function sessionPreview(session: AgentChatSessionSummary): string { + return session.lastOutputPreview?.trim() || session.summary?.trim() || "Start a conversation"; +} + +function relativeTime(value: string | null | undefined): string { + if (!value) return ""; + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return ""; + const minutes = Math.max(0, Math.floor((Date.now() - timestamp) / 60_000)); + if (minutes < 1) return "now"; + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + return days < 7 ? `${days}d` : new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +function groupLabel(value: string | null | undefined): string { + const timestamp = value ? Date.parse(value) : NaN; + if (!Number.isFinite(timestamp)) return "Older"; + const days = Math.floor((Date.now() - timestamp) / 86_400_000); + if (days <= 0) return "Today"; + if (days === 1) return "Yesterday"; + if (days < 7) return "Previous 7 days"; + return "Older"; +} + +function envelopeFromPayload(payload: Record): AgentChatEventEnvelope | null { + const candidates = [payload.envelope, payload.chatEvent, payload.event, payload]; + for (const candidate of candidates) { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue; + const record = candidate as Record; + if ( + typeof record.sessionId === "string" + && typeof record.timestamp === "string" + && record.event + && typeof record.event === "object" + ) { + return record as unknown as AgentChatEventEnvelope; + } + } + return null; +} + +function eventKey(event: AgentChatEventEnvelope): string { + const body = event.event as unknown as Record; + return [event.sessionId, event.timestamp, body.type ?? "", body.itemId ?? "", body.messageId ?? ""].join(":"); +} + +function mergeEvents(current: AgentChatEventEnvelope[], incoming: AgentChatEventEnvelope[]): AgentChatEventEnvelope[] { + if (!incoming.length) return current; + const seen = new Set(current.map(eventKey)); + const next = [...current]; + for (const event of incoming) { + const key = eventKey(event); + if (seen.has(key)) continue; + seen.add(key); + next.push(event); + } + next.sort((left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp)); + return next; +} + +function providerToolType(provider: string): Parameters[0]["toolType"] { + if (provider === "claude") return "claude-chat"; + if (provider === "codex") return "codex-chat"; + if (provider === "cursor") return "cursor"; + if (provider === "droid") return "droid-chat"; + return "opencode-chat"; +} + +export function PersonalChatsPage({ standalone = false }: { standalone?: boolean }) { + const navigate = useNavigate(); + const projectBinding = useAppStore((state) => state.projectBinding); + const chatFontSizePx = useAppStore((state) => state.chatFontSizePx); + const chatTranscriptDensity = useAppStore((state) => state.chatTranscriptDensity); + const chatChromeTint = useAppStore((state) => state.chatChromeTint); + const chatShellGeometry = useAppStore((state) => state.chatShellGeometry); + const targetKey = projectBinding?.kind === "remote" ? projectBinding.key : "local-machine"; + const [sessions, setSessions] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [eventsBySession, setEventsBySession] = useState>({}); + const [catalog, setCatalog] = useState(null); + const [modelId, setModelId] = useState(DEFAULT_MODEL_ID); + const [reasoningEffort, setReasoningEffort] = useState(null); + const [permissionMode, setPermissionMode] = useState("default"); + const [fastMode, setFastMode] = useState(false); + const [draft, setDraft] = useState(""); + const [query, setQuery] = useState(""); + const [loading, setLoading] = useState(true); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + const [menuId, setMenuId] = useState(null); + const [toolPanel, setToolPanel] = useState(null); + const [mobileListOpen, setMobileListOpen] = useState(true); + const cursorRef = useRef(0); + const targetGenerationRef = useRef(0); + + const refreshSessions = useCallback(async (generation = targetGenerationRef.current) => { + const rows = await callPersonal("list", { includeArchived: false }); + if (generation !== targetGenerationRef.current) return; + const ordered = [...(Array.isArray(rows) ? rows : [])].sort( + (left, right) => Date.parse(right.lastActivityAt) - Date.parse(left.lastActivityAt), + ); + setSessions(ordered); + setSelectedId((current) => current && ordered.some((row) => row.sessionId === current) ? current : null); + }, []); + + useEffect(() => { + const generation = ++targetGenerationRef.current; + cursorRef.current = 0; + setSessions([]); + setSelectedId(null); + setEventsBySession({}); + setToolPanel(null); + setLoading(true); + setError(null); + void Promise.all([ + refreshSessions(generation), + callPersonal("modelCatalog", { mode: "cached" }) + .then((next) => { + if (generation === targetGenerationRef.current) setCatalog(next); + }), + ]).catch((reason) => { + if (generation === targetGenerationRef.current) { + setError(reason instanceof Error ? reason.message : String(reason)); + } + }).finally(() => { + if (generation === targetGenerationRef.current) setLoading(false); + }); + }, [refreshSessions, targetKey]); + + useEffect(() => { + let cancelled = false; + const generation = targetGenerationRef.current; + let timer: number | null = null; + const poll = async () => { + if (cancelled || generation !== targetGenerationRef.current) return; + try { + const result = await bridge().streamEvents({ cursor: cursorRef.current, limit: 200 }); + if (cancelled || generation !== targetGenerationRef.current) return; + cursorRef.current = result.nextCursor ?? cursorRef.current; + const envelopes = (result.events ?? []) + .map((entry) => envelopeFromPayload(entry.payload ?? {})) + .filter((event): event is AgentChatEventEnvelope => event != null); + if (envelopes.length) { + setEventsBySession((current) => { + const next = { ...current }; + for (const event of envelopes) { + next[event.sessionId] = mergeEvents(next[event.sessionId] ?? [], [event]); + } + return next; + }); + void refreshSessions(generation).catch(() => undefined); + } + } catch (reason) { + if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + if (!cancelled) timer = window.setTimeout(poll, 700); + } + }; + void poll(); + return () => { + cancelled = true; + if (timer != null) window.clearTimeout(timer); + }; + }, [refreshSessions, targetKey]); + + useEffect(() => { + if (!selectedId) return; + const generation = targetGenerationRef.current; + setMobileListOpen(false); + void callPersonal("getEventHistory", { + sessionId: selectedId, + maxEvents: 1_500, + maxBytes: 4 * 1024 * 1024, + }).then((snapshot) => { + if (generation !== targetGenerationRef.current || snapshot.sessionId !== selectedId) return; + setEventsBySession((current) => ({ + ...current, + [selectedId]: mergeEvents(snapshot.events ?? [], current[selectedId] ?? []), + })); + }).catch((reason) => setError(reason instanceof Error ? reason.message : String(reason))); + }, [selectedId, targetKey]); + + const selectedSession = sessions.find((session) => session.sessionId === selectedId) ?? null; + const selectedEvents = selectedId ? eventsBySession[selectedId] ?? EMPTY_EVENTS : EMPTY_EVENTS; + const dynamicCatalog = useMemo( + () => catalog ? descriptorsFromAgentChatModelCatalog(catalog) : null, + [catalog], + ); + const models = useMemo(() => { + if (dynamicCatalog?.models.length) return dynamicCatalog.models; + return MODEL_REGISTRY.filter((model) => !model.deprecated); + }, [dynamicCatalog]); + const availableModelIds = useMemo( + () => dynamicCatalog?.availableModelIds.length ? dynamicCatalog.availableModelIds : models.map((model) => model.id), + [dynamicCatalog, models], + ); + + useEffect(() => { + if (!selectedSession) return; + setModelId(selectedSession.modelId ?? modelId); + setReasoningEffort(selectedSession.reasoningEffort ?? null); + setPermissionMode(selectedSession.permissionMode ?? "default"); + setFastMode(selectedSession.fastMode === true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedSession?.sessionId]); + + useEffect(() => { + if (availableModelIds.includes(modelId)) return; + const fallback = availableModelIds[0]; + if (fallback) setModelId(fallback); + }, [availableModelIds, modelId]); + + const updateSelected = useCallback(async (patch: Record) => { + if (!selectedId) return; + await callPersonal("updateSession", { sessionId: selectedId, ...patch }); + await refreshSessions(); + }, [refreshSessions, selectedId]); + + const handleModelChange = useCallback((nextModelId: string) => { + setModelId(nextModelId); + const descriptor = models.find((model) => model.id === nextModelId) ?? getModelById(nextModelId); + if (selectedId && descriptor) { + void updateSelected({ + modelId: descriptor.id, + model: getRuntimeModelRefForDescriptor(descriptor), + provider: resolveProviderGroupForModel(descriptor), + }).catch((reason) => setError(reason instanceof Error ? reason.message : String(reason))); + } + }, [models, selectedId, updateSelected]); + + const createSession = useCallback(async (): Promise => { + const descriptor = models.find((model) => model.id === modelId) ?? getModelById(modelId) ?? models[0]; + if (!descriptor) throw new Error("Choose an available model before starting a chat."); + const created = await callPersonal("create", { + provider: resolveProviderGroupForModel(descriptor), + model: getRuntimeModelRefForDescriptor(descriptor), + modelId: descriptor.id, + reasoningEffort, + permissionMode, + fastMode: modelSupportsFastMode(descriptor) ? fastMode : false, + title: null, + }); + await refreshSessions(); + setSelectedId(created.sessionId); + return created; + }, [fastMode, modelId, models, permissionMode, reasoningEffort, refreshSessions]); + + const submit = useCallback(async () => { + const text = draft.trim(); + if (!text || sending) return; + setSending(true); + setError(null); + try { + const session = selectedSession ?? await createSession(); + await callPersonal("send", { + sessionId: session.sessionId, + text, + reasoningEffort, + }); + setDraft(""); + setSelectedId(session.sessionId); + await refreshSessions(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setSending(false); + } + }, [createSession, draft, reasoningEffort, refreshSessions, selectedSession, sending]); + + const removeSession = useCallback(async (sessionId: string, action: "archive" | "delete") => { + setMenuId(null); + await callPersonal(action, { sessionId }); + setEventsBySession((current) => { + const next = { ...current }; + delete next[sessionId]; + return next; + }); + if (selectedId === sessionId) setSelectedId(null); + await refreshSessions(); + }, [refreshSessions, selectedId]); + + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase(); + return needle + ? sessions.filter((session) => `${sessionTitle(session)} ${sessionPreview(session)}`.toLowerCase().includes(needle)) + : sessions; + }, [query, sessions]); + const grouped = useMemo(() => { + const groups = new Map(); + for (const session of filtered) { + const label = groupLabel(session.lastActivityAt); + groups.set(label, [...(groups.get(label) ?? []), session]); + } + return [...groups.entries()]; + }, [filtered]); + + const appearanceStyle = useMemo( + () => buildChatAppearanceRootStyle({ chatFontSizePx, transcriptDensity: chatTranscriptDensity }), + [chatFontSizePx, chatTranscriptDensity], + ); + const browserAvailable = !isWebClientMode() && Boolean(window.ade?.builtInBrowser); + const turnActive = selectedSession?.status === "active"; + const selectedDescriptor = models.find((model) => model.id === modelId) ?? getModelById(modelId); + + return ( +
+ + +
+ + +
{selectedSession ? sessionTitle(selectedSession) : "New chat"}
+ {browserAvailable ? ( + + ) : null} + +
+ )} + footer={( +
+ {error ?
{error}
: null} +
+